diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..d1ef50060 --- /dev/null +++ b/.dockerignore @@ -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/ diff --git a/.env.example b/.env.example index add144845..9d4c2cd7d 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0bfe1562c..e170f7a39 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..2366ba12b --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/src/core/cli.rs b/src/core/cli.rs index e67937a2d..ba5ca286a 100644 --- a/src/core/cli.rs +++ b/src/core/cli.rs @@ -38,6 +38,7 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> { fn run_server_command(args: &[String]) -> Result<()> { let mut port: Option = None; + let mut host: Option = 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 ] [--jsonrpc-only] [-v|--verbose]"); + println!("Usage: openhuman run [--host ] [--port ] [--jsonrpc-only] [-v|--verbose]"); println!(); + println!( + " --host Bind address (default: 127.0.0.1 or OPENHUMAN_CORE_HOST)" + ); println!( " --port 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> { fn print_general_help(grouped: &BTreeMap>) { println!("OpenHuman core CLI\n"); println!("Usage:"); - println!(" openhuman run [--port ] [--jsonrpc-only] [--verbose]"); + println!(" openhuman run [--host ] [--port ] [--jsonrpc-only] [--verbose]"); println!(" openhuman repl [--verbose] [--eval ''] [--batch]"); println!(" openhuman call --method [--params '']"); println!(" openhuman [--param value ...]\n"); diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index f062f7814..0933300bc 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -416,11 +416,60 @@ fn core_port() -> u16 { .unwrap_or(7788) } -pub async fn run_server(port: Option, 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, + 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);