Skip to content

Commit a6f1cd7

Browse files
fix(ci): validate the Version PR and preflight its release evidence
The changesets action pushes the Version PR with GITHUB_TOKEN, and a push made with that token starts no workflow run. The conclusion drawn from that was that the Version PR cannot be checked at all, so it merged through a standing ruleset bypass. workflow_dispatch is one of the two documented exceptions to the no-new-run rule, so the same token starts the same CI/CD workflow on the Version PR's own branch — no app, no personal access token, no long-lived credential. Every Version PR head commit gets one run, decided by asking whether the head already has one, so a missed dispatch heals on the next push to main. That run also carries a release evidence preflight. #1743 pinned subject parity — the pre-release scans cover the images the release covers — but the vulnerability policy is only one of the things the release gate evaluates, and a release was still the only thing that ever produced an evidence bundle. SBOM triple validation, the licence report binding, index membership, and the linux/arm64 vulnerability policy for the images we build could each fail for the first time at a release; the pinned upstream digests need no build, so #1743 already covers both of their platforms on the pull request that changes them. The preflight generates and verifies a real bundle over the images its own run built, through the one generator and the one verifier the release uses, so the only checks a release can be the first to perform are the signature checks that need signing material a release creates. Evidence generation and image-digest resolution move out of release.yml into scripts/generate-release-evidence.ts and scripts/resolve-release-images.ts so there is one of each rather than a second copy the preflight could drift from. Platform resolution reuses isImageIndex, so a release subject that is a single manifest fails rather than falling back to the index digest. ci-contract.test.ts asserts both callers run both scripts and that the verifier's only mode-conditional behaviour is signature verification, so a check gated on anything else fails the contract test rather than a release; release-management.mdx carries the check-by-check table and why the upstream images are deliberately judged twice on the Version PR. Verified with pnpm run format and pnpm run check, and with the new node:test coverage for the resolver's retry and hand-off, the generator's manifest against the real verifier, and the dispatcher's branch and run decisions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent aa0ebaf commit a6f1cd7

14 files changed

Lines changed: 733 additions & 131 deletions

.github/workflows/cicd.yml

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,24 @@ name: CI/CD
22

33
on:
44
workflow_dispatch:
5+
inputs:
6+
release-preflight:
7+
description: "Generate and verify this ref's release evidence bundle, everything the release gate checks except the signatures that only exist at a release"
8+
type: boolean
9+
default: false
510
push:
611
branches: ["main"]
712
pull_request:
813
branches: ["**"]
914
merge_group:
1015

16+
# A dispatch never shares a group with a push on the same ref: the Version PR is validated by
17+
# dispatch while `main` is validated by push, and a push produces the images a release promotes, so
18+
# it is the one run that must never be cancelled by another. A pull request or a dispatch validates
19+
# a branch that is still moving, where the newest run is the only one whose answer is wanted.
1120
concurrency:
12-
group: ${{ github.workflow }}-${{ github.ref }}
13-
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
21+
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
22+
cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }}
1423

1524
permissions: {}
1625

@@ -40,6 +49,7 @@ jobs:
4049
docker-config: ${{ steps.filter.outputs.docker-config }}
4150
pmd-canary: ${{ steps.filter.outputs.pmd-canary }}
4251
version-bump: ${{ steps.version_bump.outputs.changed }}
52+
version: ${{ steps.version.outputs.version }}
4353
any-code: ${{ steps.filter.outputs.webapp == 'true' || steps.filter.outputs.application-server == 'true' || steps.filter.outputs.tooling == 'true' || steps.filter.outputs.agent-images == 'true' || steps.filter.outputs.postgres-image == 'true' }}
4454
should_skip: ${{ steps.skip_check.outputs.should_skip }}
4555
timeout-minutes: 5
@@ -54,6 +64,12 @@ jobs:
5464
with:
5565
do_not_skip: '["workflow_dispatch", "push", "merge_group"]'
5666

