Skip to content

Commit 53a0249

Browse files
authored
Merge pull request #20890 from NousResearch/fix/docker-push
ci(docker): don't cancel overlapping builds, guard :latest
2 parents f1a8e99 + f4031df commit 53a0249

1 file changed

Lines changed: 142 additions & 3 deletions

File tree

.github/workflows/docker-publish.yml

Lines changed: 142 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,32 @@ on:
1616
permissions:
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.
1923
concurrency:
2024
group: docker-${{ github.ref }}
21-
cancel-in-progress: true
25+
cancel-in-progress: false
2226

2327
jobs:
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

Comments
 (0)