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
name: Attestation Health Check
on:
schedule:
- cron: '23 3 * * 1' # Weekly Monday 3:23am UTC
workflow_dispatch:
# Never cancel: a dispatch landing during the Monday cron would otherwise leave two
# runs racing to file the same failure issue.
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
verify:
name: Verify Latest Release Attestations
# Must stay GitHub-hosted: this job needs `gh` (preinstalled on ubuntu-latest)
# and reliable Sigstore (Fulcio/Rekor) egress. `gh attestation verify` is the
# only tool that does Sigstore bundle verification. The whole repo is on
# ubuntu-latest (LAB-3501) and runner-guard.yml keeps it there, but the point
# is load-bearing here specifically: when this once ran on a self-hosted runner
# with no `gh` binary, every `gh` call exited 127 into `|| echo ""` and
# the job reported green while verifying nothing for weeks.
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
env:
# crates.io answers 403 to a generic user agent (verified — plain `curl/8.x`
# gets 403). One descriptive UA, shared by every crates.io call in this job.
CRATES_IO_UA: cachekit-core-attestation-check (+https://github.com/${{ github.repository }})
steps:
# github-script, not `gh`, for API calls — the convention release.yml documents
# on its "Assign release PR" step. `gh` is confined to Sigstore verification below.
- name: Resolve latest release
id: release
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
let tagName;
try {
const { data } = await github.rest.repos.getLatestRelease({
owner: context.repo.owner,
repo: context.repo.repo,
});
tagName = data.tag_name;
} catch (error) {
if (error.status !== 404) {
throw error;
}
// A 404 here does NOT mean "no releases". getLatestRelease returns the
// latest non-draft, non-prerelease release, so a repo whose releases are
// all drafts or prereleases 404s too. Skipping on a bare 404 would be a
// silent green — the exact fail-open this file was rewritten to remove.
// Only a genuinely empty release list may skip.
const { data: releases } = await github.rest.repos.listReleases({
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 1,
});
if (releases.length > 0) {
core.setFailed(
'No published full release, but the repository has draft/prerelease releases. ' +
'Nothing verifiable — refusing to report green.',
);
return;
}
core.info('Repository has no releases at all — nothing to verify.');
core.setOutput('skip', 'true');
return;
}
// release-please tags this crate `<package-name>-v<semver>`
// (release-please-config.json: package-name cachekit-core, release-type rust).
// The character classes are deliberately tight: these values build a URL and
// a file path, so `/` and shell metacharacters must not survive parsing.
const match = /^(?<crate>[A-Za-z0-9_-]+)-v(?<version>\d+\.\d+\.\d+[0-9A-Za-z.+-]*)$/
.exec(tagName);
if (!match) {
core.setFailed(`Cannot derive crate name and version from release tag '${tagName}'`);
return;
}
core.setOutput('skip', 'false');
core.setOutput('tag', tagName);
core.setOutput('crate', match.groups.crate);
core.setOutput('version', match.groups.version);
core.info(`Latest release ${tagName} -> crate ${match.groups.crate} version ${match.groups.version}`);
# The attestation subject is the `.crate` release.yml hands to
# attest-build-provenance / attest-sbom (`target/package/*.crate`) — the identical
# file `cargo publish` uploads. crates.io holds that byte-identical copy, and this
# repo publishes no GitHub release assets, so crates.io is the artifact source.
#
# `curl -f` with no `|| true`: a tagged release whose crate never reached crates.io
# is a real supply-chain gap, so it must fail the job rather than degrade to a skip.
- name: Download attested crate from crates.io
if: steps.release.outputs.skip != 'true'
id: artifact
env:
CRATE: ${{ steps.release.outputs.crate }}
VERSION: ${{ steps.release.outputs.version }}
run: |
mkdir -p release-assets
ARTIFACT="release-assets/${CRATE}-${VERSION}.crate"
# -A is mandatory, not politeness — see CRATES_IO_UA at the job level.
curl -fsSL --retry 3 --retry-connrefused \
-A "$CRATES_IO_UA" \
"https://crates.io/api/v1/crates/${CRATE}/${VERSION}/download" \
-o "$ARTIFACT"
test -s "$ARTIFACT"
echo "path=$ARTIFACT" >> "$GITHUB_OUTPUT"
echo "Downloaded $ARTIFACT ($(wc -c < "$ARTIFACT") bytes, sha256 $(sha256sum "$ARTIFACT" | cut -d' ' -f1))"
# Identity pinning takes BOTH flags. `--signer-workflow` is an unanchored prefix
# match on the certificate SAN (verified: a truncated `.../release` with no `.yml`
# also passes), so on its own it pins neither the ref nor the full filename — an
# actor with push access could mint provenance from a modified release.yml on any
# branch and this check would bless it. `--source-ref` is what asserts "built by
# the release pipeline on main", which is the only claim worth making here.
#
# `--format json --jq` is the evidence trail, not decoration: verified by
# redirecting output, a passing `gh attestation verify` prints NOTHING and exits 0,
# which in a log is indistinguishable from the skip this job used to do. The jq
# line names the digest that was actually verified.
- name: Verify provenance attestation
if: steps.release.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARTIFACT: ${{ steps.artifact.outputs.path }}
TAG: ${{ steps.release.outputs.tag }}
run: |
echo "Verifying SLSA provenance for $TAG"
gh attestation verify "$ARTIFACT" \
--repo "$GITHUB_REPOSITORY" \
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml" \
--source-ref refs/heads/main \
--predicate-type https://slsa.dev/provenance/v1 \
--format json \
--jq '.[] | "verified provenance: digest=\(.verificationResult.statement.subject[0].digest.sha256) predicate=\(.verificationResult.statement.predicateType) signer=\(.verificationResult.signature.certificate.buildSignerURI)"'
# Predicate type is the UNVERSIONED `https://cyclonedx.org/bom` — that is what
# actions/attest-sbom emits for the CycloneDX 1.6 document release.yml generates.
# Do not "fix" this to `.../bom/v1.6`: nothing matches that, nor SPDX. There is no
# second SBOM predicate to fall back to, so there is no fallback — verified or red.
- name: Verify SBOM attestation
if: steps.release.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARTIFACT: ${{ steps.artifact.outputs.path }}
TAG: ${{ steps.release.outputs.tag }}
run: |
echo "Verifying CycloneDX SBOM attestation for $TAG"
gh attestation verify "$ARTIFACT" \
--repo "$GITHUB_REPOSITORY" \
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml" \
--source-ref refs/heads/main \
--predicate-type https://cyclonedx.org/bom \
--format json \
--jq '.[] | "verified SBOM: digest=\(.verificationResult.statement.subject[0].digest.sha256) predicate=\(.verificationResult.statement.predicateType) signer=\(.verificationResult.signature.certificate.buildSignerURI)"'
# Verification above proves the latest GitHub release's crate is authentic — but
# says nothing about whether that is the version `cargo add` actually resolves.
# A hand-published version, a prerelease-skipped release, or a yank all leave
# the steps above green while users install something this job never checked
# (LAB-1036). max_stable_version is what cargo resolves by default, so
# verified != max_stable is a false green and must go red — unconditionally.
#
# Deliberately NO tolerance for the release→publish window (release.yml tags
# before `cargo publish`): that window cannot reach this comparison. While it
# is open the crate is not on crates.io yet, so the job has already gone red at
# the download step above. And an "is release.yml in flight?" tolerance here
# would itself be a fail-open — release.yml runs on every push to main, so an
# unrelated queued run would green-light a dead publish or a yanked version
# (expert panel, LAB-1036). A crate that downloaded successfully but mismatches
# max_stable is always a real defect: yanked, hand-published, prerelease-
# skipped, or a publish that died after the tag.
- name: Check crates.io serves the verified version
if: steps.release.outputs.skip != 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
CRATE: ${{ steps.release.outputs.crate }}
VERSION: ${{ steps.release.outputs.version }}
with:
script: |
const crate = process.env.CRATE;
const version = process.env.VERSION;
// try/catch so a network-level failure (DNS, connection reset) or a
// non-JSON body fails with a descriptive message instead of a raw
// unhandled-rejection trace. Red either way — diagnostics, not a swallow.
let data;
try {
const res = await fetch(`https://crates.io/api/v1/crates/${crate}`, {
headers: { 'User-Agent': process.env.CRATES_IO_UA },
});
if (!res.ok) {
core.setFailed(`crates.io API answered ${res.status} for crate '${crate}' — cannot confirm what users install.`);
return;
}
data = await res.json();
} catch (error) {
core.setFailed(`crates.io API request for '${crate}' failed: ${error.message} — cannot confirm what users install.`);
return;
}
// Fail closed if the response shape shifts under us: an error-shaped 200
// without a `crate` object, or a missing `versions` array, must fail with
// a message, not a TypeError — `|| []`/optional chaining here would
// silently degrade the checks to no-ops, `|| echo ""` in JS clothing.
if (!data.crate) {
core.setFailed(`crates.io response for '${crate}' has no crate object — cannot confirm what users install.`);
return;
}
if (!Array.isArray(data.versions)) {
core.setFailed(`crates.io response for '${crate}' has no versions array — cannot check yank status.`);
return;
}
// The explicit yank branch is not redundant with the mismatch below: it
// names the actual cause. Yanked crates stay downloadable, so the download
// step above cannot catch this case.
const entry = data.versions.find((v) => v.num === version);
if (entry && entry.yanked) {
core.setFailed(
`Verified version ${version} is YANKED on crates.io — this check just green-lit ` +
'a version users are steered away from, so the latest GitHub release no longer matches a servable crate.',
);
return;
}
const maxStable = data.crate.max_stable_version;
if (!maxStable) {
core.setFailed(`crates.io reports no stable version of '${crate}' at all (every version yanked or prerelease).`);
return;
}
if (maxStable !== version) {
core.setFailed(
`crates.io max_stable_version is ${maxStable} but this check verified ${version}. ` +
`Users running 'cargo add ${crate}' get ${maxStable}, a version this job never verified — a false green (LAB-1036).`,
);
return;
}
core.info(`crates.io max_stable_version ${maxStable} == verified ${version} — the verified crate is what 'cargo add ${crate}' installs.`);
# GitHub silently disables `schedule:` triggers in public repos after ~60 days
# without repository activity — after which this job's failure mode becomes
# "not running at all", indistinguishable from passing quietly (LAB-1036). Fail
# while the schedule is still alive so the auto-disable is preceded by a red run
# and a tracking issue instead of a quiet death. The threshold is 43 days: the
# cron is weekly AND GitHub documents that scheduled runs can be delayed or
# dropped under load, so the margin must fit TWO weekly attempts, not one —
# 60 − 43 = 17 days ≥ two cron periods, so even one dropped Monday still leaves
# a red run before the disable. (A margin under 7 days could be jumped over
# entirely: green at day 54, disabled before the next run at day 61.) No `if:`
# on the release skip output: liveness is independent of whether releases exist.
- name: Assert schedule liveness
id: liveness
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const { data: repo } = await github.rest.repos.get({
owner: context.repo.owner,
repo: context.repo.repo,
});
const staleDays = (Date.now() - Date.parse(repo.pushed_at)) / 86400000;
// `!(x <= 43)`, not `x > 43`: Date.parse of a malformed pushed_at is NaN,
// and NaN must fail closed, not log "not at risk" and pass.
if (!(staleDays <= 43)) {
core.setFailed(
`Last repository push was ${staleDays.toFixed(1)} days ago; GitHub auto-disables this schedule at ` +
'~60 days of inactivity, after which the check stops running with no signal at all. ' +
'Push activity (or re-enable the workflow) before it goes dark.',
);
return;
}
core.info(`Last push ${staleDays.toFixed(1)} days ago — schedule is not at risk of the 60-day auto-disable.`);
# Weekly job: without dedupe one unfixed break files a fresh issue every Monday,
# and a muted `bug` label is itself a fail-open alert channel. The marker is
# per-failure-class: per-tag for verification breaks (a NEW tag's failure still
# alerts instead of hiding behind the open issue for the old one), and a
# dedicated `liveness` marker for staleness — the two classes are mutually
# exclusive in one run (liveness only executes after verification succeeded),
# and a shared marker would let an open staleness issue swallow a later real
# attestation break for the same tag. Matched on body, not title or label, so a
# human retitling or relabelling the issue cannot break dedupe. The 100-issue
# page is ample here; worst case is a duplicate issue, never a missed alert.
- name: Open issue on failure
if: failure()
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
TAG: ${{ steps.release.outputs.tag }}
LIVENESS_OUTCOME: ${{ steps.liveness.outcome }}
with:
script: |
const tag = process.env.TAG || 'unresolved-release';
const liveness = process.env.LIVENESS_OUTCOME === 'failure';
const marker = liveness ? '<!-- attestation-check:liveness -->' : `<!-- attestation-check:${tag} -->`;
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const { data: open } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100,
});
const existing = open.find((i) => !i.pull_request && (i.body || '').includes(marker));
if (existing) {
core.info(`Issue #${existing.number} already tracks ${tag}; not filing a duplicate. Run: ${runUrl}`);
return;
}
const { data: created } = await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: liveness
? 'Attestation health check schedule is at risk of auto-disable'
: `Attestation verification failed for ${tag}`,
body: [
marker,
liveness
? 'The repository has had no push activity for >43 days; GitHub auto-disables this workflow\'s `schedule:` trigger at ~60 days, after which the check silently stops running.'
: `The weekly attestation health check failed for \`${tag}\`.`,
'',
`Run: ${runUrl}`,
'',
'Check that release.yml produced valid SLSA provenance and CycloneDX SBOM',
'attestations for the published `.crate`, that it ran on `main`, that the',
'crate actually reached crates.io for this tag, that crates.io\'s',
'`max_stable_version` still matches this release (nothing hand-published,',
'yanked, or prerelease-skipped), and that the repository has push activity',
'within the last 43 days (GitHub auto-disables the schedule at ~60).',
].join('\n'),
labels: ['bug'],
});
core.info(`Opened tracking issue #${created.number}`);