Repository navigation
795 lines (735 loc) · 34.8 KB
/
Copy pathdocker.yml
File metadata and controls
795 lines (735 loc) · 34.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
name: Docker Build, Test, and Publish
on:
# This workflow owns its own triggers. ci.yml does not call it.
# A reusable-workflow call keeps the caller run in progress for that full time.
# GitHub refuses ``gh run rerun`` on a run that is still in progress.
# Thus one slow advisory job blocked every rerun of the fast required jobs. A separate
# run reruns and cancels independently.
#
# Trusted main pushes resolve the environment-scoped Docker Hub secrets in
# this same workflow, never across a workflow boundary.
#
# The ``release: published`` trigger was REMOVED on purpose: a GitHub release
# event must never rebuild or rewrite the stable Docker channel. This staged
# path publishes only immutable version tags; the ordered stable publication
# controller moves stable/latest from the receipt-bound registry digest.
pull_request:
push:
branches: [main]
workflow_call:
inputs:
commit:
description: Commit to test and build. Empty keeps the triggering commit.
type: string
default: ''
release-phase:
description: "Stable-release phase: 'test' or 'publish'. Empty keeps the standalone triggers."
required: false
type: string
default: ''
tag:
description: "Attempt ref (rc.N-vX.Y.Z) under release; the versioned images carry this tag. The candidate commit arrives in the commit input."
required: false
type: string
default: ''
version:
description: "Release version written directly to the image install stamp."
required: false
type: string
default: ''
skip-tests:
description: "Release test phase only: build and archive the image without its integration tests (the claim skipped tests)."
required: false
type: boolean
default: false
outputs:
manifest-digest:
description: "Immutable digest of the published versioned multi-arch manifest."
value: ${{ jobs.release-publish-manifest.outputs.digest }}
permissions:
contents: read
# Concurrency: push/release runs are NEVER cancelled so every merge gets
# its own image. PR runs reuse a PR-scoped group with
# cancel-in-progress: true so rapid pushes to the same PR collapse to
# the latest commit. Release runs include the run_id: several reusable calls
# (test/publish) of this workflow live inside ONE parent run, and a
# shared group would cancel the parent mid-release.
concurrency:
group: docker-${{ github.event.pull_request.number || github.ref }}-${{ inputs.release-phase || 'standalone' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
IMAGE_NAME: nousresearch/hermes-agent
# A stable run is on the default branch, so GITHUB_SHA is not the candidate.
CANDIDATE_COMMIT: ${{ inputs.commit || github.sha }}
jobs:
# Resolve the release phase. Release modes run only via workflow_call from
# the parent stable-release workflow, which passes the candidate commit.
mode:
name: Resolve release phase
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 5
outputs:
phase: ${{ steps.resolve.outputs.phase }}
release: ${{ steps.resolve.outputs.release }}
steps:
- id: resolve
env:
PHASE: ${{ inputs.release-phase }}
run: |
set -euo pipefail
case "$PHASE" in
'') echo "phase=standalone" >> "$GITHUB_OUTPUT"; echo "release=false" >> "$GITHUB_OUTPUT" ;;
test) echo "phase=test" >> "$GITHUB_OUTPUT"; echo "release=true" >> "$GITHUB_OUTPUT" ;;
publish)
echo "phase=$PHASE" >> "$GITHUB_OUTPUT"; echo "release=true" >> "$GITHUB_OUTPUT" ;;
*) echo "::error::Invalid release-phase input: $PHASE"; exit 1 ;;
esac
# Classify the PR's changed files. ci.yml used to gate the docker call on
# its own ``detect`` outputs; now that this workflow triggers itself, it
# runs the same composite action. On push and release the classifier fails
# open (every lane true), so post-merge validation is never weakened.
# Release phases skip classification entirely: the parent already ran full
# CI and the tag dispatch has no meaningful PR diff to classify.
detect:
name: Detect affected areas
needs: [mode]
if: needs.mode.outputs.release != 'true'
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 10
outputs:
build: ${{ steps.gate.outputs.build }}
steps:
- uses: useblacksmith/checkout@25227e61ff9dafe400e22fa487b673eac4e4409a # v1
with:
ref: ${{ inputs.commit }}
- name: Detect affected areas
id: classify
uses: ./.github/actions/detect-changes
with:
github-token: ${{ github.token }}
- name: Decide whether to build
id: gate
env:
# The docker lane is a slow lane: on a pull request it runs only
# for docker meta, the dependency manifests, PM and tests/docker
# (or the run-e2e label). Every push to main builds the image.
# classify_changes.py owns the formula.
DOCKER: ${{ steps.classify.outputs.docker }}
run: |
set -euo pipefail
if [ "$DOCKER" = "true" ]; then
echo "build=true" >> "$GITHUB_OUTPUT"
else
echo "build=false" >> "$GITHUB_OUTPUT"
fi
# Build and test the image for each architecture. This job runs PR code, so
# in standalone mode it must remain secret-free. Publishing happens in the
# separate, protected publish paths after these tests pass.
#
# Runs ONLY in standalone mode and in the release 'test' phase: it builds
# the SAME Dockerfile and runs the SAME real docker-integration tests, then
# (test phase) saves the tested per-arch image archive + sha256 as run
# artifacts. No registry push, no signing secrets. The publish phase never
# rebuilds — it downloads these exact artifacts.
build:
name: Build and test image (${{ matrix.variant }}/${{ matrix.arch }})
needs: [mode, detect]
if: >-
!cancelled() && needs.mode.result == 'success' &&
((needs.mode.outputs.release != 'true' && github.repository == 'NousResearch/hermes-agent' && needs.detect.outputs.build == 'true') || needs.mode.outputs.phase == 'test')
strategy:
fail-fast: false
matrix:
# Two images per arch. `slim` is what :latest has always been; `desktop`
# adds the Bot Screen packages and distro Chromium fallback for the
# tier that offers a screen. PM's pinned full Chromium is in both.
# Both are built on a PR so gated-layer failures cannot reach publish.
arch: [amd64, arm64]
variant: [slim, desktop]
include:
- arch: amd64
runner: blacksmith-32vcpu-ubuntu-2404
platform: linux/amd64
# arm64 builds on the native arm64 larger runner. A build of
# linux/arm64 on an x64 host uses emulation.
- arch: arm64
runner: blacksmith-32vcpu-ubuntu-2404-arm
platform: linux/arm64
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
steps:
- name: Checkout code
uses: useblacksmith/checkout@25227e61ff9dafe400e22fa487b673eac4e4409a # v1
with:
# Stable dispatches from the default branch and names the claim commit.
# Pin it, never a mutable branch ref.
ref: ${{ inputs.commit || (needs.mode.outputs.release == 'true' && github.sha || '') }}
# Dev identity is the distance from the highest reachable final
# release, so the stamp needs both its ancestry and release tags.
fetch-depth: 0
# The stamp and boundary scripts run on the runner's python3, whose
# version depends on the runner image. Pin it instead of trusting it.
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Write install stamp
env:
RELEASE_VERSION: ${{ needs.mode.outputs.release == 'true' && inputs.version || '' }}
run: |
display="$RELEASE_VERSION"
if [ -z "$display" ]; then
display="$(python3 -m scripts.releases.distance)"
fi
identity=(--commit "$CANDIDATE_COMMIT")
if [ -n "$display" ]; then
base="${display%%+*}"
development="${display#*+}"
if [ "$development" = "$display" ]; then distance=0; else distance="${development%%.*}"; fi
identity+=(--base-version "$base" --display-version "$display" --distance "$distance")
fi
python3 scripts/write_install_stamp.py --output install-stamp.json \
--distribution docker --update-mechanism external --source ci "${identity[@]}"
- name: Reject profile exports in the build context
run: python3 scripts/ci/check_profile_archive_boundary.py
# Retry once on transient Docker Hub / buildkit pull failures
# (connection reset, auth token timeout, rate limiting). The action
# generates a unique builder name per invocation so the retry doesn't
# collide with the failed first attempt. A genuine persistent failure
# still fails the job — only the first attempt has continue-on-error.
# Refs: docker/setup-buildx-action#510
- name: Set up Docker Buildx
id: buildx
continue-on-error: true
uses: useblacksmith/setup-docker-builder@19215110ab936351210feebdfa5b440b4493e184 # v2
- name: Set up Docker Buildx (retry)
if: steps.buildx.outcome == 'failure'
uses: useblacksmith/setup-docker-builder@19215110ab936351210feebdfa5b440b4493e184 # v2
# Build once, load into the local daemon for testing. Cached
# per-arch; the push step below reuses every layer from this build.
- name: Build image (${{ matrix.variant }}/${{ matrix.arch }})
uses: useblacksmith/build-push-action@9b0579bbec7a6cad2f171596c57e7ac1e7658850 # v2
with:
context: .
file: Dockerfile
load: true
platforms: ${{ matrix.platform }}
tags: ${{ env.IMAGE_NAME }}:test
build-args: |
HERMES_BOT_DESKTOP=${{ matrix.variant == 'desktop' && '1' || '0' }}
# Slim owns the scope; desktop adds the gated apt layer, so it reads
# and writes nothing — a mode=max scope could exceed the cache cap.
cache-from: type=gha,scope=docker-${{ matrix.arch }}
cache-to: ${{ (github.event_name != 'pull_request' && matrix.variant == 'slim') && format('type=gha,mode=max,scope=docker-{0}', matrix.arch) || '' }}
# Run the docker-integration test suite against the freshly-built image
# already loaded into the local daemon (`:test`). The fixture's
# HERMES_TEST_IMAGE branch short-circuits the rebuild. Release tests
# archive these exact bytes rather than rebuilding at publish time.
# Park the stamp while PM provisions the runner, then restore it so
# tests/docker can compare the image's provenance with the checkout.
- name: Park the image install stamp while provisioning the runner toolchain
run: mv install-stamp.json "$RUNNER_TEMP/install-stamp.json"
- name: Set up locked Python and test dependencies
if: inputs.skip-tests != true
uses: ./.github/actions/setup-pm
with:
extras: '[]'
test-environment: 'true'
prune-python-cache: true
- name: Restore the image install stamp for the docker tests
run: mv "$RUNNER_TEMP/install-stamp.json" install-stamp.json
- name: Run docker integration tests
if: inputs.skip-tests != true
env:
# Skip rebuild; use the image already loaded by the build step.
HERMES_TEST_IMAGE: ${{ env.IMAGE_NAME }}:test
# Match the policy in tests.yml :: test job — no accidental
# real-API calls from inside the harness.
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
run: |
# Each of these tests drives a container, so the docker daemon sets
# the limit and not the processor. This pins the runner's worker
# count to the core count.
HERMES_TEST_WORKERS=$(nproc) scripts/run_tests.sh tests/docker/
- name: Verify release image identity
if: needs.mode.outputs.phase == 'test'
env:
RELEASE_VERSION: ${{ inputs.version }}
run: |
actual="$(docker run --rm --entrypoint python "${IMAGE_NAME}:test" -c \
'import json; print(json.load(open("/opt/hermes/install-stamp.json"))["baseVersion"])')"
test "$actual" = "$RELEASE_VERSION"
# ---------------------------------------------------------------------
# Release 'test' phase only: hand the EXACT tested bytes to the publish
# phase via actions artifacts. No registry push, no credentials here.
# ---------------------------------------------------------------------
- name: Save tested image archive (release test)
if: needs.mode.outputs.phase == 'test'
env:
ARCH: ${{ matrix.arch }}
RELEASE_TAG: ${{ inputs.tag }}
VARIANT: ${{ matrix.variant }}
run: |
set -euo pipefail
mkdir -p /tmp/image-artifacts
docker image inspect "${IMAGE_NAME}:test" > /tmp/image-artifacts/image-inspect.json
python3 - <<'PY'
import json, os
from pathlib import Path
image = json.loads(Path('/tmp/image-artifacts/image-inspect.json').read_text())[0]
if image['Architecture'] != os.environ['ARCH']:
raise SystemExit('Docker image architecture mismatch')
Path('/tmp/image-artifacts/identity.json').write_text(json.dumps({
'tag': os.environ['RELEASE_TAG'], 'commit': os.environ['CANDIDATE_COMMIT'],
'arch': os.environ['ARCH'], 'variant': os.environ['VARIANT'], 'imageId': image['Id']}), encoding='utf-8')
PY
docker save --output "/tmp/image-artifacts/image-${ARCH}.tar" "${IMAGE_NAME}:test"
(
cd /tmp/image-artifacts
sha256sum "image-${ARCH}.tar" > "image-${ARCH}.tar.sha256"
)
- name: Upload tested image archive (release test)
if: needs.mode.outputs.phase == 'test'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: docker-test-image-${{ matrix.variant }}-${{ matrix.arch }}-${{ inputs.tag }}
path: |
/tmp/image-artifacts/image-${{ matrix.arch }}.tar
/tmp/image-artifacts/image-${{ matrix.arch }}.tar.sha256
/tmp/image-artifacts/identity.json
if-no-files-found: error
retention-days: 7
compression-level: 0
# ---------------------------------------------------------------------------
# Standalone publish: rebuild and push each architecture only after the
# unprivileged build/test matrix passes, on trusted main pushes.
# This job is the sole Docker Hub credential boundary for merges.
#
# NOTE: main pushes tag :main ONLY. :latest is a user-facing stable alias
# now; the ordered publication controller moves it only after the global
# release gate, so a main push can never advance the stable channel.
# ---------------------------------------------------------------------------
publish:
if: >-
needs.mode.outputs.release != 'true' &&
github.repository == 'NousResearch/hermes-agent' &&
github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [mode, build]
environment: container-publish
strategy:
fail-fast: false
matrix:
arch: [amd64, arm64]
variant: [slim, desktop]
include:
- arch: amd64
runner: blacksmith-32vcpu-ubuntu-2404
platform: linux/amd64
# Native arm64 for the same reason as the build matrix above.
- arch: arm64
runner: blacksmith-32vcpu-ubuntu-2404-arm
platform: linux/arm64
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
steps:
- name: Checkout trusted source
uses: useblacksmith/checkout@25227e61ff9dafe400e22fa487b673eac4e4409a # v1
with:
ref: ${{ inputs.commit }}
fetch-depth: 0
# Same python3 steps as the build job; pin it the same way.
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Write install stamp
run: |
display="$(python3 -m scripts.releases.distance)"
identity=(--commit "$CANDIDATE_COMMIT")
if [ -n "$display" ]; then
base="${display%%+*}"
development="${display#*+}"
if [ "$development" = "$display" ]; then distance=0; else distance="${development%%.*}"; fi
identity+=(--base-version "$base" --display-version "$display" --distance "$distance")
fi
python3 scripts/write_install_stamp.py --output install-stamp.json \
--distribution docker --update-mechanism external --source ci "${identity[@]}"
- name: Reject profile exports in the build context
run: python3 scripts/ci/check_profile_archive_boundary.py
# Retry once on transient Docker Hub / buildkit pull failures.
# See build job for rationale; same pattern.
- name: Set up Docker Buildx
id: buildx
continue-on-error: true
uses: useblacksmith/setup-docker-builder@19215110ab936351210feebdfa5b440b4493e184 # v2
- name: Set up Docker Buildx (retry)
if: steps.buildx.outcome == 'failure'
uses: useblacksmith/setup-docker-builder@19215110ab936351210feebdfa5b440b4493e184 # v2
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Push by digest only (no tag). The merge job assembles the tagged
# manifest list after both architecture publishers complete.
- name: Push ${{ matrix.variant }}/${{ matrix.arch }} by digest
id: push
uses: useblacksmith/build-push-action@9b0579bbec7a6cad2f171596c57e7ac1e7658850 # v2
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
build-args: |
HERMES_BOT_DESKTOP=${{ matrix.variant == 'desktop' && '1' || '0' }}
labels: |
org.opencontainers.image.revision=${{ env.CANDIDATE_COMMIT }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=docker-${{ matrix.arch }}
cache-to: ${{ matrix.variant == 'slim' && format('type=gha,mode=max,scope=docker-{0}', matrix.arch) || '' }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.push.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digest-${{ matrix.variant }}-${{ matrix.arch }}
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.
#
# Main pushes tag :main only. :latest is reserved for the ordered stable
# publication controller.
# ---------------------------------------------------------------------------
merge:
# `needs` is the whole 4-leg matrix: only publish both :main variants
# after every arch and variant publisher succeeds.
if: ${{ !cancelled() && needs.mode.outputs.release != 'true' && github.repository == 'NousResearch/hermes-agent' && github.event_name == 'push' && github.ref == 'refs/heads/main' }}
# GitHub-hosted with docker/setup-buildx-action, as before the runner migration: on
# Blacksmith `imagetools create` cannot commit a manifest list to Docker Hub (404 on the
# index commit, any buildx version): v0.22.3 rc.1-rc.4 died on it in the versioned manifest.
runs-on: ubuntu-latest
needs: [mode, publish]
timeout-minutes: 10
environment: container-publish
strategy:
fail-fast: false
matrix:
# One manifest list per variant. `slim` keeps the unsuffixed tags it has
# always had; `desktop` publishes its own digests under -desktop.
include:
- variant: slim
suffix: ""
- variant: desktop
suffix: "-desktop"
steps:
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digest-${{ matrix.variant }}-*
merge-multiple: true
# Retry once on transient Docker Hub / buildkit pull failures.
# See build job for rationale; same pattern.
- name: Set up Docker Buildx
id: buildx
continue-on-error: true
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Set up Docker Buildx (retry)
if: steps.buildx.outcome == 'failure'
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: Create manifest list and push
working-directory: /tmp/digests
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
SUFFIX: ${{ matrix.suffix }}
run: |
set -euo pipefail
# Without nullglob an empty dir yields the literal `*`: one bogus entry.
shopt -s nullglob
args=()
for digest_file in *; do
args+=("${IMAGE_NAME}@sha256:${digest_file}")
done
if [ "${#args[@]}" -ne 2 ]; then echo "::error::Expected two ${SUFFIX} digests"; exit 1; fi
tags=(-t "${IMAGE_NAME}:main${SUFFIX}")
# Retry: Docker Hub API + just-pushed digest eventual consistency
# can transiently fail the create; the operation is idempotent.
for i in 1 2 3; do
if docker buildx imagetools create "${tags[@]}" "${args[@]}"; then
break
fi
if [ "$i" = 3 ]; then
echo "::error::imagetools create failed after 3 attempts"
exit 1
fi
echo "::warning::imagetools create failed (attempt $i); retrying in 20s"
sleep 20
done
- name: Inspect image
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
SUFFIX: ${{ matrix.suffix }}
run: docker buildx imagetools inspect "${IMAGE_NAME}:main${SUFFIX}"
# ===========================================================================
# Staged stable-release path (workflow_call from stable-release.yml).
# The caller is dispatched on the exact stable TAG, so github.sha in every
# reusable call is the release candidate commit.
# ===========================================================================
# Release 'publish' phase: load the EXACT tested image archives uploaded by
# the 'test' phase of this SAME workflow run, verify their hashes, push
# per-arch and NEVER rebuild. No owner gate: on a fork the Docker Hub
# login/push fails loudly (missing credentials) instead of faking green.
release-publish:
name: Publish tested Docker image (${{ matrix.variant }}/${{ matrix.arch }})
if: needs.mode.outputs.phase == 'publish'
needs: [mode]
environment: container-publish
strategy:
fail-fast: false
matrix:
arch: [amd64, arm64]
variant: [slim, desktop]
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 45
env:
ARCH: ${{ matrix.arch }}
RELEASE_TAG: ${{ inputs.tag }}
VARIANT: ${{ matrix.variant }}
SUFFIX: ${{ matrix.variant == 'desktop' && '-desktop' || '' }}
steps:
- name: Checkout release code (helper scripts only, no build)
uses: useblacksmith/checkout@25227e61ff9dafe400e22fa487b673eac4e4409a # v1
with:
ref: ${{ inputs.commit || github.sha }}
- name: Download tested image archive from the test phase (same run)
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: docker-test-image-${{ matrix.variant }}-${{ matrix.arch }}-${{ inputs.tag }}
path: /tmp/image-artifacts
- name: Verify tested archive hash (published bytes == tested bytes)
run: |
set -euo pipefail
cd /tmp/image-artifacts
echo " expected: $(cat "image-${ARCH}.tar.sha256")"
sha256sum --check "image-${ARCH}.tar.sha256"
- name: Set up Docker Buildx
id: buildx
continue-on-error: true
uses: useblacksmith/setup-docker-builder@19215110ab936351210feebdfa5b440b4493e184 # v2
- name: Set up Docker Buildx (retry)
if: steps.buildx.outcome == 'failure'
uses: useblacksmith/setup-docker-builder@19215110ab936351210feebdfa5b440b4493e184 # v2
- name: Load tested image
run: |
set -euo pipefail
docker load --input /tmp/image-artifacts/image-${ARCH}.tar
docker image inspect "${IMAGE_NAME}:test" > /tmp/image-artifacts/loaded.json
python3 - <<'PY'
import json, os
from pathlib import Path
identity = json.loads(Path('/tmp/image-artifacts/identity.json').read_text())
loaded = json.loads(Path('/tmp/image-artifacts/loaded.json').read_text())[0]
expected = (os.environ['RELEASE_TAG'], os.environ['CANDIDATE_COMMIT'], os.environ['ARCH'], os.environ['VARIANT'])
if (identity['tag'], identity['commit'], identity['arch'], identity['variant']) != expected:
raise SystemExit('Tested Docker archive identity mismatch')
if (loaded['Id'], loaded['Architecture']) != (identity['imageId'], identity['arch']):
raise SystemExit('Loaded Docker image differs from tested image')
PY
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Push tested image with per-arch release tag
run: |
set -euo pipefail
docker tag "${IMAGE_NAME}:test" "${IMAGE_NAME}:${RELEASE_TAG}-${ARCH}${SUFFIX}"
docker push "${IMAGE_NAME}:${RELEASE_TAG}-${ARCH}${SUFFIX}"
- name: Record pushed per-arch digest
run: |
set -euo pipefail
mkdir -p /tmp/digests
digest="$(docker buildx imagetools inspect "${IMAGE_NAME}:${RELEASE_TAG}-${ARCH}${SUFFIX}" \
--format '{{json .Manifest.Digest}}' | tr -d '"')"
case "$digest" in
sha256:*) ;;
*) echo "::error::Unexpected digest format: $digest"; exit 1 ;;
esac
printf '%s' "$digest" > "/tmp/digests/${ARCH}.digest"
cat "/tmp/digests/${ARCH}.digest"
- name: Upload per-arch digest artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: docker-publish-digest-${{ matrix.variant }}-${{ matrix.arch }}-${{ inputs.tag }}
path: /tmp/digests/${{ matrix.arch }}.digest
if-no-files-found: error
retention-days: 7
# Assemble the immutable versioned multi-arch manifest and expose its digest
# to the parent release receipt. Registry-side only; nothing is rebuilt.
release-publish-manifest:
name: Assemble versioned manifest and digest receipt
if: needs.mode.outputs.phase == 'publish'
needs: [mode, release-publish]
# GitHub-hosted with docker/setup-buildx-action, as before the runner migration: on
# Blacksmith `imagetools create` cannot commit a manifest list to Docker Hub (404 on the
# index commit, any buildx version): v0.22.3 rc.1-rc.4 died on it in the versioned manifest.
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.list.outputs.digest }}
timeout-minutes: 15
environment: container-publish
env:
RELEASE_TAG: ${{ inputs.tag }}
steps:
- name: Checkout release code (helper scripts only)
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7.0.1
with:
ref: ${{ inputs.commit || github.sha }}
- name: Download per-arch digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: docker-publish-digest-*-${{ inputs.tag }}
- name: Set up Docker Buildx
id: buildx
continue-on-error: true
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Set up Docker Buildx (retry)
if: steps.buildx.outcome == 'failure'
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: Create both immutable versioned manifest lists
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
run: |
set -euo pipefail
cd /tmp/digests
for variant in slim desktop; do
suffix=""
[ "$variant" = desktop ] && suffix=-desktop
digest_files=()
args=()
for arch in amd64 arm64; do
file="docker-publish-digest-${variant}-${arch}-${RELEASE_TAG}/${arch}.digest"
test -s "$file"
digest_files+=("$file")
args+=("${IMAGE_NAME}@$(cat "$file")")
done
verify_manifest() {
python3 - "$1" "${digest_files[@]}" <<'PY'
import json, pathlib, sys
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
expected = {pathlib.Path(path).read_text().strip() for path in sys.argv[2:]}
actual = {row.get("digest") for row in manifest.get("manifests", [])}
if actual != expected or len(manifest.get("manifests", [])) != 2:
raise SystemExit(f"versioned Docker manifest differs: expected {sorted(expected)}, got {sorted(actual)}")
PY
}
ref="${IMAGE_NAME}:${RELEASE_TAG}${suffix}"
if docker buildx imagetools inspect "$ref" --raw > existing.json 2>/dev/null; then
verify_manifest existing.json
else
for i in 1 2 3; do
if docker buildx imagetools create -t "$ref" "${args[@]}"; then break; fi
if [ "$i" = 3 ]; then echo "::error::imagetools create failed after 3 attempts"; exit 1; fi
sleep 20
done
fi
docker buildx imagetools inspect "$ref" --raw > verified.json
verify_manifest verified.json
done
- name: Record slim manifest-list digest for the release receipt
id: list
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
run: |
set -euo pipefail
digest="$(docker buildx imagetools inspect "${IMAGE_NAME}:${RELEASE_TAG}" \
--format '{{json .Manifest.Digest}}' | tr -d '"')"
case "$digest" in
sha256:*) ;;
*) echo "::error::Unexpected manifest-list digest: $digest"; exit 1 ;;
esac
echo "digest=$digest" >> "$GITHUB_OUTPUT"
- name: Emit release manifest artifact
id: manifest
run: |
set -euo pipefail
mkdir -p /tmp/manifest
python3 -m scripts.releases.docker manifest \
--tag "$RELEASE_TAG" \
--commit "$CANDIDATE_COMMIT" \
--digest-amd64 "$(sed 's/^sha256://' /tmp/digests/docker-publish-digest-slim-amd64-${RELEASE_TAG}/amd64.digest)" \
--digest-arm64 "$(sed 's/^sha256://' /tmp/digests/docker-publish-digest-slim-arm64-${RELEASE_TAG}/arm64.digest)" \
> /tmp/manifest/manifest.json
python3 - "$RELEASE_TAG" "${{ steps.list.outputs.digest }}" <<'EOF'
import json, sys
manifest = json.load(open("/tmp/manifest/manifest.json"))
manifest["list-digest"] = sys.argv[2]
manifest["image"] = "nousresearch/hermes-agent"
manifest["tags"] = [sys.argv[1]]
json.dump(manifest, open("/tmp/manifest/manifest.json", "w"), indent=2)
EOF
cat /tmp/manifest/manifest.json
- name: Verify manifest identity
run: |
set -euo pipefail
python3 -m scripts.releases.docker verify \
--tag "$RELEASE_TAG" --commit "$CANDIDATE_COMMIT" /tmp/manifest/manifest.json
- name: Upload release manifest artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: docker-publish-manifest-${{ inputs.tag }}
path: /tmp/manifest/manifest.json
if-no-files-found: error
retention-days: 7
# Strict phase gate: in every release phase, the jobs that phase requires
# must actually have run and succeeded. `if: always()` keeps this job in
# the graph even when earlier jobs were skipped, so a skipped/failed
# publisher turns this red instead of letting the phase go green.
release-phase-gate:
name: Docker phase requirements met
if: always() && inputs.release-phase != ''
needs: [mode, build, release-publish, release-publish-manifest]
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 5
steps:
- name: Validate phase job results
env:
PHASE: ${{ needs.mode.outputs.phase }}
BUILD: ${{ needs.build.result }}
RELEASE_PUBLISH: ${{ needs.release-publish.result }}
RELEASE_MANIFEST: ${{ needs.release-publish-manifest.result }}
MODE: ${{ needs.mode.result }}
run: |
set -euo pipefail
test "$MODE" = success
case "$PHASE" in
test)
failures=()
[ "$BUILD" = success ] || failures+=("build=$BUILD")
[ "${#failures[@]}" -eq 0 ] || { printf '::error::%s\n' "${failures[@]}"; exit 1; }
;;
publish)
failures=()
[ "$RELEASE_PUBLISH" = success ] || failures+=("release-publish=$RELEASE_PUBLISH")
[ "$RELEASE_MANIFEST" = success ] || failures+=("release-publish-manifest=$RELEASE_MANIFEST")
[ "${#failures[@]}" -eq 0 ] || { printf '::error::%s\n' "${failures[@]}"; exit 1; }
;;
*) echo "::error::Unknown phase $PHASE"; exit 1 ;;
esac