-
Notifications
You must be signed in to change notification settings - Fork 2
650 lines (596 loc) · 30 KB
/
Copy pathrelease.yml
File metadata and controls
650 lines (596 loc) · 30 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
name: Release
# Cuts a release when a merge to main bumped the root package version — i.e.
# when the Version PR (maintained by version-pr.yml) has been merged. Triggered
# after CI/CD succeeds so the Docker images for the commit already exist.
#
# A release: tags vX.Y.Z at the version-bump commit, creates the GitHub Release
# from that version's CHANGELOG.md section (flagging schema migrations), verifies
# a seeded previous-release upgrade before publication, retags the CI-built
# images (X.Y.Z, X.Y, latest), and starts the deploy chain
# (staging automatically, production after environment approval).
#
# Versioning contract: docs/admin/compatibility-policy.mdx
on:
workflow_run: # zizmor: ignore[dangerous-triggers] The guard authenticates the completed run before privileged jobs start.
workflows: ["CI/CD"]
types: [completed]
branches: [main]
# Serialize promotion of the moving series and latest tags.
concurrency:
group: release
cancel-in-progress: false
permissions: {}
jobs:
release:
# Only real pushes to our own main — a fork PR's CI/CD run reports
# head_branch of the fork; without these guards a fork branch named "main"
# could drive the privileged release job.
if: >-
${{ github.event.workflow_run.conclusion == 'success'
&& github.event.workflow_run.event == 'push'
&& github.event.workflow_run.head_repository.full_name == github.repository }}
timeout-minutes: 15
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
released: ${{ steps.cut.outputs.released }}
version: ${{ steps.cut.outputs.version }}
major: ${{ steps.cut.outputs.major }}
minor: ${{ steps.cut.outputs.minor }}
tag_name: ${{ steps.cut.outputs.tag_name }}
previous_version: ${{ steps.cut.outputs.previous_version }}
sha: ${{ steps.cut.outputs.sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Pin to the exact commit CI/CD built — never main's tip, which may
# already carry a newer version bump (that would skip this release).
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
- name: Cut release if this commit bumped the version
id: cut
# GITHUB_TOKEN suffices: the tag/Release it creates is not meant to
# trigger any workflow (deploys are wired via `needs`/dispatch below).
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
VERSION=$(git show "$SHA:package.json" | jq -r .version)
PARENT_VERSION=$(git show "${SHA}^:package.json" | jq -r .version)
TAG="v$VERSION"
# Only the Version PR merge changes the root version. Feature merges add
# changesets, not version bumps, so they no-op here.
if [ "$VERSION" = "$PARENT_VERSION" ]; then
echo "Version unchanged at $VERSION — no release to cut."
echo "released=false" >> "$GITHUB_OUTPUT"
exit 0
fi
RESUME=false
if existing=$(gh release view "$TAG" --repo "${{ github.repository }}" --json isDraft,targetCommitish 2>/dev/null); then
if [ "$(jq -r .isDraft <<< "$existing")" != true ]; then
echo "Release $TAG is already published."
echo "released=false" >> "$GITHUB_OUTPUT"
exit 0
fi
[ "$(jq -r .targetCommitish <<< "$existing")" = "$SHA" ] || {
echo "::error::Draft $TAG targets a different commit"; exit 1; }
RESUME=true
fi
echo "Cutting $TAG at $SHA (was $PARENT_VERSION)"
PREV_TAG="v$PARENT_VERSION"
previous=$(gh release view "$PREV_TAG" --repo "${{ github.repository }}" --json isDraft,isPrerelease 2>/dev/null) || {
echo "::error::Previous package version $PARENT_VERSION is not a published release"; exit 1; }
jq -e '.isDraft == false and .isPrerelease == false' <<< "$previous" >/dev/null || {
echo "::error::$PREV_TAG is not a stable published release"; exit 1; }
# Release notes = this version's CHANGELOG.md section (the assembled
# changeset entries), with a first-class migration warning when the
# release touches the Liquibase changelog.
NOTES=$(mktemp)
if [ -n "$PREV_TAG" ] && ! git diff --quiet "$PREV_TAG" "$SHA" -- server/application/src/main/resources/db/changelog/; then
{
echo "> [!WARNING]"
echo "> This release contains **schema migrations**. They run automatically on startup — back up your database before upgrading. See the [migration guide](${{ github.server_url }}/${{ github.repository }}/blob/main/MIGRATION.md)."
echo ""
} >> "$NOTES"
fi
# Materialise the changelog first: awk exits at the next section
# heading, which would SIGPIPE (exit 141) the still-writing `git show`
# once CHANGELOG.md outgrew the 64 KiB pipe buffer.
CHANGELOG_SNAPSHOT=$(mktemp)
git show "$SHA:CHANGELOG.md" > "$CHANGELOG_SNAPSHOT"
awk -v ver="## $VERSION" '
$0 == ver { on=1; next }
on && /^## / { exit }
on { print }
' "$CHANGELOG_SNAPSHOT" >> "$NOTES"
echo "----- release notes -----"; cat "$NOTES"; echo "-------------------------"
if [ "$RESUME" = false ]; then
gh release create "$TAG" --draft \
--title "$TAG" \
--notes-file "$NOTES" \
--target "$SHA" \
--repo "${{ github.repository }}"
fi
{
echo "released=true"
echo "version=$VERSION"
echo "major=${VERSION%%.*}"
MINOR="${VERSION#*.}"; echo "minor=${MINOR%%.*}"
echo "tag_name=$TAG"
echo "sha=$SHA"
echo "previous_version=$PARENT_VERSION"
} >> "$GITHUB_OUTPUT"
- name: Summary
if: steps.cut.outputs.released == 'true'
run: |
echo "## Preparing ${{ steps.cut.outputs.tag_name }}" >> "$GITHUB_STEP_SUMMARY"
echo "Evidence verification is in progress." >> "$GITHUB_STEP_SUMMARY"
tag-images:
needs: release
if: needs.release.outputs.released == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
permissions:
packages: write
id-token: write
attestations: write
contents: write # gh release upload (release image lock)
outputs:
agent-pi-digest: ${{ steps.retag.outputs.agent-pi-digest }}
application-server-digest: ${{ steps.retag.outputs.application-server-digest }}
postgres-digest: ${{ steps.retag.outputs.postgres-digest }}
steps:
- name: Check out the released tree
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release.outputs.sha }}
fetch-depth: 1
- name: Log in to GitHub Container Registry
uses: ./.github/actions/ghcr-login
with:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Capture release image digests
id: retag
env:
SHA: ${{ needs.release.outputs.sha }}
run: |
set -euo pipefail
mapfile -t IMAGES < <(jq -er '.images[]' security/release-images.json)
[ "${#IMAGES[@]}" -gt 0 ] || { echo "::error::Release image inventory is empty"; exit 1; }
declare -A DIGESTS=()
: > release-images.tsv
# Resolve every source digest before publishing any release tag.
for img in "${IMAGES[@]}"; do
FULL_IMAGE="ghcr.io/hephaestus-build/$img"
for i in $(seq 1 24); do
digest=$(docker buildx imagetools inspect "$FULL_IMAGE:$SHA" --format '{{json .Manifest}}' 2>/dev/null | jq -er '.digest' 2>/dev/null) || true
if [[ "$digest" =~ ^sha256:[a-f0-9]{64}$ ]]; then
DIGESTS[$img]=$digest
break
fi
if [ "$i" -ge 24 ]; then
echo "::error::Could not resolve a valid digest for $FULL_IMAGE:$SHA after 120 s"
exit 1
fi
sleep 5
done
done
for img in "${IMAGES[@]}"; do
FULL_IMAGE="ghcr.io/hephaestus-build/$img"
echo "::group::$img"
SRC_DIGEST=${DIGESTS[$img]}
echo "$img source digest: $SRC_DIGEST"
printf '%s\t%s\n' "$img" "$SRC_DIGEST" >> release-images.tsv
case "$img" in
agent-pi) echo "agent-pi-digest=$SRC_DIGEST" >> "$GITHUB_OUTPUT" ;;
application-server) echo "application-server-digest=$SRC_DIGEST" >> "$GITHUB_OUTPUT" ;;
postgres) echo "postgres-digest=$SRC_DIGEST" >> "$GITHUB_OUTPUT" ;;
esac
echo "::endgroup::"
done
- name: Verify Node-only agent-pi sandbox
env:
IMAGE: ghcr.io/hephaestus-build/agent-pi@${{ steps.retag.outputs.agent-pi-digest }}
LAYOUT: server/application/src/main/java/de/tum/cit/aet/hephaestus/agent/runtime/SandboxLayout.java
run: |
set -euo pipefail
docker pull "$IMAGE"
docker run --rm --entrypoint /bin/sh "$IMAGE" -c \
'node --version && for runtime in npm npx corepack yarn yarnpkg pnpm pnpx; do ! command -v "$runtime" || exit 1; done'
expected=$(grep -oE 'RUNTIME_CONTRACT_VERSION = [0-9]+' "$LAYOUT" | grep -oE '[0-9]+$')
[ -n "$expected" ] || { echo "::error::Could not read RUNTIME_CONTRACT_VERSION from $LAYOUT"; exit 1; }
declared=$(docker inspect --format '{{index .Config.Labels "hephaestus.agent.runtime-contract"}}' "$IMAGE")
if [ "$declared" != "$expected" ]; then
echo "::error::agent-pi implements runtime contract '${declared:-<none>}' but the released server stages for v${expected} — refusing to publish a pin for an unmatched pair."
exit 1
fi
echo "agent-pi implements runtime contract v${expected}"
- uses: ./.github/actions/setup-release-security-tools
- uses: ./.github/actions/setup-node-pnpm
with:
install: "none"
- name: Generate and enforce release evidence
env:
TRIVY_USERNAME: ${{ github.actor }}
TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
mkdir -p evidence
: > release-platforms.tsv
started=$SECONDS
for attempt in 1 2 3; do
trivy image --timeout 90s --download-db-only && break
if [ "$attempt" -eq 3 ]; then
echo "::error::Trivy database download failed after $attempt attempts"
exit 1
fi
sleep $((attempt * 5))
done
db_metadata="${TRIVY_CACHE_DIR:-$HOME/.cache/trivy}/db/metadata.json"
jq -e '(.UpdatedAt | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) as $updated |
((now - $updated) >= 0) and ((now - $updated) <= 86400)' \
"$db_metadata" >/dev/null
cp "$db_metadata" evidence/trivy-db.json
jq -n --arg syft "$(syft version -o json | jq -r .version)" \
--arg trivy "$(trivy version --format json | jq -r .Version)" \
--arg cosign "$(cosign version --json | jq -r .gitVersion)" \
'{syft: $syft, trivy: $trivy, cosign: $cosign}' > evidence/tool-versions.json
generate_evidence() {
local image=$1 repository=$2 digest=$3 provenance=$4
local ref="$repository@$digest"
for platform in linux/amd64 linux/arm64; do
suffix=${platform//\//-}
architecture=${platform#*/}
platform_digest=$(docker buildx imagetools inspect "$ref" --raw | jq -er \
--arg architecture "$architecture" \
'.manifests[] | select(.platform.os == "linux" and .platform.architecture == $architecture) | .digest')
[[ "$platform_digest" =~ ^sha256:[a-f0-9]{64}$ ]] || { echo "::error::$image $platform digest is malformed"; exit 1; }
platform_ref="$repository@$platform_digest"
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$image" "$platform" "$digest" "$platform_digest" "$repository" "$provenance" >> release-platforms.tsv
syft "$platform_ref" --scope squashed \
-o "syft-json=evidence/$image-$suffix.syft.json" \
-o "spdx-json=evidence/$image-$suffix.spdx.json" \
-o "cyclonedx-json=evidence/$image-$suffix.cdx.json"
trivy image --skip-db-update --scanners vuln --format json --output "evidence/$image-$suffix.trivy.json" "$platform_ref"
trivy image --skip-db-update --scanners license --format json --output "evidence/$image-$suffix.license.json" "$platform_ref"
done
}
while IFS=$'\t' read -r image digest; do
generate_evidence "$image" "ghcr.io/hephaestus-build/$image" "$digest" first-party
done < release-images.tsv
jq -r '.upstream[] | [.name, .repository, .digest] | @tsv' security/release-images.json |
while IFS=$'\t' read -r image repository digest; do
generate_evidence "$image" "$repository" "$digest" upstream
done
duration=$((SECONDS - started))
jq -n \
--arg schemaVersion "1" \
--arg generatedAt "$(date -u +%FT%TZ)" \
--arg release "${{ needs.release.outputs.tag_name }}" \
--arg commit "${{ needs.release.outputs.sha }}" \
--argjson durationSeconds "$duration" \
--rawfile subjects release-platforms.tsv \
'{schemaVersion: ($schemaVersion | tonumber), release: $release, commit: $commit,
generatedAt: $generatedAt, durationSeconds: $durationSeconds,
subjects: ($subjects | split("\n") | map(select(length > 0) | split("\t") |
{image: .[0], platform: .[1], indexDigest: .[2], digest: .[3], repository: .[4], provenance: .[5]}))}' \
> evidence/manifest.json
cp security/vulnerability-policy.json evidence/vulnerability-policy.json
cp security/release-images.json evidence/release-images.json
node scripts/verify-release-evidence.ts evidence --write-validation
jq -er '.subjects[] | select(.provenance == "first-party") | [.repository, .digest, .image, .platform] | @tsv' evidence/manifest.json |
while IFS=$'\t' read -r repository digest image platform; do
suffix=${platform//\//-}
cosign attest --yes --type spdxjson --predicate "evidence/$image-$suffix.spdx.json" "$repository@$digest"
done
(cd evidence && sha256sum -- * > SHA256SUMS)
- name: Verify evidence from registry subjects
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/verify-release-evidence.ts evidence --verify-signatures
- name: Write release image lock
id: pin
env:
VERSION: ${{ needs.release.outputs.version }}
run: |
set -euo pipefail
ASSET="release-v${VERSION}.json"
jq '{schemaVersion, release, commit,
images: [.subjects[]]
| group_by(.image)
| map({image: .[0].image, repository: .[0].repository, provenance: .[0].provenance,
indexDigest: .[0].indexDigest,
platforms: (map({key: .platform, value: .digest}) | from_entries)})}' \
evidence/manifest.json > "$ASSET"
node scripts/release-image-lock.ts "$ASSET" evidence/manifest.json \
"v${VERSION}" /tmp/release-lock.env
echo "asset-path=$ASSET" >> "$GITHUB_OUTPUT"
jq . "$ASSET"
- name: Generate subject checksums
id: subjects
env:
ASSET: ${{ steps.pin.outputs.asset-path }}
run: |
set -euo pipefail
while IFS=$'\t' read -r image digest; do
printf '%s %s\n' "${digest#sha256:}" "$image"
done < release-images.tsv > subjects.sha256
sha256sum "$ASSET" >> subjects.sha256
cat subjects.sha256
# purl namespace/name are canonically lowercase; derive them from the run
# context so the attestation follows a repository transfer (issue #1599).
echo "purl-repository=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- name: Attest release image lock
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-checksums: subjects.sha256
predicate-type: https://in-toto.io/attestation/release/v0.1
predicate: |
{ "purl": "pkg:github/${{ steps.subjects.outputs.purl-repository }}@${{ needs.release.outputs.tag_name }}" }
- name: Sign release image lock
env:
ASSET: ${{ steps.pin.outputs.asset-path }}
run: |
set -euo pipefail
cosign sign-blob --yes --bundle "${ASSET}.sigstore.json" "$ASSET"
- name: Verify signature with the deploy-side identity (fail fast)
env:
ASSET: ${{ steps.pin.outputs.asset-path }}
run: |
set -euo pipefail
cosign verify-blob \
--bundle "${ASSET}.sigstore.json" \
--certificate-identity '${{ github.server_url }}/${{ github.repository }}/.github/workflows/release.yml@refs/heads/main' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
"$ASSET"
- name: Upload release image lock to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ needs.release.outputs.tag_name }}
ASSET: ${{ steps.pin.outputs.asset-path }}
run: |
set -euo pipefail
gh release upload "$TAG_NAME" \
"$ASSET" "${ASSET}.sigstore.json" evidence/* \
--repo "${{ github.repository }}"
upgrade-test:
needs: [release, tag-images]
if: needs.release.outputs.released == 'true'
uses: ./.github/workflows/release-upgrade.yml
permissions:
contents: read
packages: read
with:
# The previous release keeps the namespace it was published under (GHCR packages
# do not transfer between organizations — issue #1599), so the reusable workflow
# resolves its repository per version from security/release-identities.json.
previous-version: ${{ needs.release.outputs.previous_version }}
candidate-application-image: ghcr.io/hephaestus-build/application-server@${{ needs.tag-images.outputs.application-server-digest }}
postgres-image: ghcr.io/hephaestus-build/postgres@${{ needs.tag-images.outputs.postgres-digest }}
candidate-source-sha: ${{ needs.release.outputs.sha }}
supported-host-smoke:
needs: [release, tag-images]
if: needs.release.outputs.released == 'true'
name: Host smoke (${{ matrix.architecture }})
strategy:
fail-fast: false
matrix:
include:
- architecture: amd64
runner: ubuntu-24.04
- architecture: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
timeout-minutes: 20
permissions:
# The release is still a draft here, and draft releases are visible only to tokens with push
# access, so `gh release download` in prepare-release-lock.ts needs contents: write.
contents: write
packages: read
steps:
- name: Check out the released tree
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release.outputs.sha }}
persist-credentials: false
- uses: ./.github/actions/setup-node-pnpm
with:
install: "none"
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Log in to GitHub Container Registry
uses: ./.github/actions/ghcr-login
with:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Smoke-test the blessed install
env:
ARCHITECTURE: ${{ matrix.architecture }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_SHA: ${{ needs.release.outputs.sha }}
TAG_NAME: ${{ needs.release.outputs.tag_name }}
run: |
set -euo pipefail
cd docker/self-host
cleanup() {
status=$?
if [ "$status" -ne 0 ]; then
docker compose --env-file .env --env-file release-lock.env logs --no-color || true
fi
docker compose --env-file .env --env-file release-lock.env down -v --remove-orphans || true
exit "$status"
}
trap cleanup EXIT
actual_arch=$(docker version --format '{{.Server.Arch}}')
[ "$actual_arch" = "$ARCHITECTURE" ] || { echo "::error::Expected native $ARCHITECTURE, got $actual_arch"; exit 1; }
. /etc/os-release
[ "$ID" = ubuntu ] && [ "$VERSION_ID" = 24.04 ] || { echo "::error::Ubuntu 24.04 is required"; exit 1; }
docker_major=$(docker version --format '{{.Server.Version}}' | cut -d. -f1)
[ "$docker_major" -ge 28 ] || { echo "::error::Docker Engine 28 or newer is required"; exit 1; }
compose_version=$(docker compose version --short)
[ "$(printf '%s\n' 2.24.4 "$compose_version" | sort -V | head -n1)" = 2.24.4 ] || {
echo "::error::Docker Compose 2.24.4 or newer is required"; exit 1; }
./setup.sh
sed -i \
-e 's/^APP_HOSTNAME=$/APP_HOSTNAME=hephaestus-smoke.invalid/' \
-e 's/^ACME_EMAIL=$/ACME_EMAIL=release-smoke@example.invalid/' \
-e 's/^GH_OAUTH_CLIENT_ID=$/GH_OAUTH_CLIENT_ID=release-smoke/' \
-e 's/^GH_OAUTH_CLIENT_SECRET=$/GH_OAUTH_CLIENT_SECRET=release-smoke-secret/' \
-e 's/^HEPHAESTUS_AUTH_BOOTSTRAP_ADMINS=$/HEPHAESTUS_AUTH_BOOTSTRAP_ADMINS=github:1/' \
.env
node ../../scripts/prepare-release-lock.ts "$TAG_NAME"
docker compose --env-file .env --env-file release-lock.env config --quiet
docker compose --env-file .env --env-file release-lock.env up -d --wait --wait-timeout 600
curl --fail --insecure --silent --show-error --max-time 10 --noproxy '*' \
--resolve hephaestus-smoke.invalid:443:127.0.0.1 \
https://hephaestus-smoke.invalid/ >/dev/null
jq -n \
--arg schemaVersion "1" \
--arg release "$TAG_NAME" \
--arg commit "$RELEASE_SHA" \
--arg testedAt "$(date -u +%FT%TZ)" \
--arg os "$(. /etc/os-release; printf '%s %s' "$NAME" "$VERSION_ID")" \
--arg architecture "$actual_arch" \
--arg dockerEngine "$(docker version --format '{{.Server.Version}}')" \
--arg dockerCompose "$compose_version" \
--argjson services "$(docker compose --env-file .env --env-file release-lock.env ps --all --format json | jq -s 'map({service: .Service, state: .State, health: .Health, exitCode: .ExitCode}) | sort_by(.service)')" \
'{schemaVersion: ($schemaVersion | tonumber), release: $release, commit: $commit,
testedAt: $testedAt, host: {os: $os, architecture: $architecture,
dockerEngine: $dockerEngine, dockerCompose: $dockerCompose}, services: $services}' \
> "../../host-smoke-$ARCHITECTURE.json"
- name: Upload smoke-test record
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: host-smoke-${{ matrix.architecture }}
path: host-smoke-${{ matrix.architecture }}.json
if-no-files-found: error
publish-release:
needs: [release, tag-images, upgrade-test, supported-host-smoke]
if: needs.release.outputs.released == 'true'
timeout-minutes: 20
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
attestations: read
steps:
- name: Checkout release verifier
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release.outputs.sha }}
fetch-depth: 1
persist-credentials: false
- uses: ./.github/actions/setup-node-pnpm
with:
install: "none"
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Log in to GitHub Container Registry
uses: ./.github/actions/ghcr-login
with:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Download supported-host smoke records
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: host-smoke-*
path: host-smoke
merge-multiple: true
- name: Verify supported-host smoke records
env:
RELEASE_SHA: ${{ needs.release.outputs.sha }}
TAG_NAME: ${{ needs.release.outputs.tag_name }}
run: |
set -euo pipefail
jq -s -e --arg release "$TAG_NAME" --arg commit "$RELEASE_SHA" '
length == 2 and
([.[].host.architecture] | sort) == ["amd64", "arm64"] and
all(.[];
.schemaVersion == 1 and .release == $release and .commit == $commit and
(.testedAt | fromdateiso8601) and
.host.os == "Ubuntu 24.04" and
(.host.dockerEngine | type) == "string" and
(.host.dockerCompose | type) == "string" and
(.services | length) > 0 and
([.services[].service] | length) == ([.services[].service] | unique | length) and
all(.services[];
if .state == "exited" then .exitCode == 0
else .state == "running" and .health == "healthy" end))' host-smoke/*.json >/dev/null
- name: Verify durable assets from a clean environment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ needs.release.outputs.tag_name }}
run: |
set -euo pipefail
mkdir evidence
gh release download "$TAG_NAME" --repo "${{ github.repository }}" --dir evidence
(cd evidence && sha256sum -c SHA256SUMS)
lock="release-${TAG_NAME}.json"
cosign verify-blob \
--bundle "evidence/$lock.sigstore.json" \
--certificate-identity '${{ github.server_url }}/${{ github.repository }}/.github/workflows/release.yml@refs/heads/main' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
"evidence/$lock"
node scripts/release-image-lock.ts "evidence/$lock" evidence/manifest.json \
"$TAG_NAME" /tmp/release-lock.env
node scripts/verify-release-evidence.ts evidence --verify-signatures
asset="evidence/release-${TAG_NAME}.json"
cosign verify-blob --bundle "${asset}.sigstore.json" \
--certificate-identity '${{ github.server_url }}/${{ github.repository }}/.github/workflows/release.yml@refs/heads/main' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' "$asset" >/dev/null
- name: Promote verified image digests
env:
VERSION: ${{ needs.release.outputs.version }}
SERIES: ${{ needs.release.outputs.major }}.${{ needs.release.outputs.minor }}
run: |
set -euo pipefail
jq -er '[.subjects[] | select(.provenance == "first-party") | [.repository, .indexDigest]] | unique[] | @tsv' evidence/manifest.json |
while IFS=$'\t' read -r ref digest; do
docker buildx imagetools create -t "$ref:$VERSION" "$ref@$digest"
promoted=$(docker buildx imagetools inspect "$ref:$VERSION" --format '{{json .Manifest}}' | jq -r .digest)
[ "$promoted" = "$digest" ] || { echo "::error::Promotion changed $ref:$VERSION digest"; exit 1; }
done
jq -er '[.subjects[] | select(.provenance == "first-party") | [.repository, .indexDigest]] | unique[] | @tsv' evidence/manifest.json |
while IFS=$'\t' read -r ref digest; do
docker buildx imagetools create -t "$ref:$SERIES" -t "$ref:latest" "$ref@$digest"
for tag in "$SERIES" latest; do
promoted=$(docker buildx imagetools inspect "$ref:$tag" --format '{{json .Manifest}}' | jq -r .digest)
[ "$promoted" = "$digest" ] || { echo "::error::Promotion changed $ref:$tag digest"; exit 1; }
done
done
- name: Publish release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ needs.release.outputs.tag_name }}
run: |
set -euo pipefail
gh release upload "$TAG_NAME" host-smoke/*.json --repo "${{ github.repository }}"
gh release edit "$TAG_NAME" --repo "${{ github.repository }}" --draft=false
[ "$(gh release view "$TAG_NAME" --repo "${{ github.repository }}" --json isImmutable --jq .isImmutable)" = true ] || {
echo "::error::Repository immutable releases must be enabled before publishing"; exit 1; }
deploy-staging:
needs: [release, tag-images, publish-release]
if: needs.release.outputs.released == 'true'
uses: ./.github/workflows/deploy-staging.yml
permissions:
contents: read
packages: read
with:
image-tag: ${{ needs.release.outputs.tag_name }}
# Deploy credentials include organization secrets, which reach nested reusable
# workflows only through inheritance — environment secrets alone do not cover them.
secrets: inherit # zizmor: ignore[secrets-inherit]
deploy-production:
needs: [release, deploy-staging]
if: needs.release.outputs.released == 'true'
uses: ./.github/workflows/deploy-prod.yml
permissions:
contents: read
with:
image-tag: ${{ needs.release.outputs.tag_name }}
# Deploy credentials include organization secrets, which reach nested reusable
# workflows only through inheritance — environment secrets alone do not cover them.
secrets: inherit # zizmor: ignore[secrets-inherit]