1616permissions :
1717 contents : read
1818
19+ # Top-level concurrency: do NOT cancel in-flight builds when a new push lands.
20+ # Every commit deserves its own SHA-tagged image in the registry, and we guard
21+ # the :latest tag in a separate job below (with its own concurrency group) so
22+ # a slow run can't clobber :latest with older bits.
1923concurrency :
2024 group : docker-${{ github.ref }}
21- cancel-in-progress : true
25+ cancel-in-progress : false
2226
2327jobs :
2428 build-and-push :
2529 # Only run on the upstream repository, not on forks
2630 if : github.repository == 'NousResearch/hermes-agent'
2731 runs-on : ubuntu-latest
2832 timeout-minutes : 60
33+ outputs :
34+ pushed_sha_tag : ${{ steps.mark_pushed.outputs.pushed }}
2935 steps :
3036 - name : Checkout code
3137 uses : actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
3238 with :
3339 submodules : recursive
40+ # Fetch enough history to run `git merge-base --is-ancestor` in the
41+ # move-latest job. That job reuses this checkout via its own
42+ # actions/checkout call, but commits reachable from main up to ~1000
43+ # back are plenty for any realistic race window.
44+ fetch-depth : 1000
3445
3546 - name : Set up QEMU
3647 uses : docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
@@ -74,18 +85,30 @@ jobs:
7485 username : ${{ secrets.DOCKERHUB_USERNAME }}
7586 password : ${{ secrets.DOCKERHUB_TOKEN }}
7687
77- - name : Push multi-arch image (main branch)
88+ # Always push a per-commit SHA tag on main. This is race-free because
89+ # every commit has a unique SHA — concurrent runs can't clobber each
90+ # other here. We also embed the git SHA as an OCI label so the
91+ # move-latest job (below) can read it back off the registry's `:latest`.
92+ - name : Push multi-arch image with SHA tag (main branch)
93+ id : push_sha
7894 if : github.event_name == 'push' && github.ref == 'refs/heads/main'
7995 uses : docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
8096 with :
8197 context : .
8298 file : Dockerfile
8399 push : true
84100 platforms : linux/amd64,linux/arm64
85- tags : nousresearch/hermes-agent:latest
101+ tags : nousresearch/hermes-agent:sha-${{ github.sha }}
102+ labels : |
103+ org.opencontainers.image.revision=${{ github.sha }}
86104 cache-from : type=gha
87105 cache-to : type=gha,mode=max
88106
107+ - name : Mark SHA tag pushed
108+ id : mark_pushed
109+ if : github.event_name == 'push' && github.ref == 'refs/heads/main'
110+ run : echo "pushed=true" >> "$GITHUB_OUTPUT"
111+
89112 - name : Push multi-arch image (release)
90113 if : github.event_name == 'release'
91114 uses : docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
@@ -97,3 +120,119 @@ jobs:
97120 tags : nousresearch/hermes-agent:${{ github.event.release.tag_name }}
98121 cache-from : type=gha
99122 cache-to : type=gha,mode=max
123+
124+ # Second job: moves `:latest` to point at the SHA tag the first job pushed.
125+ #
126+ # Has its own concurrency group with `cancel-in-progress: true`, which
127+ # gives us the serialization we need: if a newer push arrives while an
128+ # older run is mid-way through this job, the older run is cancelled
129+ # before it can clobber `:latest`. Combined with the ancestor check
130+ # below, this means `:latest` only ever moves forward in git history.
131+ move-latest :
132+ if : |
133+ github.repository == 'NousResearch/hermes-agent'
134+ && github.event_name == 'push'
135+ && github.ref == 'refs/heads/main'
136+ && needs.build-and-push.outputs.pushed_sha_tag == 'true'
137+ needs : build-and-push
138+ runs-on : ubuntu-latest
139+ timeout-minutes : 10
140+ concurrency :
141+ group : docker-move-latest-${{ github.ref }}
142+ cancel-in-progress : true
143+ steps :
144+ - name : Checkout code
145+ uses : actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
146+ with :
147+ fetch-depth : 1000
148+
149+ - name : Set up Docker Buildx
150+ uses : docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
151+
152+ - name : Log in to Docker Hub
153+ uses : docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
154+ with :
155+ username : ${{ secrets.DOCKERHUB_USERNAME }}
156+ password : ${{ secrets.DOCKERHUB_TOKEN }}
157+
158+ # Read the git revision label off the current `:latest` manifest, then
159+ # use `git merge-base --is-ancestor` to check whether our commit is a
160+ # descendant of it. If `:latest` doesn't exist yet, or its label is
161+ # missing, we treat that as "safe to publish". If another run already
162+ # advanced `:latest` past us (or diverged), we skip and leave it alone.
163+ - name : Decide whether to move :latest
164+ id : latest_check
165+ run : |
166+ set -euo pipefail
167+ image=nousresearch/hermes-agent
168+
169+ # Pull the JSON for the linux/amd64 sub-manifest's config and extract
170+ # the OCI revision label with jq — Go template field access can't
171+ # handle dots in map keys, so using json+jq is the robust route.
172+ image_json=$(
173+ docker buildx imagetools inspect "${image}:latest" \
174+ --format '{{ json (index .Image "linux/amd64") }}' \
175+ 2>/dev/null || true
176+ )
177+
178+ if [ -z "${image_json}" ]; then
179+ echo "No existing :latest (or inspect failed) — safe to publish."
180+ echo "push_latest=true" >> "$GITHUB_OUTPUT"
181+ exit 0
182+ fi
183+
184+ current_sha=$(
185+ printf '%s' "${image_json}" \
186+ | jq -r '.config.Labels."org.opencontainers.image.revision" // ""'
187+ )
188+
189+ if [ -z "${current_sha}" ]; then
190+ echo "Registry :latest has no revision label — safe to publish."
191+ echo "push_latest=true" >> "$GITHUB_OUTPUT"
192+ exit 0
193+ fi
194+
195+ echo "Registry :latest is at ${current_sha}"
196+ echo "This run is at ${GITHUB_SHA}"
197+
198+ if [ "${current_sha}" = "${GITHUB_SHA}" ]; then
199+ echo ":latest already points at our SHA — nothing to do."
200+ echo "push_latest=false" >> "$GITHUB_OUTPUT"
201+ exit 0
202+ fi
203+
204+ # Make sure we have the :latest commit locally for merge-base.
205+ if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then
206+ git fetch --no-tags --prune origin \
207+ "+refs/heads/main:refs/remotes/origin/main" \
208+ || true
209+ fi
210+
211+ if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then
212+ echo "Registry :latest points at an unknown commit (${current_sha}); refusing to overwrite."
213+ echo "push_latest=false" >> "$GITHUB_OUTPUT"
214+ exit 0
215+ fi
216+
217+ # Our SHA must be a descendant of the current :latest to be safe.
218+ if git merge-base --is-ancestor "${current_sha}" "${GITHUB_SHA}"; then
219+ echo "Our commit is a descendant of :latest — safe to advance."
220+ echo "push_latest=true" >> "$GITHUB_OUTPUT"
221+ else
222+ echo "Another run advanced :latest past us (or diverged) — leaving it alone."
223+ echo "push_latest=false" >> "$GITHUB_OUTPUT"
224+ fi
225+
226+ # Retag the already-pushed SHA manifest as :latest. This is a registry-
227+ # side operation — no rebuild, no layer re-push — so it's quick and
228+ # atomic per-tag. The ancestor check above plus the cancel-in-progress
229+ # concurrency on this job together guarantee we only ever move :latest
230+ # forward in git history.
231+ - name : Move :latest to this SHA
232+ if : steps.latest_check.outputs.push_latest == 'true'
233+ run : |
234+ set -euo pipefail
235+ image=nousresearch/hermes-agent
236+ docker buildx imagetools create \
237+ --tag "${image}:latest" \
238+ "${image}:sha-${GITHUB_SHA}"
0 commit comments