Every Linux lane that does real work ran on a 4-core `ubuntu-latest`. The Python suite and the JS checks were split into many small jobs to make that size usable. Each split job repeated the full setup. In most of the JS jobs the repeated setup cost more than the work. The work lanes move to larger runners. Then the splits that existed only to make small runners usable go away. Python tests: 12 slices become 1 job on a 96-core runner. Slicing cost a matrix job, a duration cache, a per-slice artifact and a merge job. 96 cores clear the floor that the slowest single test file sets, which is about 82s. A second slice divides work that is already at that floor, and adds a second setup. Duration data from run 32522943054 gives the numbers behind this: 3178 files, 11645s in series. The worker count is explicit, because `run_tests.sh` defaults to twice the core count. A later commit sets it from a measurement on this hardware. JS checks: 14 jobs become 1. The matrix paid about 371s of repeated setup to spread about 612s of work. One larger runner installs one time. The three UI shard scripts and `run-ui-shard.mjs` are therefore removed, because the unsharded `test:ui` covers the same tests. The unit of parallel work inside that job is a CHECK, and not a workspace. apps/desktop is most of the payload, and its own `check` is a serial && chain. A spread across workspaces alone therefore leaves that chain as the long pole. A package that declares `check:*` sub-scripts gives one unit for each sub-script. That is the same selection rule the matrix used. The loop lives in `.github/scripts/run-workspace-checks.mjs`, so the same sequence runs on a laptop. It runs 11 units together, buffers the output of each one, and fails at the end with the full list. Children that share one stdout interleave their lines and make a failure hard to read. `npm run --ws check` stops at the first workspace that fails. `check:test:plugins` joins the desktop `check` script. The matrix prefers `check:*` sub-scripts over the plain `check` script, so `check:test:plugins` ran only as its own leg. Without this change the merge drops that suite and the job stays green. node_modules is cached on the lockfile, and `npm ci` is skipped on an exact hit. The `cache: npm` option of `setup-node` caches only the ~/.npm tarball cache, which leaves the extract and the postinstalls to pay again. The arm64 image build stays on a native arm64 runner. A build of linux/arm64 on an x64 host uses emulation. The docker test lane caps its workers at the core count. Each of those tests drives a container, so the docker daemon sets the limit and not the processor. `.github/actionlint.yaml` declares the runner labels. actionlint knows the GitHub-hosted labels only, and an undeclared label reads as an error that hides the real findings. The `detect` job checks out one file through a sparse checkout, and its timeout drops to 1 minute. It reads `scripts/ci/classify_changes.py` and nothing else. Verification: - actionlint reports 9 findings across all workflows. An unmodified HEAD with the same config reports the same 9. This change adds none. - A wrong label still fails. actionlint reports `ubuntu-latest-32-cor` and `ubuntu-latest-32-arm-cores`. - Every changed workflow parses, and `name` parses as a string. - A replay of the `save-durations` merge step against a three-artifact layout returns all 3178 entries. - An expansion of the npm script graph gives the same leaf commands for the parallel units and for a plain `npm run check`, in both directions. Against the 13-leg matrix the count is 13 to 11, and the whole difference is the three UI shards that collapse into one unsharded `check:test:ui`. - `--list` reports the 11 units, and a full local run completes and reports the time of each unit. - The runner labels cannot be verified here. The first real run is the test.
336 lines
14 KiB
YAML
336 lines
14 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 eeps 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.
|
|
pull_request:
|
|
push:
|
|
branches: [main]
|
|
release:
|
|
types: [published]
|
|
|
|
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:
|
|
# 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.
|
|
detect:
|
|
name: Detect affected areas
|
|
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 it must remain secret-free. Publishing happens in the separate,
|
|
# protected publish job after these tests pass.
|
|
build:
|
|
needs: [detect]
|
|
if: github.repository == 'NousResearch/hermes-agent' && needs.detect.outputs.build == 'true'
|
|
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
|
|
|
|
# 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
|
|
build-args: |
|
|
HERMES_GIT_SHA=${{ github.sha }}
|
|
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.
|
|
# ---------------------------------------------------------------------
|
|
- 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)
|
|
uses: ./.github/actions/retry
|
|
with:
|
|
command: 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
|
|
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 caps the workers. The
|
|
# default from run_tests.sh is cpu_count*2, which starts 64
|
|
# containers together on the 32-core amd64 runner.
|
|
HERMES_TEST_WORKERS=$(nproc) 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
|
|
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
|
|
|
|
# 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 }}
|
|
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: ${{ 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.
|
|
#
|
|
# 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: ubuntu-latest
|
|
needs: [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 }}
|
|
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
|