hermes-agent/.github/workflows/docker.yml
ethernet 634f482ff8 perf(ci): route jobs to the new capability-split runner scale sets
hermes-agent-ci-infra now offers three amd64 scale sets instead of one,
split by capability rather than size:

  arc-runner-set     no dind   4/8Gi     general (unchanged label)
  arc-runner-docker  dind      4/8Gi     needs a docker daemon
  arc-runner-small   no dind   500m/1Gi  short gates, warm pod

Of the 22 workflows targeting arc-runner-set, only docker.yml ever talks to
a daemon. Every other job was booting a privileged docker:dind sidecar, an
extra init container and a `docker info` startup probe just to run ruff, so
dind now lives only on arc-runner-docker (and arm64, which already had it).
docker.yml's amd64 legs and its manifest-merge job move there; the arm64
legs are unchanged.

The short gate jobs move to arc-runner-small. Each of these ran for 14-29s
while reserving 4 CPU / 8Gi — a fifth of a c3-standard-22 node — and there
are enough of them per PR to delay the test matrix they run alongside. The
small set sits on the always-on system pool and keeps one warm pod, so
these jobs skip pod creation and image pull entirely.

No behavior change to what any job does; only where it runs.
2026-07-31 14:15:26 -04:00

360 lines
16 KiB
YAML