67+
# The version this ref carries, which the release evidence preflight needs to resolve the
68+
# namespace and signing identity a release of it would use.
69+
- name: Read the release version
70+
id: version
71+
run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT"
72+
5773
- name: Detect version bump
5874
id: version_bump
5975
if: github.event_name == 'push'
@@ -412,14 +428,82 @@ jobs:
412428
agent_images_changed: ${{ (needs.detect-changes.outputs.agent-images == 'true' || needs.detect-changes.outputs.docker-config == 'true' || github.event_name != 'pull_request') && 'true' || 'false' }}
413429
postgres_image_changed: ${{ (needs.detect-changes.outputs.postgres-image == 'true' || needs.detect-changes.outputs.docker-config == 'true' || github.event_name != 'pull_request') && 'true' || 'false' }}
414430

431+
# Everything the release evidence gate checks, against the images this run just built, except the
432+
# signatures and attestations that do not exist until a release creates them. A release was the
433+
# first and only thing that ever produced an evidence bundle, so the SBOM triple, the licence
434+
# report binding, index membership, and the linux/arm64 vulnerability policy for the images we
435+
# build could each fail for the first time at the release — the pinned upstream digests need no
436+
# build, so ci-security-scan.yml already covers both of their platforms on the pull request that
437+
# changes them. release-management.mdx § Nothing may fail for the first time at a release carries
438+
# the check-by-check reasoning.
439+
Release-preflight:
440+
name: "Release evidence preflight"
441+
needs: [detect-changes, Build, Docker]
442+
if: ${{ github.event_name == 'workflow_dispatch' && inputs.release-preflight }}
443+
timeout-minutes: 45
444+
runs-on: ubuntu-latest
445+
permissions:
446+
contents: read
447+
packages: read
448+
steps:
449+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
450+
with:
451+
fetch-depth: 1
452+
persist-credentials: false
453+
454+
- uses: ./.github/actions/setup-node-pnpm
455+
with:
456+
install: "none"
457+
458+
- uses: ./.github/actions/setup-release-security-tools
459+
460+
# The same freshness bound the release applies: a stale mirror must not be read as a clean scan.
461+
- uses: ./.github/actions/download-trivy-db
462+
with:
463+
max-age-hours: "24"
464+
465+
- uses: ./.github/actions/ghcr-login
466+
with:
467+
username: ${{ github.actor }}
468+
password: ${{ secrets.GITHUB_TOKEN }}
469+
470+
- name: Generate and verify this ref's release evidence
471+
env:
472+
# The version this ref would release under. On the Version PR branch that is the release
473+
# being prepared; on any other ref it is the version already published, and either way it
474+
# resolves the namespace and identity the evidence gate demands.
475+
RELEASE: v${{ needs.detect-changes.outputs.version }}
476+
SOURCE_TAG: run-${{ github.run_id }}-${{ github.run_attempt }}
477+
TRIVY_USERNAME: ${{ github.actor }}
478+
TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
479+
run: |
480+
set -euo pipefail
481+
node scripts/resolve-release-images.ts "$SOURCE_TAG" release-images.tsv
482+
node scripts/generate-release-evidence.ts evidence \
483+
--release "$RELEASE" --commit "$GITHUB_SHA" --digests release-images.tsv
484+
node scripts/verify-release-evidence.ts evidence --write-validation
485+
# Again without --write-validation, which re-derives the validation documents and fails if
486+
# they changed: the release runs the verifier twice for the same reason.
487+
node scripts/verify-release-evidence.ts evidence
488+
489+
# An exit status cannot say which subject carries which finding.
490+
- name: Upload the evidence bundle
491+
if: ${{ !cancelled() }}
492+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
493+
with:
494+
name: release-evidence-preflight
495+
path: evidence
496+
if-no-files-found: warn
497+
retention-days: 7
498+
415499
all-ci-passed:
416500
name: "CI Status Gate"
417501
timeout-minutes: 10
418502
runs-on: ubuntu-latest
419503
permissions:
420504
actions: read
421505
statuses: write
422-
needs: [detect-changes, workflow-lint, zizmor, Quality, Build, Security, Test, Changesets, Compose, Docker]
506+
needs: [detect-changes, workflow-lint, zizmor, Quality, Build, Security, Test, Changesets, Compose, Docker, Release-preflight]
423507
if: always()
424508
steps:
425509
- name: Generate workflow timeline
@@ -479,6 +563,7 @@ jobs:
479563
echo "Changesets: ${{ needs.Changesets.result }}"
480564
echo "Compose: ${{ needs.Compose.result }}"
481565
echo "Docker: ${{ needs.Docker.result }}"
566+
echo "Preflight: ${{ needs.Release-preflight.result }}"
482567
483568
if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]]; then
484569
echo "status=failure" >> $GITHUB_OUTPUT
@@ -531,6 +616,7 @@ jobs:
531616
echo "| Compose | $(result_to_emoji '${{ needs.Compose.result }}') |" >> $GITHUB_STEP_SUMMARY
532617
echo "| Security | $(result_to_emoji '${{ needs.Security.result }}') |" >> $GITHUB_STEP_SUMMARY
533618
echo "| Docker | $(result_to_emoji '${{ needs.Docker.result }}') |" >> $GITHUB_STEP_SUMMARY
619+
echo "| Release evidence preflight | $(result_to_emoji '${{ needs.Release-preflight.result }}') |" >> $GITHUB_STEP_SUMMARY
534620
echo "" >> $GITHUB_STEP_SUMMARY
535621
536622
echo "### 📁 Components Changed" >> $GITHUB_STEP_SUMMARY

