Files
hermes-agent/.github/workflows/docker.yml

757 lines
32 KiB
YAML

name: Docker Build, Test, and Publish
on:
# This workflow owns its own triggers. ci.yml does not call it.
# A reusable-workflow call keeps the caller run in progress for that full time.
# GitHub refuses ``gh run rerun`` on a run that is still in progress.
# Thus one slow advisory job blocked every rerun of the fast required jobs. A separate
# run reruns and cancels independently.
#
# Trusted main pushes resolve the environment-scoped Docker Hub secrets in
# this same workflow, never across a workflow boundary.
#
# The ``release: published`` trigger was REMOVED on purpose: a GitHub release
# event must never rebuild or rewrite the stable Docker channel. This staged
# path publishes only immutable version tags; the ordered stable publication
# controller moves stable/latest from the receipt-bound registry digest.
pull_request:
push:
branches: [main]
workflow_call:
inputs:
release-phase:
description: "Stable-release phase: 'test' or 'publish'. Empty keeps the standalone triggers."
required: false
type: string
default: ''
tag:
description: "Exact stable tag (vX.Y.Z) under release. The caller is dispatched on this tag, so github.sha is the release candidate."
required: false
type: string
default: ''
version:
description: "Release version written directly to the image install stamp."
required: false
type: string
default: ''
outputs:
manifest-digest:
description: "Immutable digest of the published versioned multi-arch manifest."
value: ${{ jobs.release-publish-manifest.outputs.digest }}
permissions:
contents: read
# Concurrency: push/release runs are NEVER cancelled so every merge gets
# its own image. PR runs reuse a PR-scoped group with
# cancel-in-progress: true so rapid pushes to the same PR collapse to
# the latest commit. Release runs include the run_id: several reusable calls
# (test/publish) of this workflow live inside ONE parent run, and a
# shared group would cancel the parent mid-release.
concurrency:
group: docker-${{ github.event.pull_request.number || github.ref }}-${{ inputs.release-phase || 'standalone' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
IMAGE_NAME: nousresearch/hermes-agent
jobs:
# Resolve the release phase. Release modes run only via workflow_call from
# the parent stable-release workflow on the tagged candidate commit.
mode:
name: Resolve release phase
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
phase: ${{ steps.resolve.outputs.phase }}
release: ${{ steps.resolve.outputs.release }}
steps:
- id: resolve
env:
PHASE: ${{ inputs.release-phase }}
run: |
set -euo pipefail
case "$PHASE" in
'') echo "phase=standalone" >> "$GITHUB_OUTPUT"; echo "release=false" >> "$GITHUB_OUTPUT" ;;
test) echo "phase=test" >> "$GITHUB_OUTPUT"; echo "release=true" >> "$GITHUB_OUTPUT" ;;
publish)
echo "phase=$PHASE" >> "$GITHUB_OUTPUT"; echo "release=true" >> "$GITHUB_OUTPUT" ;;
*) echo "::error::Invalid release-phase input: $PHASE"; exit 1 ;;
esac
# Classify the PR's changed files. ci.yml used to gate the docker call on
# its own ``detect`` outputs; now that this workflow triggers itself, it
# runs the same composite action. On push and release the classifier fails
# open (every lane true), so post-merge validation is never weakened.
# Release phases skip classification entirely: the parent already ran full
# CI and the tag dispatch has no meaningful PR diff to classify.
detect:
name: Detect affected areas
needs: [mode]
if: needs.mode.outputs.release != 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
build: ${{ steps.gate.outputs.build }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Detect affected areas
id: classify
uses: ./.github/actions/detect-changes
with:
github-token: ${{ github.token }}
- name: Decide whether to build
id: gate
env:
# The docker lane derives from python_prod (not python: the image
# copies installed code, never tests/, so tests-only PRs skip the
# build), frontend and docker_meta. classify_changes.py owns the
# formula so this gate and the nix lane cannot drift apart.
DOCKER: ${{ steps.classify.outputs.docker }}
run: |
set -euo pipefail
if [ "$DOCKER" = "true" ]; then
echo "build=true" >> "$GITHUB_OUTPUT"
else
echo "build=false" >> "$GITHUB_OUTPUT"
fi
# Build and test the image for each architecture. This job runs PR code, so
# in standalone mode it must remain secret-free. Publishing happens in the
# separate, protected publish paths after these tests pass.
#
# Runs ONLY in standalone mode and in the release 'test' phase: it builds
# the SAME Dockerfile and runs the SAME real docker-integration tests, then
# (test phase) saves the tested per-arch image archive + sha256 as run
# artifacts. No registry push, no signing secrets. The publish phase never
# rebuilds — it downloads these exact artifacts.
build:
name: Build and test image (${{ matrix.arch }})
needs: [mode, detect]
if: >-
!cancelled() && needs.mode.result == 'success' &&
((needs.mode.outputs.release != 'true' && github.repository == 'NousResearch/hermes-agent' && needs.detect.outputs.build == 'true') || needs.mode.outputs.phase == 'test')
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest-32-core
platform: linux/amd64
cache-from: type=gha,scope=docker-amd64
cache-to: type=gha,mode=max,scope=docker-amd64
# arm64 builds on the native arm64 larger runner. A build of
# linux/arm64 on an x64 host uses emulation.
- arch: arm64
runner: ubuntu-latest-32-arm-core
platform: linux/arm64
cache-from: type=gha,scope=docker-arm64
cache-to: type=gha,mode=max,scope=docker-arm64
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Release calls are dispatched on the stable tag: pin to the exact
# candidate commit, never a mutable branch ref.
ref: ${{ needs.mode.outputs.release == 'true' && github.sha || '' }}
# Dev identity is the distance from the highest reachable final
# release, so the stamp needs both its ancestry and release tags.
fetch-depth: 0
- name: Write install stamp
env:
RELEASE_VERSION: ${{ needs.mode.outputs.release == 'true' && inputs.version || '' }}
run: |
display="$RELEASE_VERSION"
if [ -z "$display" ]; then
display="$(python3 -m scripts.releases.distance)"
fi
identity=(--commit "$GITHUB_SHA")
if [ -n "$display" ]; then
base="${display%%+*}"
development="${display#*+}"
if [ "$development" = "$display" ]; then distance=0; else distance="${development%%.*}"; fi
identity+=(--base-version "$base" --display-version "$display" --distance "$distance")
fi
python3 scripts/write_install_stamp.py --output install-stamp.json \
--distribution docker --update-mechanism external --source ci "${identity[@]}"
- name: Reject profile exports in the build context
run: python3 scripts/ci/check_profile_archive_boundary.py
# Retry once on transient Docker Hub / buildkit pull failures
# (connection reset, auth token timeout, rate limiting). The action
# generates a unique builder name per invocation so the retry doesn't
# collide with the failed first attempt. A genuine persistent failure
# still fails the job — only the first attempt has continue-on-error.
# Refs: docker/setup-buildx-action#510
- name: Set up Docker Buildx
id: buildx
continue-on-error: true
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Set up Docker Buildx (retry)
if: steps.buildx.outcome == 'failure'
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
# Build once, load into the local daemon for testing. Cached
# per-arch; the push step below reuses every layer from this build.
- name: Build image (${{ matrix.arch }})
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
load: true
platforms: ${{ matrix.platform }}
tags: ${{ env.IMAGE_NAME }}:test
cache-from: ${{ matrix.cache-from }}
cache-to: ${{ (github.event_name != 'pull_request') && matrix.cache-to || '' }}
# Run the docker-integration test suite against the freshly-built
# image already loaded into the local daemon (`:test`).
#
# Piggybacking here avoids a second image build: the build step
# already loaded the image into the daemon under
# `${IMAGE_NAME}:test`, so we just point ``HERMES_TEST_IMAGE`` at
# that. The fixture's ``HERMES_TEST_IMAGE`` branch (see
# tests/docker/conftest.py:62-63) short-circuits the rebuild.
#
# Why this job and not a standalone one: the image is 5GB+; passing
# it between jobs via ``docker save``/``upload-artifact`` is slower
# than the build itself. Reusing the existing daemon state is the
# cheapest path to coverage on every PR that touches docker code.
# (The release path DOES pay that cost — see the save steps below —
# because publish must push the exact tested bytes, not a rebuild.)
# ---------------------------------------------------------------------
# The stamp above marks the checkout as a docker distribution, and PM
# then expects the image's packaged runtime. The runner is not the
# image: park the stamp while the test toolchain is provisioned, then
# put it back — tests/docker compares the image's provenance against it.
- name: Park the image install stamp while provisioning the runner toolchain
run: mv install-stamp.json "$RUNNER_TEMP/install-stamp.json"
- name: Set up locked Python and test dependencies
uses: ./.github/actions/setup-pm
with:
extras: '[]'
test-environment: 'true'
prune-python-cache: true
- name: Restore the image install stamp for the docker tests
run: mv "$RUNNER_TEMP/install-stamp.json" install-stamp.json
- name: Run docker integration tests
env:
# Skip rebuild; use the image already loaded by the build step.
HERMES_TEST_IMAGE: ${{ env.IMAGE_NAME }}:test
# Match the policy in tests.yml :: test job — no accidental
# real-API calls from inside the harness.
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
run: |
# Each of these tests drives a container, so the docker daemon sets
# the limit and not the processor. This pins the runner's worker
# count to the core count.
HERMES_TEST_WORKERS=$(nproc) scripts/run_tests.sh tests/docker/
- name: Verify release image identity
if: needs.mode.outputs.phase == 'test'
env:
RELEASE_VERSION: ${{ inputs.version }}
run: |
actual="$(docker run --rm --entrypoint python "${IMAGE_NAME}:test" -c \
'import json; print(json.load(open("/opt/hermes/install-stamp.json"))["baseVersion"])')"
test "$actual" = "$RELEASE_VERSION"
# ---------------------------------------------------------------------
# Release 'test' phase only: hand the EXACT tested bytes to the publish
# phase via actions artifacts. No registry push, no credentials here.
# ---------------------------------------------------------------------
- name: Save tested image archive (release test)
if: needs.mode.outputs.phase == 'test'
env:
ARCH: ${{ matrix.arch }}
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
mkdir -p /tmp/image-artifacts
docker image inspect "${IMAGE_NAME}:test" > /tmp/image-artifacts/image-inspect.json
python3 - <<'PY'
import json, os
from pathlib import Path
image = json.loads(Path('/tmp/image-artifacts/image-inspect.json').read_text())[0]
if image['Architecture'] != os.environ['ARCH']:
raise SystemExit('Docker image architecture mismatch')
Path('/tmp/image-artifacts/identity.json').write_text(json.dumps({
'tag': os.environ['RELEASE_TAG'], 'commit': os.environ['GITHUB_SHA'],
'arch': os.environ['ARCH'], 'imageId': image['Id']}), encoding='utf-8')
PY
docker save --output "/tmp/image-artifacts/image-${ARCH}.tar" "${IMAGE_NAME}:test"
(
cd /tmp/image-artifacts
sha256sum "image-${ARCH}.tar" > "image-${ARCH}.tar.sha256"
)
- name: Upload tested image archive (release test)
if: needs.mode.outputs.phase == 'test'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: docker-test-image-${{ matrix.arch }}-${{ inputs.tag }}
path: |
/tmp/image-artifacts/image-${{ matrix.arch }}.tar
/tmp/image-artifacts/image-${{ matrix.arch }}.tar.sha256
/tmp/image-artifacts/identity.json
if-no-files-found: error
retention-days: 7
compression-level: 0
# ---------------------------------------------------------------------------
# Standalone publish: rebuild and push each architecture only after the
# unprivileged build/test matrix passes, on trusted main pushes.
# This job is the sole Docker Hub credential boundary for merges.
#
# NOTE: main pushes tag :main ONLY. :latest is a user-facing stable alias
# now; the ordered publication controller moves it only after the global
# release gate, so a main push can never advance the stable channel.
# ---------------------------------------------------------------------------
publish:
if: >-
needs.mode.outputs.release != 'true' &&
github.repository == 'NousResearch/hermes-agent' &&
github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [mode, build]
environment: container-publish
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest-32-core
platform: linux/amd64
cache-from: type=gha,scope=docker-amd64
cache-to: type=gha,mode=max,scope=docker-amd64
# Native arm64 for the same reason as the build matrix above.
- arch: arm64
runner: ubuntu-latest-32-arm-core
platform: linux/arm64
cache-from: type=gha,scope=docker-arm64
cache-to: type=gha,mode=max,scope=docker-arm64
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
steps:
- name: Checkout trusted source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Write install stamp
run: |
display="$(python3 -m scripts.releases.distance)"
identity=(--commit "$GITHUB_SHA")
if [ -n "$display" ]; then
base="${display%%+*}"
development="${display#*+}"
if [ "$development" = "$display" ]; then distance=0; else distance="${development%%.*}"; fi
identity+=(--base-version "$base" --display-version "$display" --distance "$distance")
fi
python3 scripts/write_install_stamp.py --output install-stamp.json \
--distribution docker --update-mechanism external --source ci "${identity[@]}"
- name: Reject profile exports in the build context
run: python3 scripts/ci/check_profile_archive_boundary.py
# Retry once on transient Docker Hub / buildkit pull failures.
# See build job for rationale; same pattern.
- name: Set up Docker Buildx
id: buildx
continue-on-error: true
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Set up Docker Buildx (retry)
if: steps.buildx.outcome == 'failure'
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Push by digest only (no tag). The merge job assembles the tagged
# manifest list after both architecture publishers complete.
- name: Push ${{ matrix.arch }} by digest
id: push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
labels: |
org.opencontainers.image.revision=${{ github.sha }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: ${{ matrix.cache-from }}
cache-to: ${{ matrix.cache-to }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.push.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: digest-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# ---------------------------------------------------------------------------
# Stitch both per-arch digests into a single tagged multi-arch manifest.
# This is a registry-side operation — no building, no layer re-push —
# so it runs in ~30 seconds.
#
# Main pushes tag :main only. :latest is reserved for the ordered stable
# publication controller.
# ---------------------------------------------------------------------------
merge:
if: >-
needs.mode.outputs.release != 'true' &&
github.repository == 'NousResearch/hermes-agent' &&
github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
needs: [mode, publish]
timeout-minutes: 10
environment: container-publish
steps:
- name: Download digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
# Retry once on transient Docker Hub / buildkit pull failures.
# See build job for rationale; same pattern.
- name: Set up Docker Buildx
id: buildx
continue-on-error: true
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Set up Docker Buildx (retry)
if: steps.buildx.outcome == 'failure'
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create manifest list and push
working-directory: /tmp/digests
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
run: |
set -euo pipefail
args=()
for digest_file in *; do
args+=("${IMAGE_NAME}@sha256:${digest_file}")
done
tags=(-t "${IMAGE_NAME}:main")
# Retry: Docker Hub API + just-pushed digest eventual consistency
# can transiently fail the create; the operation is idempotent.
for i in 1 2 3; do
if docker buildx imagetools create "${tags[@]}" "${args[@]}"; then
break
fi
if [ "$i" = 3 ]; then
echo "::error::imagetools create failed after 3 attempts"
exit 1
fi
echo "::warning::imagetools create failed (attempt $i); retrying in 20s"
sleep 20
done
- name: Inspect image
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
run: docker buildx imagetools inspect "${IMAGE_NAME}:main"
# ===========================================================================
# Staged stable-release path (workflow_call from stable-release.yml).
# The caller is dispatched on the exact stable TAG, so github.sha in every
# reusable call is the release candidate commit.
# ===========================================================================
# Release 'publish' phase: load the EXACT tested image archives uploaded by
# the 'test' phase of this SAME workflow run, verify their hashes, push
# per-arch and NEVER rebuild. No owner gate: on a fork the Docker Hub
# login/push fails loudly (missing credentials) instead of faking green.
release-publish:
name: Publish tested Docker image (${{ matrix.arch }})
if: needs.mode.outputs.phase == 'publish'
needs: [mode]
environment: container-publish
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
- arch: arm64
runs-on: ubuntu-latest-32-core
timeout-minutes: 45
env:
ARCH: ${{ matrix.arch }}
RELEASE_TAG: ${{ inputs.tag }}
steps:
- name: Checkout release code (helper scripts only, no build)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.sha }}
- name: Download tested image archive from the test phase (same run)
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: docker-test-image-${{ matrix.arch }}-${{ inputs.tag }}
path: /tmp/image-artifacts
- name: Verify tested archive hash (published bytes == tested bytes)
run: |
set -euo pipefail
cd /tmp/image-artifacts
echo " expected: $(cat "image-${ARCH}.tar.sha256")"
sha256sum --check "image-${ARCH}.tar.sha256"
- name: Set up Docker Buildx
id: buildx
continue-on-error: true
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Set up Docker Buildx (retry)
if: steps.buildx.outcome == 'failure'
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Load tested image
run: |
set -euo pipefail
docker load --input /tmp/image-artifacts/image-${ARCH}.tar
docker image inspect "${IMAGE_NAME}:test" > /tmp/image-artifacts/loaded.json
python3 - <<'PY'
import json, os
from pathlib import Path
identity = json.loads(Path('/tmp/image-artifacts/identity.json').read_text())
loaded = json.loads(Path('/tmp/image-artifacts/loaded.json').read_text())[0]
expected = (os.environ['RELEASE_TAG'], os.environ['GITHUB_SHA'], os.environ['ARCH'])
if (identity['tag'], identity['commit'], identity['arch']) != expected:
raise SystemExit('Tested Docker archive identity mismatch')
if (loaded['Id'], loaded['Architecture']) != (identity['imageId'], identity['arch']):
raise SystemExit('Loaded Docker image differs from tested image')
PY
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Push tested image with per-arch release tag
run: |
set -euo pipefail
docker tag "${IMAGE_NAME}:test" "${IMAGE_NAME}:${RELEASE_TAG}-${ARCH}"
docker push "${IMAGE_NAME}:${RELEASE_TAG}-${ARCH}"
- name: Record pushed per-arch digest
run: |
set -euo pipefail
mkdir -p /tmp/digests
digest="$(docker buildx imagetools inspect "${IMAGE_NAME}:${RELEASE_TAG}-${ARCH}" \
--format '{{json .Manifest.Digest}}' | tr -d '"')"
case "$digest" in
sha256:*) ;;
*) echo "::error::Unexpected digest format: $digest"; exit 1 ;;
esac
printf '%s' "$digest" > "/tmp/digests/${ARCH}.digest"
cat "/tmp/digests/${ARCH}.digest"
- name: Upload per-arch digest artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: docker-publish-digest-${{ matrix.arch }}-${{ inputs.tag }}
path: /tmp/digests/${{ matrix.arch }}.digest
if-no-files-found: error
retention-days: 7
# Assemble the immutable versioned multi-arch manifest and expose its digest
# to the parent release receipt. Registry-side only; nothing is rebuilt.
release-publish-manifest:
name: Assemble versioned manifest and digest receipt
if: needs.mode.outputs.phase == 'publish'
needs: [mode, release-publish]
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.list.outputs.digest }}
timeout-minutes: 15
environment: container-publish
env:
RELEASE_TAG: ${{ inputs.tag }}
steps:
- name: Checkout release code (helper scripts only)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.sha }}
- name: Download per-arch digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
path: /tmp/digests
pattern: docker-publish-digest-*-${{ inputs.tag }}
merge-multiple: true
- name: Set up Docker Buildx
id: buildx
continue-on-error: true
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Set up Docker Buildx (retry)
if: steps.buildx.outcome == 'failure'
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create versioned manifest list
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
run: |
set -euo pipefail
cd /tmp/digests
test -f amd64.digest && test -f arm64.digest
args=()
for arch in amd64 arm64; do
args+=("${IMAGE_NAME}@$(cat "${arch}.digest")")
done
verify_manifest() {
python3 - "$1" amd64.digest arm64.digest <<'PY'
import json, pathlib, sys
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
expected = {pathlib.Path(path).read_text().strip() for path in sys.argv[2:]}
actual = {row.get("digest") for row in manifest.get("manifests", [])}
if actual != expected:
raise SystemExit(f"versioned Docker manifest differs: expected {sorted(expected)}, got {sorted(actual)}")
PY
}
if docker buildx imagetools inspect "${IMAGE_NAME}:${RELEASE_TAG}" --raw > existing.json 2>/dev/null; then
verify_manifest existing.json
echo "Verified existing immutable ${IMAGE_NAME}:${RELEASE_TAG}"
else
for i in 1 2 3; do
if docker buildx imagetools create \
-t "${IMAGE_NAME}:${RELEASE_TAG}" \
"${args[@]}"; then
break
fi
if [ "$i" = 3 ]; then
echo "::error::imagetools create failed after 3 attempts"
exit 1
fi
sleep 20
done
fi
docker buildx imagetools inspect "${IMAGE_NAME}:${RELEASE_TAG}" --raw > verified.json
verify_manifest verified.json
- name: Record manifest-list digest
id: list
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
run: |
set -euo pipefail
digest="$(docker buildx imagetools inspect "${IMAGE_NAME}:${RELEASE_TAG}" \
--format '{{json .Manifest.Digest}}' | tr -d '"')"
case "$digest" in
sha256:*) ;;
*) echo "::error::Unexpected manifest-list digest: $digest"; exit 1 ;;
esac
echo "digest=$digest" >> "$GITHUB_OUTPUT"
- name: Emit release manifest artifact
id: manifest
run: |
set -euo pipefail
mkdir -p /tmp/manifest
python3 -m scripts.releases.docker manifest \
--tag "$RELEASE_TAG" \
--commit "$GITHUB_SHA" \
--digest-amd64 "$(sed 's/^sha256://' /tmp/digests/amd64.digest)" \
--digest-arm64 "$(sed 's/^sha256://' /tmp/digests/arm64.digest)" \
> /tmp/manifest/manifest.json
python3 - "$RELEASE_TAG" "${{ steps.list.outputs.digest }}" <<'EOF'
import json, sys
manifest = json.load(open("/tmp/manifest/manifest.json"))
manifest["list-digest"] = sys.argv[2]
manifest["image"] = "nousresearch/hermes-agent"
manifest["tags"] = [sys.argv[1]]
json.dump(manifest, open("/tmp/manifest/manifest.json", "w"), indent=2)
EOF
cat /tmp/manifest/manifest.json
- name: Verify manifest identity
run: |
set -euo pipefail
python3 -m scripts.releases.docker verify \
--tag "$RELEASE_TAG" --commit "$GITHUB_SHA" /tmp/manifest/manifest.json
- name: Upload release manifest artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: docker-publish-manifest-${{ inputs.tag }}
path: /tmp/manifest/manifest.json
if-no-files-found: error
retention-days: 7
# Strict phase gate: in every release phase, the jobs that phase requires
# must actually have run and succeeded. `if: always()` keeps this job in
# the graph even when earlier jobs were skipped, so a skipped/failed
# publisher turns this red instead of letting the phase go green.
release-phase-gate:
name: Docker phase requirements met
if: always() && inputs.release-phase != ''
needs: [mode, build, release-publish, release-publish-manifest]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Validate phase job results
env:
PHASE: ${{ needs.mode.outputs.phase }}
BUILD: ${{ needs.build.result }}
RELEASE_PUBLISH: ${{ needs.release-publish.result }}
RELEASE_MANIFEST: ${{ needs.release-publish-manifest.result }}
MODE: ${{ needs.mode.result }}
run: |
set -euo pipefail
test "$MODE" = success
case "$PHASE" in
test)
failures=()
[ "$BUILD" = success ] || failures+=("build=$BUILD")
[ "${#failures[@]}" -eq 0 ] || { printf '::error::%s\n' "${failures[@]}"; exit 1; }
;;
publish)
failures=()
[ "$RELEASE_PUBLISH" = success ] || failures+=("release-publish=$RELEASE_PUBLISH")
[ "$RELEASE_MANIFEST" = success ] || failures+=("release-publish-manifest=$RELEASE_MANIFEST")
[ "${#failures[@]}" -eq 0 ] || { printf '::error::%s\n' "${failures[@]}"; exit 1; }
;;
*) echo "::error::Unknown phase $PHASE"; exit 1 ;;
esac