55 - cron : ' 23 3 * * 1' # Weekly Monday 3:23am UTC
66 workflow_dispatch :
77
8+ # Never cancel: a dispatch landing during the Monday cron would otherwise leave two
9+ # runs racing to file the same failure issue.
10+ concurrency :
11+ group : ${{ github.workflow }}
12+ cancel-in-progress : false
13+
814jobs :
915 verify :
1016 name : Verify Latest Release Attestations
11- # GitHub-hosted, NOT the self-hosted `cachekit` runner. Two independently
12- # verified reasons, either of which alone makes this job incapable of
13- # verifying anything on ARC (LAB-984):
14- # 1. The ARC pods have no `gh` CLI (LAB-899). `gh attestation verify` is the
15- # only tool that performs Sigstore bundle verification — there is no
16- # github-script equivalent, and `gh attestation list` (which the previous
17- # version of this file called) is not a real subcommand at all. Every `gh`
18- # invocation here exited 127 into a `2>/dev/null || echo ""` and the job
19- # reported green while skipping its own checks for weeks.
20- # 2. The ARC pods have unreliable DNS/egress to Sigstore (Fulcio/Rekor) —
21- # the same reason release.yml's publish job is already on ubuntu-latest.
22- # This job runs weekly and is free on public repos.
17+ # ubuntu-latest, NOT the self-hosted `cachekit` runner: the ARC pods have no `gh`
18+ # binary (LAB-899) and unreliable Sigstore (Fulcio/Rekor) egress — the same reason
19+ # release.yml's publish job is hosted. `gh attestation verify` is the only tool
20+ # that does Sigstore bundle verification, so moving this job back to ARC silently
21+ # re-breaks it (LAB-984: every `gh` call exited 127 into `|| echo ""`, and the job
22+ # reported green while verifying nothing for weeks).
2323 runs-on : ubuntu-latest
2424 permissions :
2525 contents : read
2626 issues : write
2727 steps :
28- # github-script, NOT `gh`: confines `gh` to the single thing only it can do
29- # (Sigstore verification, below) and keeps API calls on the SHA-pinned action
30- # this repo already standardised on (release.yml:44).
31- #
32- # getLatestRelease is deliberate: it 404s when — and only when — the repo has
33- # zero published releases, so "nothing to verify" is unreachable via an API
34- # error. Any non-404 rethrows and fails the job. No error swallowing.
28+ # github-script, not `gh`, for API calls — the convention release.yml documents
29+ # on its "Assign release PR" step. `gh` is confined to Sigstore verification below.
3530 - name : Resolve latest release
3631 id : release
3732 uses : actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
@@ -45,17 +40,36 @@ jobs:
4540 });
4641 tagName = data.tag_name;
4742 } catch (error) {
48- if (error.status === 404) {
49- core.info('Repository has no published releases — nothing to verify.');
50- core.setOutput('skip', 'true');
43+ if (error.status !== 404) {
44+ throw error;
45+ }
46+ // A 404 here does NOT mean "no releases". getLatestRelease returns the
47+ // latest non-draft, non-prerelease release, so a repo whose releases are
48+ // all drafts or prereleases 404s too. Skipping on a bare 404 would be a
49+ // silent green — the exact fail-open this file was rewritten to remove.
50+ // Only a genuinely empty release list may skip.
51+ const { data: releases } = await github.rest.repos.listReleases({
52+ owner: context.repo.owner,
53+ repo: context.repo.repo,
54+ per_page: 1,
55+ });
56+ if (releases.length > 0) {
57+ core.setFailed(
58+ 'No published full release, but the repository has draft/prerelease releases. ' +
59+ 'Nothing verifiable — refusing to report green.',
60+ );
5161 return;
5262 }
53- throw error;
63+ core.info('Repository has no releases at all — nothing to verify.');
64+ core.setOutput('skip', 'true');
65+ return;
5466 }
5567 // release-please tags this crate `<package-name>-v<semver>`
5668 // (release-please-config.json: package-name cachekit-core, release-type rust).
57- // An unparseable tag is a hard failure, never a silent skip.
58- const match = /^(?<crate>.+)-v(?<version>\d+\.\d+\.\d+.*)$/.exec(tagName);
69+ // The character classes are deliberately tight: these values build a URL and
70+ // a file path, so `/` and shell metacharacters must not survive parsing.
71+ const match = /^(?<crate>[A-Za-z0-9_-]+)-v(?<version>\d+\.\d+\.\d+[0-9A-Za-z.+-]*)$/
72+ .exec(tagName);
5973 if (!match) {
6074 core.setFailed(`Cannot derive crate name and version from release tag '${tagName}'`);
6175 return;
@@ -66,18 +80,13 @@ jobs:
6680 core.setOutput('version', match.groups.version);
6781 core.info(`Latest release ${tagName} -> crate ${match.groups.crate} version ${match.groups.version}`);
6882
69- # The attested artifact is the `.crate` that release.yml hands to
70- # attest-build-provenance and attest-sbom (`target/package/*.crate`) — the
71- # identical file `cargo publish` then uploads to crates.io. GitHub *release
72- # assets* are deliberately not consulted: this repo publishes none (v0.4.0 has
73- # an empty asset list) and they were never the attestation subject. crates.io
74- # is the canonical copy and is byte-identical by digest, which is what
75- # attestation lookup keys on.
83+ # The attestation subject is the `.crate` release.yml hands to
84+ # attest-build-provenance / attest-sbom (`target/package/*.crate`) — the identical
85+ # file `cargo publish` uploads. crates.io holds that byte-identical copy, and this
86+ # repo publishes no GitHub release assets, so crates.io is the artifact source.
7687 #
77- # `curl -f`, no `|| true`: a tagged release whose crate never reached crates.io
78- # is a genuine supply-chain gap — publish failing after the tag landed is
79- # precisely what release.yml's manual re-publish escape hatch exists for — so
80- # it must fail this job rather than degrade to a skip.
88+ # `curl -f` with no `|| true`: a tagged release whose crate never reached crates.io
89+ # is a real supply-chain gap, so it must fail the job rather than degrade to a skip.
8190 - name : Download attested crate from crates.io
8291 if : steps.release.outputs.skip != 'true'
8392 id : artifact
8796 run : |
8897 mkdir -p release-assets
8998 ARTIFACT="release-assets/${CRATE}-${VERSION}.crate"
90- # -A is mandatory, not politeness: crates.io's API answers 403 to a generic
91- # user agent (verified — plain `curl/8.x` gets 403 on this exact URL), and
92- # the crates.io crawler policy asks callers to identify themselves.
99+ # -A is mandatory, not politeness: crates.io answers 403 to a generic user
100+ # agent (verified — plain `curl/8.x` gets 403 on this exact URL).
93101 curl -fsSL --retry 3 --retry-connrefused \
94102 -A "cachekit-core-attestation-check (+https://github.com/${GITHUB_REPOSITORY})" \
95103 "https://crates.io/api/v1/crates/${CRATE}/${VERSION}/download" \
@@ -98,90 +106,93 @@ jobs:
98106 echo "path=$ARTIFACT" >> "$GITHUB_OUTPUT"
99107 echo "Downloaded $ARTIFACT ($(wc -c < "$ARTIFACT") bytes, sha256 $(sha256sum "$ARTIFACT" | cut -d' ' -f1))"
100108
101- # `--signer-workflow` pins WHICH workflow was allowed to sign. Without it,
102- # `--repo` alone accepts an attestation produced by any workflow in the repo,
103- # so a compromised workflow could mint one that passes.
109+ # Identity pinning takes BOTH flags. `--signer-workflow` is an unanchored prefix
110+ # match on the certificate SAN (verified: a truncated `.../release` with no `.yml`
111+ # also passes), so on its own it pins neither the ref nor the full filename — an
112+ # actor with push access could mint provenance from a modified release.yml on any
113+ # branch and this check would bless it. `--source-ref` is what asserts "built by
114+ # the release pipeline on main", which is the only claim worth making here.
104115 #
105- # `--format json --jq` is not cosmetic: gh prints its "Verification succeeded"
106- # banner only to a TTY, so on a runner a passing verify is otherwise completely
107- # silent — indistinguishable in the log from the skip this job used to do.
108- # The jq line puts the verified digest, predicate type and signer URI in the log .
116+ # `--format json --jq` is the evidence trail, not decoration: verified by
117+ # redirecting output, a passing `gh attestation verify` prints NOTHING and exits 0,
118+ # which in a log is indistinguishable from the skip this job used to do. The jq
119+ # line names the digest that was actually verified .
109120 - name : Verify provenance attestation
110121 if : steps.release.outputs.skip != 'true'
111122 env :
112123 GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
113124 ARTIFACT : ${{ steps.artifact.outputs.path }}
125+ TAG : ${{ steps.release.outputs.tag }}
114126 run : |
115- echo "Verifying SLSA provenance for ${{ steps.release.outputs.tag }} "
127+ echo "Verifying SLSA provenance for $TAG "
116128 gh attestation verify "$ARTIFACT" \
117129 --repo "$GITHUB_REPOSITORY" \
118130 --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml" \
131+ --source-ref refs/heads/main \
119132 --predicate-type https://slsa.dev/provenance/v1 \
120133 --format json \
121134 --jq '.[] | "verified provenance: digest=\(.verificationResult.statement.subject[0].digest.sha256) predicate=\(.verificationResult.statement.predicateType) signer=\(.verificationResult.signature.certificate.buildSignerURI)"'
122135
123- # Predicate type is `https://cyclonedx.org/bom` — unversioned. That is what
124- # actions/attest-sbom emits for the CycloneDX 1.6 document release.yml generates
125- # (`cargo sbom --output-format cyclone_dx_json_1_6`). The previous version of
126- # this file looked for `https://cyclonedx.org/bom/v1.6` with an
127- # `|| <spdx.dev/Document/v2.3>` fallback; both strings match nothing here, so
128- # that check could not have passed even with an artifact in hand. There is no
129- # SPDX attestation to fall back to, so there is no fallback — one predicate,
130- # verified or red.
136+ # Predicate type is the UNVERSIONED `https://cyclonedx.org/bom` — that is what
137+ # actions/attest-sbom emits for the CycloneDX 1.6 document release.yml generates.
138+ # Do not "fix" this to `.../bom/v1.6`: nothing matches that, nor SPDX. There is no
139+ # second SBOM predicate to fall back to, so there is no fallback — verified or red.
131140 - name : Verify SBOM attestation
132141 if : steps.release.outputs.skip != 'true'
133142 env :
134143 GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
135144 ARTIFACT : ${{ steps.artifact.outputs.path }}
145+ TAG : ${{ steps.release.outputs.tag }}
136146 run : |
137- echo "Verifying CycloneDX SBOM attestation for ${{ steps.release.outputs.tag }} "
147+ echo "Verifying CycloneDX SBOM attestation for $TAG "
138148 gh attestation verify "$ARTIFACT" \
139149 --repo "$GITHUB_REPOSITORY" \
140150 --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml" \
151+ --source-ref refs/heads/main \
141152 --predicate-type https://cyclonedx.org/bom \
142153 --format json \
143154 --jq '.[] | "verified SBOM: digest=\(.verificationResult.statement.subject[0].digest.sha256) predicate=\(.verificationResult.statement.predicateType) signer=\(.verificationResult.signature.certificate.buildSignerURI)"'
144155
145- # Deduped: this job is weekly, so an unfixed failure would otherwise file a
146- # fresh issue every Monday. If a tracking issue is already open, log and stop.
147- # Scans the first 100 open `bug` issues — ample for this repo; if that is ever
148- # exceeded the worst case is a duplicate issue , not a missed alert.
149- # github-script rather than `gh issue create` so a failure to raise the alert
150- # is itself a hard failure instead of a shell exit status nobody reads .
156+ # Weekly job: without dedupe one unfixed break files a fresh issue every Monday,
157+ # and a muted `bug` label is itself a fail-open alert channel. The marker is
158+ # per-tag, so a NEW tag's failure still alerts instead of hiding behind the open
159+ # issue for the old one. Matched on body , not title or label, so a human
160+ # retitling or relabelling the issue cannot break dedupe. The 100-issue page is
161+ # ample here; worst case is a duplicate issue, never a missed alert .
151162 - name : Open issue on failure
152163 if : failure()
153164 uses : actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
154165 env :
155166 TAG : ${{ steps.release.outputs.tag }}
156167 with :
157168 script : |
158- const TITLE_PREFIX = 'Attestation verification failed ';
159- const tag = process.env.TAG || '( tag unresolved)' ;
169+ const tag = process.env.TAG || 'unresolved-release ';
170+ const marker = `<!-- attestation-check:${ tag} -->` ;
160171 const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
161172 const { data: open } = await github.rest.issues.listForRepo({
162173 owner: context.repo.owner,
163174 repo: context.repo.repo,
164175 state: 'open',
165- labels: 'bug',
166176 per_page: 100,
167177 });
168- const existing = open.find((i) => !i.pull_request && i.title.startsWith(TITLE_PREFIX ));
178+ const existing = open.find((i) => !i.pull_request && (i.body || '').includes(marker ));
169179 if (existing) {
170- core.info(`Tracking issue #${existing.number} is already open ; not filing a duplicate. Run: ${runUrl}`);
180+ core.info(`Issue #${existing.number} already tracks ${tag} ; not filing a duplicate. Run: ${runUrl}`);
171181 return;
172182 }
173183 const { data: created } = await github.rest.issues.create({
174184 owner: context.repo.owner,
175185 repo: context.repo.repo,
176- title: `${TITLE_PREFIX} for ${tag}`,
186+ title: `Attestation verification failed for ${tag}`,
177187 body: [
188+ marker,
178189 `The weekly attestation health check failed for \`${tag}\`.`,
179190 '',
180191 `Run: ${runUrl}`,
181192 '',
182193 'Check that release.yml produced valid SLSA provenance and CycloneDX SBOM',
183- 'attestations for the published `.crate`, and that the crate actually ',
184- 'reached crates.io for this tag.',
194+ 'attestations for the published `.crate`, that it ran on `main`, and that ',
195+ 'the crate actually reached crates.io for this tag.',
185196 ].join('\n'),
186197 labels: ['bug'],
187198 });
0 commit comments