name: Docker Build and Publish on: push: branches: [main] paths: - '**/*.py' - 'pyproject.toml' - 'uv.lock' - 'Dockerfile' - 'docker/**' - '.github/workflows/docker-publish.yml' release: types: [published] permissions: contents: read # Top-level concurrency: do NOT cancel in-flight builds when a new push lands. # Every commit deserves its own SHA-tagged image in the registry, and we guard # the :latest tag in a separate job below (with its own concurrency group) so # a slow run can't clobber :latest with older bits. concurrency: group: docker-${{ github.ref }} cancel-in-progress: false jobs: build-and-push: # Only run on the upstream repository, not on forks if: github.repository == 'NousResearch/hermes-agent' runs-on: ubuntu-latest timeout-minutes: 60 outputs: pushed_sha_tag: ${{ steps.mark_pushed.outputs.pushed }} steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: submodules: recursive # Fetch enough history to run `git merge-base --is-ancestor` in the # move-latest job. That job reuses this checkout via its own # actions/checkout call, but commits reachable from main up to ~1000 # back are plenty for any realistic race window. fetch-depth: 1000 - name: Set up QEMU uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 # Build amd64 only so we can `load` the image for smoke testing. # `load: true` cannot export a multi-arch manifest to the local daemon. # The multi-arch build follows on push to main / release. - name: Build image (amd64, smoke test) uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: Dockerfile load: true platforms: linux/amd64 tags: nousresearch/hermes-agent:test cache-from: type=gha cache-to: type=gha,mode=max - name: Test image starts run: | mkdir -p /tmp/hermes-test sudo chown -R 10000:10000 /tmp/hermes-test # The image runs as the hermes user (UID 10000). GitHub Actions # creates /tmp/hermes-test root-owned by default, which hermes # can't write to — chown it to match the in-container UID before # bind-mounting. Real users doing `docker run -v ~/.hermes:...` # with their own UID hit the same issue and have their own # remediations (HERMES_UID env var, or chown locally). docker run --rm \ -v /tmp/hermes-test:/opt/data \ --entrypoint /opt/hermes/docker/entrypoint.sh \ nousresearch/hermes-agent:test --help - name: Test dashboard subcommand run: | mkdir -p /tmp/hermes-test sudo chown -R 10000:10000 /tmp/hermes-test # Verify the dashboard subcommand is included in the Docker image. # This prevents regressions like #9153 where the dashboard command # was present in source but missing from the published image. docker run --rm \ -v /tmp/hermes-test:/opt/data \ --entrypoint /opt/hermes/docker/entrypoint.sh \ nousresearch/hermes-agent:test dashboard --help - 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 }} # Always push a per-commit SHA tag on main. This is race-free because # every commit has a unique SHA — concurrent runs can't clobber each # other here. We also embed the git SHA as an OCI label so the # move-latest job (below) can read it back off the registry's `:latest`. - name: Push multi-arch image with SHA tag (main branch) id: push_sha if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: Dockerfile push: true platforms: linux/amd64,linux/arm64 tags: nousresearch/hermes-agent:sha-${{ github.sha }} labels: | org.opencontainers.image.revision=${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max - name: Mark SHA tag pushed id: mark_pushed if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: echo "pushed=true" >> "$GITHUB_OUTPUT" - name: Push multi-arch image (release) if: github.event_name == 'release' uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: Dockerfile push: true platforms: linux/amd64,linux/arm64 tags: nousresearch/hermes-agent:${{ github.event.release.tag_name }} cache-from: type=gha cache-to: type=gha,mode=max # Second job: moves `:latest` to point at the SHA tag the first job pushed. # # Has its own concurrency group with `cancel-in-progress: true`, which # gives us the serialization we need: if a newer push arrives while an # older run is mid-way through this job, the older run is cancelled # before it can clobber `:latest`. Combined with the ancestor check # below, 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.build-and-push.outputs.pushed_sha_tag == 'true' needs: build-and-push runs-on: ubuntu-latest timeout-minutes: 10 concurrency: group: docker-move-latest-${{ github.ref }} cancel-in-progress: true 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}"