name: Docker Build, Test, and Publish
on:
# Trusted main pushes run this workflow directly so environment-scoped
# Docker Hub secrets are resolved by the top-level workflow, never across
# a reusable-workflow boundary.
push:
branches: [main]
release:
types: [published]
# CI calls this only for untrusted PR build/test coverage. Those runs never
# reach the protected publish or merge jobs below.
workflow_call:
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.
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 and test the image for each architecture. This job runs PR code,
# so it must remain secret-free. Publishing happens in the separate,
# protected publish job after these tests pass.
#
# Buildx layer cache lives in Artifact Registry (us-central1, same region
# as the ARC runners) instead of GitHub's cache CDN. Reads are keyless via
# GKE Workload Identity on the runner pods; writes are keyless via GitHub
# OIDC -> GCP WIF (google-github-actions/auth) and happen ONLY on trusted
# main-push/release contexts — PR builds of any origin are read-only so
# PR-controlled code can never write cache layers the publish job reads.
build:
if: github.repository == 'NousResearch/hermes-agent'
permissions:
contents: read
# OIDC token for WIF cache writes — only minted on non-PR events
# (see the gcp-auth step); PR runs stay secret-free.
id-token: write
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
# dind lives only on arc-runner-docker now; the general amd64
# set dropped it so the ~20 workflows that never touch a daemon
# stop paying for a privileged sidecar on every pod.
runner: arc-runner-docker
platform: linux/amd64
- arch: arm64
runner: arc-runner-arm64
platform: linux/arm64
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# 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@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Set up Docker Buildx (retry)
if: steps.buildx.outcome == 'failure'
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
# Keyless GCP auth via GitHub OIDC -> WIF. PR builds (fork or
# same-repo) get NO write token: this job runs PR-controlled code
# and the publish job reads the same cache ref, so a PR-writable
# cache would be a layer-poisoning vector. PRs read the cache via
# the pod's GKE Workload Identity; writes happen only on trusted
# main-push/release contexts.
- name: Authenticate to GCP (WIF, cache writes)
id: gcp-auth
if: github.event_name != 'pull_request'
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
with:
project_id: hermes-agent-github-actions
workload_identity_provider: projects/1067970703723/locations/global/workloadIdentityPools/github-actions/providers/github-oidc
service_account: gha-buildx-cache@hermes-agent-github-actions.iam.gserviceaccount.com
token_format: access_token
# PR builds mint a READ-ONLY registry token from the runner pod's GKE
# Workload Identity instead. The pod's KSA (arc-runners/buildx-cache-
# reader) impersonates gha-buildx-cache-ro@, which holds only
# artifactregistry.reader on the ci-cache repo — so this token cannot
# write cache layers, and the layer-poisoning boundary above holds.
#
# This step is what actually turns pod WI into a docker credential:
# buildx only forwards auth that's present in the docker cred store,
# and there is no ambient cred helper for *.pkg.dev in the runner
# image — without an explicit login, cache-from falls back to an
# anonymous pull, gets 403, and every PR build runs cache-cold
# (~15 min instead of ~3).
- name: Mint read-only cache token (pod Workload Identity)
id: wi-token
if: github.event_name == 'pull_request'
run: |
set -euo pipefail
token=$(curl -sSf -H "Metadata-Flavor: Google" \
"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token" \
| jq -r .access_token)
echo "::add-mask::$token"
echo "token=$token" >> "$GITHUB_OUTPUT"
- name: Log in to Artifact Registry
if: steps.gcp-auth.outputs.access_token != '' || steps.wi-token.outputs.token != ''
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: us-central1-docker.pkg.dev
username: oauth2accesstoken
password: ${{ steps.gcp-auth.outputs.access_token || steps.wi-token.outputs.token }}
# Build once, load into the local daemon for testing. Cached
# per-arch; the push step below reuses every layer from this build.
# Cache lives in same-region Artifact Registry: reads always work
# (pod Workload Identity); writes only when the WIF auth step ran.
- 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
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
cache-from: type=registry,ref=us-central1-docker.pkg.dev/hermes-agent-github-actions/ci-cache/hermes-agent:buildcache-${{ matrix.arch }}
cache-to: ${{ steps.gcp-auth.outputs.access_token != '' && format('type=registry,ref=us-central1-docker.pkg.dev/hermes-agent-github-actions/ci-cache/hermes-agent:buildcache-{0},mode=max,image-manifest=true', matrix.arch) || '' }}
# 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.
# ---------------------------------------------------------------------
# uv and CPython 3.11 are baked into the nousresearch/nous-gke-runner
# image (hermes-agent-ci-infra runner/Dockerfile) — no setup-uv, no
# `uv python install`.
- name: Restore uv cache (for docker tests)
uses: ./.github/actions/uv-cache
- name: Install Python dependencies (for docker tests)
# ``dev`` extra pulls in pytest, pytest-asyncio —
# everything tests/docker/ needs. We deliberately avoid ``all``
# here because the docker tests only drive the container via
# subprocess and don't import hermes_agent's optional deps.
uses: ./.github/actions/retry
with:
command: uv sync --locked --python 3.11 --extra dev
- name: Run docker integration tests
# HERMES_TEST_WORKERS=8: this suite shares ONE dockerd, so width
# is daemon-bound, not CPU-bound. Width sweep with prewarmed image
# + split files (2026-07): -j4 58-62s, -j8 39s, -j12 35s but with
# ~2x per-file contention inflation at 12. 8 is the knee.
# run_tests.sh forwards HERMES_TEST_WORKERS through its hermetic
# env -i into the parallel runner.
uses: ./.github/actions/profile
with:
label: docker-tests-${{ matrix.arch }}
command: HERMES_TEST_WORKERS=8 HERMES_TEST_IMAGE="${{ env.IMAGE_NAME }}:test" scripts/run_tests.sh tests/docker/ --file-timeout 600
# ---------------------------------------------------------------------------
# Rebuild and push each architecture only after the unprivileged build/test
# matrix passes. This job is the sole Docker Hub credential boundary.
# ---------------------------------------------------------------------------
publish:
if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
needs: [build]
environment: container-publish
permissions:
contents: read
id-token: write
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
# See the build job: dind lives only on arc-runner-docker.
runner: arc-runner-docker
platform: linux/amd64
- arch: arm64
runner: arc-runner-arm64
platform: linux/arm64
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
steps:
- name: Checkout trusted source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# 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@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Set up Docker Buildx (retry)
if: steps.buildx.outcome == 'failure'
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
# Same keyless AR cache as the build job. Publish only runs on
# trusted main/release contexts, so WIF auth is unconditional here.
- name: Authenticate to GCP (WIF, cache)
id: gcp-auth
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
with:
project_id: hermes-agent-github-actions
workload_identity_provider: projects/1067970703723/locations/global/workloadIdentityPools/github-actions/providers/github-oidc
service_account: gha-buildx-cache@hermes-agent-github-actions.iam.gserviceaccount.com
token_format: access_token
- name: Log in to Artifact Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: us-central1-docker.pkg.dev
username: oauth2accesstoken
password: ${{ steps.gcp-auth.outputs.access_token }}
- 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 }}
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=us-central1-docker.pkg.dev/hermes-agent-github-actions/ci-cache/hermes-agent:buildcache-${{ matrix.arch }}
cache-to: type=registry,ref=us-central1-docker.pkg.dev/hermes-agent-github-actions/ci-cache/hermes-agent:buildcache-${{ matrix.arch }},mode=max,image-manifest=true
- 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.
#
# On main pushes: tags both :main and :latest.
# On releases: tags :<release_tag_name>.
# ---------------------------------------------------------------------------
merge:
if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
# Needs buildx to assemble the manifest list — see the build job.
runs-on: arc-runner-docker
needs: [publish]
timeout-minutes: 10
environment: container-publish
steps:
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
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@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Set up Docker Buildx (retry)
if: steps.buildx.outcome == 'failure'
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- 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 }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
args=()
for digest_file in *; do
args+=("${IMAGE_NAME}@sha256:${digest_file}")
done
if [ "${{ github.event_name }}" = "release" ]; then
tags=(-t "${IMAGE_NAME}:${RELEASE_TAG}")
else
tags=(-t "${IMAGE_NAME}:main" -t "${IMAGE_NAME}:latest")
fi
# 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 }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
if [ "${{ github.event_name }}" = "release" ]; then
docker buildx imagetools inspect "${IMAGE_NAME}:${RELEASE_TAG}"
else
docker buildx imagetools inspect "${IMAGE_NAME}:main"
fi