.github/workflows/release.yml

Lines changed: 12 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -105,50 +105,17 @@ jobs:
105105
username: ${{ github.actor }}
106106
password: ${{ secrets.GITHUB_TOKEN }}
107107

108+
- uses: ./.github/actions/setup-node-pnpm
109+
with:
110+
install: "none"
111+
112+
# The images this release promotes, resolved through the same script the pre-merge evidence
113+
# preflight resolves them with, so the two can never disagree about which artefact is subject.
108114
- name: Capture release image digests
109115
id: retag
110116
env:
111117
SOURCE_TAG: run-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}
112-
run: |
113-
set -euo pipefail
114-
115-
mapfile -t IMAGES < <(jq -er '.images[]' security/release-images.json)
116-
[ "${#IMAGES[@]}" -gt 0 ] || { echo "::error::Release image inventory is empty"; exit 1; }
117-
declare -A DIGESTS=()
118-
: > release-images.tsv
119-
120-
# Resolve the complete image inventory before generating evidence.
121-
for img in "${IMAGES[@]}"; do
122-
FULL_IMAGE="ghcr.io/hephaestus-build/$img"
123-
for i in $(seq 1 24); do
124-
digest=$(docker buildx imagetools inspect "$FULL_IMAGE:$SOURCE_TAG" --format '{{json .Manifest}}' 2>/dev/null | jq -er '.digest' 2>/dev/null) || true
125-
if [[ "$digest" =~ ^sha256:[a-f0-9]{64}$ ]]; then
126-
DIGESTS[$img]=$digest
127-
break
128-
fi
129-
if [ "$i" -ge 24 ]; then
130-
echo "::error::Could not resolve a valid digest for $FULL_IMAGE:$SOURCE_TAG after 120 s"
131-
exit 1
132-
fi
133-
sleep 5
134-
done
135-
done
136-
137-
for img in "${IMAGES[@]}"; do
138-
FULL_IMAGE="ghcr.io/hephaestus-build/$img"
139-
echo "::group::$img"
140-
141-
SRC_DIGEST=${DIGESTS[$img]}
142-
echo "$img source digest: $SRC_DIGEST"
143-
printf '%s\t%s\n' "$img" "$SRC_DIGEST" >> release-images.tsv
144-
145-
case "$img" in
146-
agent-pi) echo "agent-pi-digest=$SRC_DIGEST" >> "$GITHUB_OUTPUT" ;;
147-
application-server) echo "application-server-digest=$SRC_DIGEST" >> "$GITHUB_OUTPUT" ;;
148-
postgres) echo "postgres-digest=$SRC_DIGEST" >> "$GITHUB_OUTPUT" ;;
149-
esac
150-
echo "::endgroup::"
151-
done
118+
run: node scripts/resolve-release-images.ts "$SOURCE_TAG" release-images.tsv
152119

