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 :main or release-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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 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 the floating tag 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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 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 :main; on # releases it produces :. # # For main pushes the ancestor check runs BEFORE the manifest push so # we never overwrite :main with an older commit. The top-level # concurrency group (`docker-${{ github.ref }}` with # `cancel-in-progress: false`) already serialises runs per ref; the # ancestor check is defense-in-depth. # --------------------------------------------------------------------------- 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_release_tag: ${{ steps.mark_release_pushed.outputs.pushed }} release_tag: ${{ steps.tag.outputs.tag }} steps: - name: Checkout code if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1000 - 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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} # Read the git revision label off the current :main manifest, then # use `git merge-base --is-ancestor` to check whether our commit is # a descendant of it. If :main doesn't exist yet, or its label is # missing, we treat that as "safe to publish". If another run # already advanced :main past us (or diverged), we skip and leave # it alone. - name: Decide whether to move :main if: github.event_name == 'push' && github.ref == 'refs/heads/main' id: main_check run: | set -euo pipefail image=nousresearch/hermes-agent image_json=$( docker buildx imagetools inspect "${image}:main" \ --format '{{ json (index .Image "linux/amd64") }}' \ 2>/dev/null || true ) if [ -z "${image_json}" ]; then echo "No existing :main (or inspect failed) — safe to publish." echo "push_main=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 :main has no revision label — safe to publish." echo "push_main=true" >> "$GITHUB_OUTPUT" exit 0 fi echo "Registry :main is at ${current_sha}" echo "This run is at ${GITHUB_SHA}" if [ "${current_sha}" = "${GITHUB_SHA}" ]; then echo ":main already points at our SHA — nothing to do." echo "push_main=false" >> "$GITHUB_OUTPUT" exit 0 fi 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 :main points at an unknown commit (${current_sha}); refusing to overwrite." echo "push_main=false" >> "$GITHUB_OUTPUT" exit 0 fi if git merge-base --is-ancestor "${current_sha}" "${GITHUB_SHA}"; then echo "Our commit is a descendant of :main — safe to advance." echo "push_main=true" >> "$GITHUB_OUTPUT" else echo "Another run advanced :main past us (or diverged) — leaving it alone." echo "push_main=false" >> "$GITHUB_OUTPUT" fi # Compute the tag for this run. Main pushes tag directly as :main # (no per-commit SHA tags); 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=main" >> "$GITHUB_OUTPUT" fi # Gate the manifest push on the ancestor check for main pushes. # For releases there is no gate — the check doesn't even run. - name: Create manifest list and push if: github.event_name != 'push' || steps.main_check.outputs.push_main == 'true' working-directory: /tmp/digests run: | set -euo pipefail 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 if: github.event_name != 'push' || steps.main_check.outputs.push_main == 'true' 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 release tag is live. - name: Mark release tag pushed id: mark_release_pushed if: github.event_name == 'release' run: echo "pushed=true" >> "$GITHUB_OUTPUT" # --------------------------------------------------------------------------- # Move :latest to point at the release tag the merge job pushed. # # :latest is the floating tag that tracks the most recent stable release. # Only `release: published` events advance it — never main pushes. # # We still run an ancestor check against the existing :latest so that a # backport release on an older branch (e.g. patching v1.1.5 after v1.2.3 # is out) doesn't drag :latest backwards. The check is the same shape # as the ancestor check in the merge job for :main: read the OCI # revision label off the current :latest, look up that commit in git, # and only advance if our release commit is a strict descendant. # --------------------------------------------------------------------------- move-latest: if: | github.repository == 'NousResearch/hermes-agent' && github.event_name == 'release' && needs.merge.outputs.pushed_release_tag == 'true' needs: merge runs-on: ubuntu-latest timeout-minutes: 10 concurrency: group: docker-move-latest cancel-in-progress: false steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Decide whether to move :latest id: latest_check run: | set -euo pipefail image=nousresearch/hermes-agent 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 release 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. # Releases can be cut from any branch, so fetch broadly. 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 release SHA must be a descendant of the current :latest. # Backport releases on older branches won't satisfy this and will # be left alone — :latest stays on the newer release. if git merge-base --is-ancestor "${current_sha}" "${GITHUB_SHA}"; then echo "Our release commit is a descendant of :latest — safe to advance." echo "push_latest=true" >> "$GITHUB_OUTPUT" else echo "Existing :latest is newer than this release (likely a backport) — leaving it alone." echo "push_latest=false" >> "$GITHUB_OUTPUT" fi # Retag the already-pushed release manifest as :latest. - name: Move :latest to this release tag if: steps.latest_check.outputs.push_latest == 'true' env: RELEASE_TAG: ${{ needs.merge.outputs.release_tag }} run: | set -euo pipefail image=nousresearch/hermes-agent docker buildx imagetools create \ --tag "${image}:latest" \ "${image}:${RELEASE_TAG}"