A procurement form asks which SLSA Build level each of your packages reaches, and the release pipelines have never claimed one. The npm package is attested by GitHub, the Java client is signed with a key kept on a self-hosted runner, and the GitLab job writes a metadata file that no consumer verifies. Each pipeline earns a different answer, so the form needs a policy rather than a guess.
What you get
You will end up with a release policy naming the level each artifact tier must reach, and a script that grades every pipeline against the SLSA Build track from its workflow file. This is for you if you run the CI that publishes packages.
Short answer
Pick the SLSA Build level by what your build platform can sign and isolate, not by ambition. L1
needs provenance that exists, L2 needs a hosted platform to sign it, and L3 needs the build
steps kept away from the signing material. On GitHub, actions/attest in the build job is L2
and the same step inside a reusable workflow is L3. A self-hosted runner holding a key is L1,
whatever the policy says.
You will need
A package released from CI, read access to the workflow files that release it, and Node 22 or
later for the grading script. Verified 2026-10-21 against Node 22.22.0 and yaml 2.9.1. The
levels are the Build track of the SLSA specification,
version 1.0,1 and the requirements behind them are on its
producing artifacts page. The sample grades five
fixture pipelines and runs none of them.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| actions/attest-build-provenance in a reusable workflow | A package on a public registry, built on GitHub-hosted runners | Your build steps move into a workflow in another repository, which fights monorepos and one-off build logic | The build cannot be expressed as a shared workflow, or it runs on your own runners |
| actions/attest-build-provenance in the build job | Any GitHub Actions build you want verifiable with one added step | Stops at L2, because the step that attests sits beside the steps it describes and can be edited with them | The artifact’s consumers require L3 |
| GitLab CI artifact provenance | GitLab pipelines that want a record of the build with no signing infrastructure | An unsigned statement, so L1 only, in a file that nothing verifies until you write the tool | You need a signed claim a consumer can check |
| self-hosted runners | Builds that need hardware, licenses, or a network the hosted runners cannot reach | Every requirement beyond L1 becomes yours to prove, and a persistent runner with a key on it proves none of them | The build runs on hosted runners without special hardware |
| slsa-github-generator | An existing L3 setup, verified with slsa-verifier, that you are not ready to move | No further maintenance, so a GitHub Actions runtime deprecation can stop it, and verification needs a second tool | You are setting up provenance for the first time |
The one-step route costs a block of YAML and stops at L2. The reusable workflow buys L3 and takes your build steps into a repository your package’s contributors cannot edit, which is the point and the inconvenience. GitLab’s metadata costs nothing and proves little without a signature. A self-hosted runner moves every requirement beyond L1 onto your own operations team, and the generator that defined the L3 route on GitHub is no longer maintained, and neither is its verifier.2
Read the three levels as five requirements
The specification splits each level between the producer and the build platform. The producer’s three duties, a consistent build process, a chosen platform, and distributed provenance, hold at every level and live in your release process. The platform’s five are the ones a script can check, and they are the rows of the specification’s own table.
export const REQUIREMENTS = [
{ level: 1, name: 'Provenance exists', ok: (f) => f.provenance !== 'none' },
{ level: 2, name: 'Provenance is authentic', ok: (f) => f.signedByPlatform },
{ level: 2, name: 'Hosted', ok: (f) => f.hosted },
{ level: 3, name: 'Provenance is unforgeable', ok: (f) => f.generatedOutsideBuildSteps },
{ level: 3, name: 'Isolated', ok: (f) => f.ephemeral && !f.sharedSecrets },
]
L1 asks only that provenance exist and name the artifact by digest. L2 asks that a hosted
platform sign it, where hosted
means shared or dedicated infrastructure rather than somebody’s workstation, so a self-hosted
runner in a rack still qualifies. L3 asks for two things the build steps must not be able to do:
reach the signing material, and influence another build or a later one. A level is reached only
when every lower level is complete, which is what assess in the same file enforces.
Grade each pipeline from its workflow and its runner
A workflow file shows how provenance is produced. It cannot show whether the machine persists between jobs or holds a key, so those facts are declared beside the claim.
tiers:
public-package: L3
internal-artifact: L2
pipelines:
- name: npm-package
tier: public-package
claims: L3
workflow: workflows/npm-package.yml
runner:
kind: github-hosted
The route is read from the file. A job-level uses: naming another repository’s workflow is a
reusable workflow, and one pointing at the generator is the generator. An actions/attest step is
attestation inside the build job. A cosign sign-blob --key line is a key the job can read, and
GitLab’s RUNNER_GENERATE_ARTIFACTS_METADATA is unsigned metadata. A file can show more than one
route, as when a test job calls a shared workflow and the release job signs with a key, so the
weakest is the one graded.
const WEAKEST_FIRST = ['key-in-job', 'attest-in-job', 'reusable-workflow', 'slsa-generator']
export function provenanceRoute(file) {
const doc = parse(readFileSync(file, 'utf8'))
if (doc.variables?.RUNNER_GENERATE_ARTIFACTS_METADATA === 'true') return 'gitlab-metadata'
const found = new Set()
for (const job of Object.values(doc.jobs ?? {})) {
if (typeof job.uses === 'string' && !job.uses.startsWith('./')) {
found.add(job.uses.includes('slsa-github-generator/.github/workflows/') ? 'slsa-generator' : 'reusable-workflow')
}
for (const step of job.steps ?? []) {
if (/^actions\/(attest|attest-build-provenance)@/.test(step.uses ?? '')) found.add('attest-in-job')
if (/cosign sign-blob .*--key/.test(step.run ?? '')) found.add('key-in-job')
}
}
return WEAKEST_FIRST.find((route) => found.has(route)) ?? 'none'
}
node assess.mjs
pipeline runner provenance claims achieves capped by
npm-package github-hosted reusable-workflow L3 L3 -
python-wheel github-hosted attest-in-job L2 L2 Provenance is unforgeable: the build steps and the attesting step share one job
go-binary github-hosted slsa-generator L3 L3 -
php-tarball gitlab-shared gitlab-metadata L2 L1 Provenance is authentic: the statement carries no signature
java-jar self-hosted key-in-job L3 L1 Provenance is authentic: the signing key is readable by the build steps
php-tarball: claims L2, tier internal-artifact requires L2, achieves L1
java-jar: claims L3, tier public-package requires L3, achieves L1
2 of 5 pipelines fall short of their claim or their tier
The grades follow the platforms’ own statements. GitHub documents artifact attestations as L2 on their own, and as L3 when the build runs inside a reusable workflow that also attests. The calling workflow then cannot edit the steps or the attestation. The generator’s README claims L3 for its reusable workflows and says that alone is not sufficient, since distribution and verification are still yours. GitLab’s runner writes an in-toto statement with a SLSA provenance predicate and signs nothing,3 so the two Level 2 ticks it needs are both missing. The two shortfalls are the lines a release manager reads.
Read the provenance your platform already writes
The level you can defend is written into the provenance a consumer verifies. The predicate
actions/attest builds, in its source,
records the signing workflow as builder.id and the runner kind under internalParameters,
both copied from the job’s OIDC token.
node read-provenance.mjs provenance/npm-package.json provenance/python-wheel.json
provenance/npm-package.json
subject: meterco-sdk-1.4.0.tgz
signer: https://github.com/meterco/release-workflows/.github/workflows/npm-package.yml@refs/tags/v3.2.0
caller: https://github.com/meterco/sdk/.github/workflows/npm-package.yml
runner: github-hosted
shape: the signer is a reusable workflow in another repository: the L3 shape, if that workflow owns the build steps
provenance/python-wheel.json
subject: meterco_sdk-1.4.0-py3-none-any.whl
signer: https://github.com/meterco/sdk/.github/workflows/python-wheel.yml@refs/tags/v1.4.0
caller: https://github.com/meterco/sdk/.github/workflows/python-wheel.yml
runner: github-hosted
shape: the signer is the calling workflow itself, so the build steps sat beside the attest step: the L2 shape
The signer is the job_workflow_ref claim, so a reusable workflow signs under its own name and
the caller’s workflow appears only in the external parameters. That is the difference a consumer
checks with gh attestation verify and
its --signer-workflow flag. The runner_environment claim is github-hosted or
self-hosted, as the OIDC token reference
lists it, so a self-hosted build is visible to anyone who reads the statement.
Write the level into the release policy
One table decides the level, and it belongs in the release policy beside the pipelines.
| Artifact | Require | Route on GitHub | Route elsewhere |
|---|---|---|---|
| A package on a public registry | L3 | actions/attest inside a reusable workflow in a repository the package’s contributors cannot push to | A hosted builder that signs outside your job, or L2 stated plainly |
| An internal library or image | L2 | actions/attest in the build job, on GitHub-hosted runners | A hosted runner that signs the statement; unsigned metadata stays L1 |
| A build that must stay on your own runners | L1, and say so | Keep signing keys off the runner and record the runner in the provenance | The same |
The third row is the one teams resist. A self-hosted runner can reach L2 or L3 in the specification’s eyes, and the FAQ puts every requirement on whichever party informs the provenance, which for a self-hosted runner is you. Claim L1 until you can show an ephemeral machine per job and a key the job never sees.
Check it worked
Run the tests, which pin the five grades, the three facts the page rests on, and the policy’s own failure modes.
node --test policy.test.mjs
1..12
# tests 12
# suites 0
# pass 12
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 306.065232
Test 3 holds a readable signing key at L1 on every runner kind. Test 4 holds attestation inside
the build job at L2 on a hosted runner, and test 6 holds a workstation build at L1. Tests 9 to
12 grade the weakest route in a mixed file and refuse an unknown runner kind or tier. Point
assess.mjs at your own release-policy.yml and the exit code is the policy check.
When it goes wrong
A pipeline declared L3 grades L1 because its signing key lives on the runner. The java-jar
workflow runs cosign sign-blob --key /etc/release/cosign.key on a persistent self-hosted
machine, so every job on that machine can sign anything, and the specification’s authenticity
requirement wants the key accessible only to the platform. The ladder out is one fact at a time.
node pitfall.mjs
L1 as declared: cosign key on a persistent self-hosted runner
Provenance is authentic: the signing key is readable by the build steps
L2 move the key off the runner, attest from the job instead
Provenance is unforgeable: the build steps and the attesting step share one job
L2 make the runners ephemeral, one job per machine
Provenance is unforgeable: the build steps and the attesting step share one job
L3 move the build into a reusable workflow that attests
every requirement holds
L3 or build on GitHub-hosted runners with that workflow
every requirement holds
The fourth rung is the model’s reading of the specification, not a claim GitHub makes. GitHub documents L3 for its hosted runners, and its hardening guidance says self-hosted runners have no guarantee of a clean ephemeral machine. Take the fifth rung unless the hardware forbids it.
Verification passes with --owner alone and proves less than the policy assumes. Without
--signer-workflow, any workflow in the owner’s repositories is an acceptable signer, and the L3
claim rests on one specific reusable workflow. Pin it in the verify command, and in the
documentation your consumers copy from.
A pinned generator_generic_slsa3.yml@v2.1.0 keeps working until GitHub retires a runtime it
depends on. The README says the workflows should not be assumed to receive updates, including for
runtime deprecations, and that moving to attestations changes how consumers verify. Plan the move
while the old route still runs.
When not to do this
Do not claim a level your consumers cannot verify. A level is a property of provenance somebody checks, and the specification’s list of L1 benefits opens with the words “with verification” before it names one. A badge in a README with no verify command beside it is a claim with no source.
Do not move a monorepo’s build into a reusable workflow for the badge. L3 on GitHub forces that layout, and a build with custom steps per package becomes a workflow nobody can change without a release of its own. L2 in the build job, with the policy saying so, is a stronger position than an L3 workflow the team routes around.
Do not run release builds for a public repository on self-hosted runners. GitHub’s hardening guidance says they should almost never be used there, because anyone who can open a pull request can compromise the machine. A release key on that machine is the first thing they take.
Do not read the grade as a security review. The Build track covers tampering with the build and with the provenance. It says nothing about the code that was built, the dependencies it pulled, or who may push to the source. A Build L3 package can ship a vulnerable release with its level intact.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0 and yaml 2.9.1. Every output block is what the command
preceding it printed. No pipeline ran: the workflow files are fixtures read by facts.mjs, and
the runner facts are declared in release-policy.yml. The two provenance statements were written
by hand in the shape actions/attest builds, from its source, not downloaded from GitHub.
slsa-github-generator, actions/attest, GitLab’s runner, and gh attestation verify were read
from their documentation and not run.
Footnotes
-
Version 1.0 is marked retired on its own pages, which point at version 1.2 as current. The Build track table is the same five rows in both, three on the provenance and two on isolation, with the same ticks per level. The 1.0 pages are cited here because the generator’s README and GitHub’s documentation cite them. It is also the edition that removed the Source track and the old SLSA 4, leaving a single track of three levels for a page like this one to grade against. ↩︎ Back to text
-
Both repositories open with the same notice. The generator says it is no longer actively maintained, that its most recent release is v2.1.0 from February 2025, and that users should move to GitHub artifact attestations. The verifier says the same, and adds that the replacement is verified with
gh attestation verifyinstead. The tool that made L3 on GitHub a checkbox hands the checkbox to the platform, and its README is careful to say that already generated provenance stays valid. ↩︎ Back to text -
GitLab’s statement is an in-toto v0.1 Statement wrapped around a SLSA 1.0 provenance predicate, two generations of format in one document. Its
builder.idis a URI pointing at the runner’s details page in the GitLab UI. The documentation lists every field and names no level, which is the correct amount of claim for an unsigned file. ↩︎ Back to text