name: Docker Build and Publish on: push: branches: [main] paths: - '**/*.py' - 'pyproject.toml' - 'uv.lock' - 'Dockerfile' - 'docker/**' - '.github/workflows/docker-publish.yml' - '.github/actions/hermes-smoke-test/**' pull_request: branches: [main] paths: - '**/*.py' - 'pyproject.toml' - 'uv.lock' - 'Dockerfile' - 'docker/**' - '.github/workflows/docker-publish.yml' - '.github/actions/hermes-smoke-test/**' release: types: [published] permissions: contents: read # Concurrency: push/release runs are NEVER cancelled so every merge gets its # own SHA-tagged image; :latest is guarded separately by the move-latest job. # PR runs reuse a PR-scoped group with cancel-in-progress: true so rapid # pushes to the same PR collapse to the latest commit. concurrency: group: docker-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: IMAGE_NAME: nousresearch/hermes-agent jobs: # --------------------------------------------------------------------------- # Build amd64 natively. This job also runs the smoke tests (basic --help # and the dashboard subcommand regression guard from #9153), because amd64 # is the only arch we can `load` into the local daemon on an amd64 runner. # --------------------------------------------------------------------------- build-amd64: # Only run on the upstream repository, not on forks if: github.repository == 'NousResearch/hermes-agent' runs-on: ubuntu-latest timeout-minutes: 45 outputs: digest: ${{ steps.push.outputs.digest }} steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: submodules: recursive - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 # Build once, load into the local daemon for smoke testing. Cached # to gha with a per-arch scope; the push step below reuses every # layer from this build. - name: Build image (amd64, smoke test) uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: Dockerfile load: true platforms: linux/amd64 tags: ${{ env.IMAGE_NAME }}:test cache-from: type=gha,scope=docker-amd64 cache-to: type=gha,mode=max,scope=docker-amd64 - name: Smoke test image uses: ./.github/actions/hermes-smoke-test with: image: ${{ env.IMAGE_NAME }}:test - name: Log in to Docker Hub if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} # Push amd64 by digest only (no tag). The merge job assembles the # tagged manifest list. `push-by-digest=true` is docker's recommended # pattern for multi-runner multi-platform builds. # # We apply the OCI revision label here (and again on arm64) because # the move-latest job reads it off the linux/amd64 sub-manifest config # of `:latest` to decide whether it's safe to advance. The label must # be on each per-arch image — manifest lists themselves don't carry # image config labels. - name: Push amd64 by digest id: push if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: Dockerfile platforms: linux/amd64 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: type=gha,scope=docker-amd64 cache-to: type=gha,mode=max,scope=docker-amd64 # Write the digest to a file and upload it as an artifact so the # merge job can stitch both per-arch digests into a manifest list. - name: Export digest if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' run: | mkdir -p /tmp/digests digest="${{ steps.push.outputs.digest }}" touch "/tmp/digests/${digest#sha256:}" - name: Upload digest artifact if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: digest-amd64 path: /tmp/digests/* if-no-files-found: error retention-days: 1 # --------------------------------------------------------------------------- # Build arm64 natively on GitHub's free arm64 runner. This replaces the # previous QEMU-emulated arm64 build, which was ~5-10x slower and shared # a cache scope with amd64. Matches the amd64 job's shape: build+load, # smoke test, then on push/release push by digest. # --------------------------------------------------------------------------- build-arm64: if: github.repository == 'NousResearch/hermes-agent' runs-on: ubuntu-24.04-arm timeout-minutes: 45 outputs: digest: ${{ steps.push.outputs.digest }} steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: submodules: recursive - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 # Build once, load into the local daemon for smoke testing. Cached # to gha with a per-arch scope; the push step below reuses every # layer from this build. - name: Build image (arm64, smoke test) uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: Dockerfile load: true platforms: linux/arm64 tags: ${{ env.IMAGE_NAME }}:test cache-from: type=gha,scope=docker-arm64 cache-to: type=gha,mode=max,scope=docker-arm64 - name: Smoke test image uses: ./.github/actions/hermes-smoke-test with: image: ${{ env.IMAGE_NAME }}:test - name: Log in to Docker Hub if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Push arm64 by digest id: push if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: Dockerfile platforms: linux/arm64 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: type=gha,scope=docker-arm64 cache-to: type=gha,mode=max,scope=docker-arm64 - name: Export digest if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' run: | mkdir -p /tmp/digests digest="${{ steps.push.outputs.digest }}" touch "/tmp/digests/${digest#sha256:}" - name: Upload digest artifact if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: digest-arm64 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. On main pushes it produces :sha-. # On releases it produces :. # --------------------------------------------------------------------------- merge: if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release') runs-on: ubuntu-latest needs: [build-amd64, build-arm64] timeout-minutes: 10 outputs: pushed_sha_tag: ${{ steps.mark_pushed.outputs.pushed }} steps: - name: Download digests uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: path: /tmp/digests pattern: digest-* merge-multiple: true - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Log in to Docker Hub uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} # Compute the tag for this run. Main pushes use sha- (so every # commit gets its own immutable tag); releases use the release tag name. - name: Compute tag id: tag run: | if [ "${{ github.event_name }}" = "release" ]; then echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT" else echo "tag=sha-${{ github.sha }}" >> "$GITHUB_OUTPUT" fi - name: Create manifest list and push working-directory: /tmp/digests run: | set -euo pipefail # Build the arg array from each digest file (filename = the digest # hex, with no sha256: prefix; empty file content, only the name # matters). Using an array avoids shellcheck SC2046 and keeps # every digest a single argv token even under pathological names. args=() for digest_file in *; do args+=("${IMAGE_NAME}@sha256:${digest_file}") done docker buildx imagetools create \ -t "${IMAGE_NAME}:${TAG}" \ "${args[@]}" env: IMAGE_NAME: ${{ env.IMAGE_NAME }} TAG: ${{ steps.tag.outputs.tag }} - name: Inspect image run: | docker buildx imagetools inspect "${IMAGE_NAME}:${TAG}" env: IMAGE_NAME: ${{ env.IMAGE_NAME }} TAG: ${{ steps.tag.outputs.tag }} # Signal to move-latest that the SHA tag is live. Only on main pushes; # releases don't trigger move-latest (they use their own release tag). - name: Mark SHA tag pushed id: mark_pushed if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: echo "pushed=true" >> "$GITHUB_OUTPUT" # --------------------------------------------------------------------------- # Move :latest to point at the SHA tag the merge job pushed. # # The real serialization guarantee comes from the top-level concurrency # group (`docker-${{ github.ref }}` with `cancel-in-progress: false`), # which ensures at most one workflow run for this ref executes at a time. # That means two move-latest steps for the same ref cannot overlap. # # This job has its own concurrency group as defense-in-depth: if the # top-level group is ever loosened, queued move-latests will run serially # in arrival order, each one running the ancestor check below and either # advancing :latest or skipping. `cancel-in-progress: false` matches the # top-level setting — we don't want rapid pushes to cancel a queued # move-latest, because the ancestor check is the real safety mechanism # and queueing is cheap (move-latest is a ~30s registry op). # # Combined with the ancestor check, this means :latest only ever moves # forward in git history. # --------------------------------------------------------------------------- move-latest: if: | github.repository == 'NousResearch/hermes-agent' && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.merge.outputs.pushed_sha_tag == 'true' needs: merge runs-on: ubuntu-latest timeout-minutes: 10 concurrency: group: docker-move-latest-${{ github.ref }} cancel-in-progress: false steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 1000 - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Log in to Docker Hub uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} # Read the git revision label off the current :latest manifest, then # use `git merge-base --is-ancestor` to check whether our commit is a # descendant of it. If :latest doesn't exist yet, or its label is # missing, we treat that as "safe to publish". If another run already # advanced :latest past us (or diverged), we skip and leave it alone. - name: Decide whether to move :latest id: latest_check run: | set -euo pipefail image=nousresearch/hermes-agent # Pull the JSON for the linux/amd64 sub-manifest's config and extract # the OCI revision label with jq — Go template field access can't # handle dots in map keys, so using json+jq is the robust route. image_json=$( docker buildx imagetools inspect "${image}:latest" \ --format '{{ json (index .Image "linux/amd64") }}' \ 2>/dev/null || true ) if [ -z "${image_json}" ]; then echo "No existing :latest (or inspect failed) — safe to publish." echo "push_latest=true" >> "$GITHUB_OUTPUT" exit 0 fi current_sha=$( printf '%s' "${image_json}" \ | jq -r '.config.Labels."org.opencontainers.image.revision" // ""' ) if [ -z "${current_sha}" ]; then echo "Registry :latest has no revision label — safe to publish." echo "push_latest=true" >> "$GITHUB_OUTPUT" exit 0 fi echo "Registry :latest is at ${current_sha}" echo "This run is at ${GITHUB_SHA}" if [ "${current_sha}" = "${GITHUB_SHA}" ]; then echo ":latest already points at our SHA — nothing to do." echo "push_latest=false" >> "$GITHUB_OUTPUT" exit 0 fi # Make sure we have the :latest commit locally for merge-base. if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then git fetch --no-tags --prune origin \ "+refs/heads/main:refs/remotes/origin/main" \ || true fi if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then echo "Registry :latest points at an unknown commit (${current_sha}); refusing to overwrite." echo "push_latest=false" >> "$GITHUB_OUTPUT" exit 0 fi # Our SHA must be a descendant of the current :latest to be safe. if git merge-base --is-ancestor "${current_sha}" "${GITHUB_SHA}"; then echo "Our commit is a descendant of :latest — safe to advance." echo "push_latest=true" >> "$GITHUB_OUTPUT" else echo "Another run advanced :latest past us (or diverged) — leaving it alone." echo "push_latest=false" >> "$GITHUB_OUTPUT" fi # Retag the already-pushed SHA manifest as :latest. This is a registry- # side operation — no rebuild, no layer re-push — so it's quick and # atomic per-tag. The ancestor check above plus the cancel-in-progress # concurrency on this job together guarantee we only ever move :latest # forward in git history. - name: Move :latest to this SHA if: steps.latest_check.outputs.push_latest == 'true' run: | set -euo pipefail image=nousresearch/hermes-agent docker buildx imagetools create \ --tag "${image}:latest" \ "${image}:sha-${GITHUB_SHA}"