feat(docker): Dockerfile, cloud server support, and parallel release pipeline (#174)

* added doceker build

* added docker files

* feat(ci): add Docker build phase to release pipeline and .dockerignore

Restructure release.yml into parallel build phases: build-desktop (matrix)
and build-docker run concurrently after create-release. Docker image is
pushed to GHCR and pull instructions are appended to release notes.
publish-release now gates on both phases succeeding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: apply cargo fmt to jsonrpc host binding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(review): address PR review findings

- .env.example: comment out OPENHUMAN_CORE_HOST so Docker's 0.0.0.0
  default isn't overridden when users copy the example file
- cli.rs: add --host to print_general_help() usage line for consistency
- jsonrpc.rs: use tuple bind (host, port) for IPv6 support, add
  debug logging with source tracking (CLI/env/default) before bind
- release.yml: push only staging tag in build-docker, promote to
  versioned + latest in publish-release after all builds succeed;
  cleanup-failed-release deletes the staging image on failure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Steven Enamakel
2026-04-01 11:58:37 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent f5b66de2f1
commit 6f8b5d6c9f
6 changed files with 331 additions and 154 deletions
+39
View File
@@ -0,0 +1,39 @@
# Build artifacts
target/
app/src-tauri/target/
# Node / frontend (not needed for core binary)
app/
node_modules/
dist/
.vite/
# IDE / editor
.idea/
.vscode/
*.swp
*.swo
*~
# Git
.git/
.gitmodules
# CI / docs
.github/
docs/
*.md
!Cargo.lock
# Environment / secrets
.env
.env.*
!.env.example
# OS files
.DS_Store
Thumbs.db
# Tests (not needed in build context)
tests/
scripts/
+3
View File
@@ -23,6 +23,9 @@ JWT_TOKEN=
# ---------------------------------------------------------------------------
# Core process
# ---------------------------------------------------------------------------
# [optional] Default: 127.0.0.1 (use 0.0.0.0 for Docker / cloud).
# Leave unset to keep the default; the Docker image sets 0.0.0.0 automatically.
# OPENHUMAN_CORE_HOST=
# [optional] Default: 7788
OPENHUMAN_CORE_PORT=7788
# [optional] Default: http://127.0.0.1:7788/rpc
+140 -147
View File
@@ -14,12 +14,38 @@ on:
permissions:
contents: write
packages: write
concurrency:
group: release-main
cancel-in-progress: false
# ---------------------------------------------------------------------------
# Job dependency graph
#
# prepare-release
# │
# ├─── create-release
# │ │
# │ ┌────┴────────────┐
# │ │ │
# │ build-desktop build-docker
# │ (matrix: macOS, (GHCR image)
# │ linux, windows)
# │ │ │
# │ └────┬────────────┘
# │ │
# │ publish-release
# │ │
# │ notify-discord
# │
# └─── cleanup-failed-release (on failure)
# ---------------------------------------------------------------------------
jobs:
# =========================================================================
# Phase 1: Version bump, commit, tag
# =========================================================================
prepare-release:
name: Prepare release commit and tag
runs-on: ubuntu-latest
@@ -156,6 +182,9 @@ jobs:
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
# =========================================================================
# Phase 2: Create draft GitHub release
# =========================================================================
create-release:
name: Create GitHub release
runs-on: ubuntu-latest
@@ -200,8 +229,11 @@ jobs:
core.setOutput('release_id', String(release.data.id));
core.setOutput('upload_url', release.data.upload_url);
build-artifacts:
name: Build and upload artifacts
# =========================================================================
# Phase 3a: Build desktop artifacts (parallel matrix)
# =========================================================================
build-desktop:
name: "Desktop: ${{ matrix.settings.artifact_suffix }}"
needs: [prepare-release, create-release]
runs-on: ${{ matrix.settings.platform }}
environment: Production
@@ -727,12 +759,60 @@ jobs:
path: |
${{ steps.cli-paths.outputs.cli_path }}
# =========================================================================
# Phase 3b: Build & push Docker image (runs parallel with build-desktop)
# =========================================================================
build-docker:
name: "Docker: build and push"
needs: [prepare-release, create-release]
runs-on: ubuntu-latest
environment: Production
env:
REGISTRY: ghcr.io
IMAGE_NAME: tinyhumansai/openhuman-core
steps:
- name: Checkout tag
uses: actions/checkout@v4
with:
ref: ${{ needs.prepare-release.outputs.tag }}
fetch-depth: 1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push staging tag
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:staging-${{ needs.prepare-release.outputs.tag }}
cache-from: type=gha
cache-to: type=gha,mode=max
labels: |
org.opencontainers.image.title=openhuman-core
org.opencontainers.image.description=OpenHuman core JSON-RPC server
org.opencontainers.image.version=${{ needs.prepare-release.outputs.version }}
org.opencontainers.image.source=https://github.com/tinyhumansai/openhuman
org.opencontainers.image.revision=${{ needs.prepare-release.outputs.sha }}
# =========================================================================
# Phase 4: Publish the draft release (waits for ALL build phases)
# =========================================================================
publish-release:
name: Publish draft release
runs-on: ubuntu-latest
environment: Production
needs: [create-release, build-artifacts]
if: needs.build-artifacts.result == 'success'
needs: [prepare-release, create-release, build-desktop, build-docker]
if: needs.build-desktop.result == 'success' && needs.build-docker.result == 'success'
steps:
- name: Validate required installer assets exist
uses: actions/github-script@v7
@@ -764,6 +844,33 @@ jobs:
}
core.info('All required installer assets are present.');
- name: Promote Docker image from staging to release tags
env:
REGISTRY: ghcr.io
IMAGE_NAME: tinyhumansai/openhuman-core
TAG: ${{ needs.prepare-release.outputs.tag }}
run: |
# Log in to GHCR
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
STAGING="${REGISTRY}/${IMAGE_NAME}:staging-${TAG}"
VERSIONED="${REGISTRY}/${IMAGE_NAME}:${TAG}"
LATEST="${REGISTRY}/${IMAGE_NAME}:latest"
echo "Promoting ${STAGING} → ${VERSIONED}, ${LATEST}"
docker buildx imagetools create --tag "${VERSIONED}" --tag "${LATEST}" "${STAGING}"
- name: Append Docker pull instructions to release notes
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare-release.outputs.tag }}
IMAGE_NAME: tinyhumansai/openhuman-core
run: |
gh release view "${TAG}" --repo tinyhumansai/openhuman --json body -q .body > /tmp/body.md
printf '\n---\n\n### Docker\n\n```\ndocker pull ghcr.io/%s:%s\ndocker run -p 7788:7788 --env-file .env ghcr.io/%s:%s\n```\n' \
"${IMAGE_NAME}" "${TAG}" "${IMAGE_NAME}" "${TAG}" >> /tmp/body.md
gh release edit "${TAG}" --repo tinyhumansai/openhuman --notes-file /tmp/body.md
- name: Publish release
uses: actions/github-script@v7
with:
@@ -777,153 +884,19 @@ jobs:
});
core.info(`Published release ${releaseId}`);
# notify-discord:
# name: Notify Discord about release
# runs-on: ubuntu-latest
# environment: Production
# needs: [prepare-release, create-release, build-artifacts, publish-release]
# if: needs.publish-release.result == 'success' && needs.build-artifacts.result == 'success'
# steps:
# - name: Post release notification to Discord
# continue-on-error: true
# uses: actions/github-script@v7
# env:
# DISCORD_RELEASE_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }}
# RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
# RELEASE_TAG: ${{ needs.prepare-release.outputs.tag }}
# with:
# script: |
# const webhookUrl = process.env.DISCORD_RELEASE_WEBHOOK_URL;
# const releaseId = Number(process.env.RELEASE_ID);
# const releaseTag = process.env.RELEASE_TAG;
# if (!webhookUrl) {
# core.warning('DISCORD_RELEASE_WEBHOOK_URL is not configured; skipping Discord release notification.');
# return;
# }
# if (!Number.isFinite(releaseId) || releaseId <= 0) {
# core.warning(`Invalid release id "${process.env.RELEASE_ID}"; skipping Discord release notification.`);
# return;
# }
# const { owner, repo } = context.repo;
# const maxDescriptionLength = 3500;
# const maxAssetLines = 8;
# function truncate(text, maxLength) {
# if (!text) return '';
# if (text.length <= maxLength) return text;
# return `${text.slice(0, maxLength - 3)}...`;
# }
# function stripMarkdownHeadings(text) {
# return text
# .replace(/^#{1,6}\s+/gm, '')
# .replace(/\r/g, '')
# .trim();
# }
# const { data: release } = await github.rest.repos.getRelease({
# owner,
# repo,
# release_id: releaseId,
# });
# const releases = await github.paginate(github.rest.repos.listReleases, {
# owner,
# repo,
# per_page: 100,
# });
# const sortedPublished = releases
# .filter((item) => !item.draft)
# .sort((a, b) => new Date(b.published_at || b.created_at || 0) - new Date(a.published_at || a.created_at || 0));
# const currentIndex = sortedPublished.findIndex((item) => item.id === release.id);
# const previousRelease = currentIndex >= 0 ? sortedPublished[currentIndex + 1] : null;
# const compareUrl = previousRelease
# ? `https://github.com/${owner}/${repo}/compare/${previousRelease.tag_name}...${release.tag_name}`
# : null;
# const releaseTitle = release.name || release.tag_name || releaseTag;
# const bodyText = stripMarkdownHeadings(release.body || '');
# const summary = bodyText
# ? truncate(bodyText, maxDescriptionLength)
# : 'GitHub release published. See the release page for full notes.';
# const assetLines = (release.assets || [])
# .slice(0, maxAssetLines)
# .map((asset) => `- [${asset.name}](${asset.browser_download_url})`);
# const extraAssetCount = Math.max((release.assets || []).length - maxAssetLines, 0);
# if (extraAssetCount > 0) {
# assetLines.push(`- ${extraAssetCount} more asset(s) available on the release page`);
# }
# const fields = [
# {
# name: 'Version',
# value: `\`${release.tag_name || releaseTag}\``,
# inline: true,
# },
# {
# name: 'Release',
# value: `[Open on GitHub](${release.html_url})`,
# inline: true,
# },
# ];
# if (compareUrl) {
# fields.push({
# name: 'Compare',
# value: `[${previousRelease.tag_name}...${release.tag_name}](${compareUrl})`,
# inline: true,
# });
# }
# if (assetLines.length > 0) {
# fields.push({
# name: 'Artifacts',
# value: assetLines.join('\n'),
# inline: false,
# });
# }
# const payload = {
# content: `New GitHub release published for \`${owner}/${repo}\`: **${releaseTitle}**`,
# embeds: [
# {
# title: releaseTitle,
# url: release.html_url,
# description: summary,
# color: release.prerelease ? 0xf0ad4e : 0x2ecc71,
# fields,
# footer: {
# text: release.prerelease ? 'Pre-release' : 'Published release',
# },
# timestamp: release.published_at || new Date().toISOString(),
# },
# ],
# };
# const response = await fetch(webhookUrl, {
# method: 'POST',
# headers: { 'Content-Type': 'application/json' },
# body: JSON.stringify(payload),
# });
# if (!response.ok) {
# const body = await response.text();
# core.warning(`Discord notification failed: HTTP ${response.status} ${body.slice(0, 300)}`);
# return;
# }
# core.info(`Posted Discord release notification for ${release.tag_name || releaseTag}.`);
# =========================================================================
# Cleanup: remove draft release + tag if ANY build phase failed
# =========================================================================
cleanup-failed-release:
name: Remove release and tag if build failed
runs-on: ubuntu-latest
environment: Production
needs: [prepare-release, create-release, build-artifacts]
if: always() && needs.create-release.result == 'success' && (needs.build-artifacts.result == 'failure' || needs.build-artifacts.result == 'cancelled')
needs: [prepare-release, create-release, build-desktop, build-docker]
if: >-
always()
&& needs.create-release.result == 'success'
&& (needs.build-desktop.result == 'failure' || needs.build-desktop.result == 'cancelled'
|| needs.build-docker.result == 'failure' || needs.build-docker.result == 'cancelled')
steps:
- name: Delete GitHub release
uses: actions/github-script@v7
@@ -963,3 +936,23 @@ jobs:
throw e;
}
}
- name: Delete staging Docker image
continue-on-error: true
env:
TAG: ${{ needs.prepare-release.outputs.tag }}
run: |
PACKAGE="openhuman-core"
STAGING_TAG="staging-${TAG}"
echo "Attempting to delete staging Docker tag: ${STAGING_TAG}"
# List package versions and find the staging tag
VERSION_ID="$(gh api \
-H "Accept: application/vnd.github+json" \
"/orgs/tinyhumansai/packages/container/${PACKAGE}/versions" \
--paginate --jq ".[] | select(.metadata.container.tags[] == \"${STAGING_TAG}\") | .id" 2>/dev/null | head -1)"
if [ -n "$VERSION_ID" ]; then
gh api -X DELETE "/orgs/tinyhumansai/packages/container/${PACKAGE}/versions/${VERSION_ID}" || true
echo "Deleted staging image version ${VERSION_ID}"
else
echo "Staging tag ${STAGING_TAG} not found or already deleted"
fi
+79
View File
@@ -0,0 +1,79 @@
# ---------------------------------------------------------------------------
# OpenHuman Core — multi-stage Docker build
# Produces a minimal image running the `openhuman-core` binary (JSON-RPC server).
#
# Build: docker build -t openhuman-core .
# Run: docker run -p 7788:7788 --env-file .env openhuman-core
# ---------------------------------------------------------------------------
# ==========================================================================
# Stage 1: Build the Rust binary
# ==========================================================================
FROM rust:1.93-bookworm AS builder
ENV DEBIAN_FRONTEND=noninteractive
# System dependencies required for compilation
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
pkg-config \
libssl-dev \
clang \
mold \
ca-certificates \
git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Cache dependencies — copy only manifests first
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
# Create a dummy src to build deps
RUN mkdir -p src && \
echo 'fn main() {}' > src/main.rs && \
echo 'pub fn run_core_from_args(_: &[String]) -> anyhow::Result<()> { Ok(()) }' > src/lib.rs && \
cargo build --release --bin openhuman-core 2>/dev/null || true && \
rm -rf src
# Copy actual source and build
COPY src/ src/
# Touch main.rs to force rebuild of our code (not deps)
RUN touch src/main.rs src/lib.rs && \
cargo build --release --bin openhuman-core
# ==========================================================================
# Stage 2: Minimal runtime image
# ==========================================================================
FROM debian:bookworm-slim AS runtime
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
libssl3 \
curl \
&& rm -rf /var/lib/apt/lists/*
# Non-root user for security
RUN useradd --create-home --shell /bin/bash openhuman
USER openhuman
WORKDIR /home/openhuman
# Copy the built binary
COPY --from=builder /build/target/release/openhuman-core /usr/local/bin/openhuman-core
# Default workspace directory
ENV OPENHUMAN_WORKSPACE=/home/openhuman/.openhuman
# Bind to all interfaces so the container is reachable
ENV OPENHUMAN_CORE_HOST=0.0.0.0
ENV OPENHUMAN_CORE_PORT=7788
ENV RUST_LOG=info
EXPOSE 7788
# Health check against the root endpoint
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -sf http://localhost:7788/health || exit 1
ENTRYPOINT ["openhuman-core"]
CMD ["serve"]
+17 -3
View File
@@ -38,6 +38,7 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> {
fn run_server_command(args: &[String]) -> Result<()> {
let mut port: Option<u16> = None;
let mut host: Option<String> = None;
let mut socketio_enabled = true;
let mut verbose = false;
let mut i = 0usize;
@@ -53,6 +54,14 @@ fn run_server_command(args: &[String]) -> Result<()> {
);
i += 2;
}
"--host" => {
host = Some(
args.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing value for --host"))?
.clone(),
);
i += 2;
}
"--jsonrpc-only" => {
socketio_enabled = false;
i += 1;
@@ -62,8 +71,11 @@ fn run_server_command(args: &[String]) -> Result<()> {
i += 1;
}
"-h" | "--help" => {
println!("Usage: openhuman run [--port <u16>] [--jsonrpc-only] [-v|--verbose]");
println!("Usage: openhuman run [--host <addr>] [--port <u16>] [--jsonrpc-only] [-v|--verbose]");
println!();
println!(
" --host <addr> Bind address (default: 127.0.0.1 or OPENHUMAN_CORE_HOST)"
);
println!(
" --port <u16> Listen address port (default: 7788 or OPENHUMAN_CORE_PORT)"
);
@@ -82,7 +94,9 @@ fn run_server_command(args: &[String]) -> Result<()> {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(async { crate::core::jsonrpc::run_server(port, socketio_enabled).await })?;
rt.block_on(async {
crate::core::jsonrpc::run_server(host.as_deref(), port, socketio_enabled).await
})?;
Ok(())
}
@@ -327,7 +341,7 @@ fn grouped_schemas() -> BTreeMap<String, Vec<ControllerSchema>> {
fn print_general_help(grouped: &BTreeMap<String, Vec<ControllerSchema>>) {
println!("OpenHuman core CLI\n");
println!("Usage:");
println!(" openhuman run [--port <u16>] [--jsonrpc-only] [--verbose]");
println!(" openhuman run [--host <addr>] [--port <u16>] [--jsonrpc-only] [--verbose]");
println!(" openhuman repl [--verbose] [--eval '<cmd>'] [--batch]");
println!(" openhuman call --method <name> [--params '<json>']");
println!(" openhuman <namespace> <function> [--param value ...]\n");
+53 -4
View File
@@ -416,11 +416,60 @@ fn core_port() -> u16 {
.unwrap_or(7788)
}
pub async fn run_server(port: Option<u16>, socketio_enabled: bool) -> anyhow::Result<()> {
fn core_host() -> String {
std::env::var("OPENHUMAN_CORE_HOST")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "127.0.0.1".to_string())
}
pub async fn run_server(
host: Option<&str>,
port: Option<u16>,
socketio_enabled: bool,
) -> anyhow::Result<()> {
let _ = all::all_registered_controllers();
let port = port.unwrap_or_else(core_port);
let bind_addr = format!("127.0.0.1:{port}");
let listener = tokio::net::TcpListener::bind(&bind_addr).await?;
let (resolved_port, port_source) = match port {
Some(p) => (p, "CLI --port"),
None => (
core_port(),
if std::env::var("OPENHUMAN_CORE_PORT").is_ok() {
"env OPENHUMAN_CORE_PORT"
} else {
"default"
},
),
};
let (resolved_host, host_source) = match host {
Some(h) => (h.to_string(), "CLI --host"),
None => (
core_host(),
if std::env::var("OPENHUMAN_CORE_HOST")
.ok()
.filter(|s| !s.is_empty())
.is_some()
{
"env OPENHUMAN_CORE_HOST"
} else {
"default"
},
),
};
log::debug!(
"[core] Bind resolution: host={resolved_host} (from {host_source}), port={resolved_port} (from {port_source})"
);
let port = resolved_port;
let host = resolved_host;
let bind_addr = format!("{host}:{port}");
let listener = tokio::net::TcpListener::bind((host.as_str(), port))
.await
.map_err(|e| {
log::error!("[core] Failed to bind to {bind_addr}: {e}");
e
})?;
let app = build_core_http_router(socketio_enabled);