mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
The PR-read-only hardening (1d5eb3bf4) gated the WIF auth step on
non-PR events, which also silently gated the Artifact Registry docker
login that step fed. The comment said "PRs read the cache via the pod's
GKE Workload Identity", but pod WI is not ambient for buildx: with no
login, cache-from does an anonymous pull against us-central1-docker.pkg.dev
and gets 403 Forbidden (visible as "failed to configure registry cache
importer" in every PR build since), so every PR built cache-cold
(~12-16 min instead of ~2-3).
Fix: on PR events, mint an access token from the pod's GKE metadata
server (the runner pod's KSA impersonates gha-buildx-cache-ro@, which
has only artifactregistry.reader) and feed it to the same docker login
step. The security boundary is unchanged — PR builds still cannot write
cache layers; cache-to remains gated on the WIF token that only exists
on trusted main-push/release contexts.
360 lines
16 KiB
YAML
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
|
|
runner: arc-runner-set
|
|
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.
|
|
# ---------------------------------------------------------------------
|
|
- name: Install uv (for docker tests)
|
|
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
|
with:
|
|
# Pinned: unpinned setup-uv fetches a 'latest' manifest from
|
|
# raw.githubusercontent.com every job; transient fetch failures
|
|
# fail the job (2026-07-28 incident). Keep in sync with tests.yml.
|
|
version: '0.9.28'
|
|
|
|
- name: Set up Python 3.11 (for docker tests)
|
|
run: uv python install 3.11
|
|
|
|
- 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
|
|
runner: arc-runner-set
|
|
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')
|
|
runs-on: arc-runner-set
|
|
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
|