mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
Switch top-level concurrency to cancel-in-progress=false so every push to main gets its own SHA-tagged image published — no more discarded builds when commits land back-to-back. Guard the :latest tag with a second job that has its own concurrency group with cancel-in-progress=true plus a git-ancestor check against the revision label on the current :latest. Together these guarantee :latest only ever moves forward in history: a slower run whose commit isn't a descendant of the current :latest refuses to clobber it, and a newer push mid-way through the move-latest job preempts the older one before it can retag. - Every main push publishes nousresearch/hermes-agent:sha-<commit> with an org.opencontainers.image.revision label embedded. - move-latest job reads that label off :latest, runs merge-base --is-ancestor, and only retags (via buildx imagetools create, registry-side, no rebuild) if our commit strictly descends. - fetch-depth bumped to 1000 so merge-base has the history it needs. - Release tag flow unchanged (unique tag, no race).
238 lines
9.5 KiB
YAML
238 lines
9.5 KiB
YAML
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: |
|
|
# 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).
|
|
mkdir -p /tmp/hermes-test
|
|
sudo chown -R 10000:10000 /tmp/hermes-test
|
|
docker run --rm \
|
|
-v /tmp/hermes-test:/opt/data \
|
|
--entrypoint /opt/hermes/docker/entrypoint.sh \
|
|
nousresearch/hermes-agent:test --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}"
|