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
+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