757 lines
32 KiB
YAML
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
|