name: Attestation Health Check
on:
schedule:
- cron: '23 3 * * 1' workflow_dispatch:
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
verify:
name: Verify Latest Release Attestations
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
env:
CRATES_IO_UA: cachekit-core-attestation-check (+https://github.com/${{ github.repository }})
steps:
- name: Resolve latest release
id: release
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd 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}`);
- 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))"
- 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)"'
- 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)"'
- name: Check crates.io serves the verified version
if: steps.release.outputs.skip != 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd 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.`);
- name: Assert schedule liveness
id: liveness
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd 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.`);
- name: Open issue on failure
if: failure()
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd 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}`);