153120
- name: Verify Node-only agent-pi sandbox
154121
env:
@@ -171,10 +138,6 @@ jobs:
171138
172139
- uses: ./.github/actions/setup-release-security-tools
173140

174-
- uses: ./.github/actions/setup-node-pnpm
175-
with:
176-
install: "none"
177-
178141
# The database the whole evidence bundle is scanned against: recorded into the bundle, and
179142
# refused if it is over a day old, so a stale mirror cannot be signed as a fresh scan.
180143
- uses: ./.github/actions/download-trivy-db
@@ -186,61 +149,12 @@ jobs:
186149
env:
187150
TRIVY_USERNAME: ${{ github.actor }}
188151
TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
152+
RELEASE: ${{ needs.release.outputs.tag_name }}
153+
COMMIT: ${{ needs.release.outputs.sha }}
189154
run: |
190155
set -euo pipefail
191-
mkdir -p evidence
192-
: > release-platforms.tsv
193-
started=$SECONDS
194-
jq -n --arg syft "$(syft version -o json | jq -r .version)" \
195-
--arg trivy "$(trivy version --format json | jq -r .Version)" \
196-
--arg cosign "$(cosign version --json | jq -r .gitVersion)" \
197-
'{syft: $syft, trivy: $trivy, cosign: $cosign}' > evidence/tool-versions.json
198-
generate_evidence() {
199-
local image=$1 repository=$2 digest=$3 provenance=$4
200-
local ref="$repository@$digest"
201-
for platform in linux/amd64 linux/arm64; do
202-
suffix=${platform//\//-}
203-
architecture=${platform#*/}
204-
platform_digest=$(docker buildx imagetools inspect "$ref" --raw | jq -er \
205-
--arg architecture "$architecture" \
206-
'.manifests[] | select(.platform.os == "linux" and .platform.architecture == $architecture) | .digest')
207-
[[ "$platform_digest" =~ ^sha256:[a-f0-9]{64}$ ]] || { echo "::error::$image $platform digest is malformed"; exit 1; }
208-
platform_ref="$repository@$platform_digest"
209-
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$image" "$platform" "$digest" "$platform_digest" "$repository" "$provenance" >> release-platforms.tsv
210-
# Scan the registry artifact, never a daemon copy of it: a daemon pull
211-
# re-serializes an OCI manifest as Docker schema 2, so the SBOM would
212-
# record a locally computed manifestDigest instead of the released one.
213-
# --platform makes Syft fail loudly if the digest is not this platform's.
214-
syft --from registry "$platform_ref" --platform "$platform" --scope squashed \
215-
-o "syft-json=evidence/$image-$suffix.syft.json" \
216-
-o "spdx-json=evidence/$image-$suffix.spdx.json" \
217-
-o "cyclonedx-json=evidence/$image-$suffix.cdx.json"
218-
trivy image --skip-db-update --scanners vuln --format json --output "evidence/$image-$suffix.trivy.json" "$platform_ref"
219-
trivy image --skip-db-update --scanners license --format json --output "evidence/$image-$suffix.license.json" "$platform_ref"
220-
done
221-
}
222-
while IFS=$'\t' read -r image digest; do
223-
generate_evidence "$image" "ghcr.io/hephaestus-build/$image" "$digest" first-party
224-
done < release-images.tsv
225-
jq -r '.upstream[] | [.name, .repository, .digest] | @tsv' security/release-images.json |
226-
while IFS=$'\t' read -r image repository digest; do
227-
generate_evidence "$image" "$repository" "$digest" upstream
228-
done
229-
duration=$((SECONDS - started))
230-
jq -n \
231-
--arg schemaVersion "1" \
232-
--arg generatedAt "$(date -u +%FT%TZ)" \
233-
--arg release "${{ needs.release.outputs.tag_name }}" \
234-
--arg commit "${{ needs.release.outputs.sha }}" \
235-
--argjson durationSeconds "$duration" \
236-
--rawfile subjects release-platforms.tsv \
237-
'{schemaVersion: ($schemaVersion | tonumber), release: $release, commit: $commit,
238-
generatedAt: $generatedAt, durationSeconds: $durationSeconds,
239-
subjects: ($subjects | split("\n") | map(select(length > 0) | split("\t") |
240-
{image: .[0], platform: .[1], indexDigest: .[2], digest: .[3], repository: .[4], provenance: .[5]}))}' \
241-
> evidence/manifest.json
242-
cp security/vulnerability-policy.json evidence/vulnerability-policy.json
243-
cp security/release-images.json evidence/release-images.json
156+
node scripts/generate-release-evidence.ts evidence \
157+
--release "$RELEASE" --commit "$COMMIT" --digests release-images.tsv
244158
node scripts/verify-release-evidence.ts evidence --write-validation
245159
jq -er '.subjects[] | select(.provenance == "first-party") | [.repository, .digest, .image, .platform] | @tsv' evidence/manifest.json |
246160
while IFS=$'\t' read -r repository digest image platform; do
@@ -279,7 +193,7 @@ jobs:
279193
ASSET: ${{ steps.pin.outputs.asset-path }}
280194
run: |
281195
set -euo pipefail
282-
while IFS=$'\t' read -r image digest; do
196+
while IFS=$'\t' read -r image _ digest; do
283197
printf '%s %s\n' "${digest#sha256:}" "$image"
284198
done < release-images.tsv > subjects.sha256
285199
sha256sum "$ASSET" >> subjects.sha256

.github/workflows/version-pr.yml

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
name: Version PR
22

33
# Maintains the changesets Version PR; release.yml handles merged version bumps.
4-
# GITHUB_TOKEN-authored updates do not trigger CI, so release automation uses the
5-
# ruleset bypass. A release still requires successful main CI.
4+
#
5+
# The changesets action pushes with GITHUB_TOKEN, and a push made with that token starts no workflow
6+
# run — so the Version PR used to carry no checks at all and merged through a ruleset bypass.
7+
# workflow_dispatch is one of the two documented exceptions to that rule, so the same token starts
8+
# CI/CD on the Version PR's own branch instead; scripts/dispatch-version-pr-ci.ts has the reasoning.
69
#
710
# Contributor guide: docs/contributor/release-management.mdx
811

@@ -21,6 +24,7 @@ jobs:
2124
timeout-minutes: 15
2225
runs-on: ubuntu-latest
2326
permissions:
27+
actions: write # dispatch CI/CD on the Version PR's branch
2428
contents: write
2529
pull-requests: write
2630
steps:
@@ -40,3 +44,10 @@ jobs:
4044
pr-title: "chore(release): version packages"
4145
commit-message: "chore(release): version packages"
4246
github-token: ${{ secrets.GITHUB_TOKEN }}
47+
48+
# Every Version PR head commit gets a CI/CD run, so the commit whose merge cuts a release is
49+
# validated before it lands rather than after.
50+
- name: Run CI on the Version PR
51+
env:
52+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
53+
run: node scripts/dispatch-version-pr-ci.ts

0 commit comments

Comments
 (0)