How-to › Release and secure packages

How to choose a SLSA build level for a package release pipeline#

Grade each release pipeline against the three SLSA Build levels from its workflow and runner, then write the level each artifact tier must reach into one policy.

Audience
Platform team
Level
beginner
Topic
Secure the supply chain
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
actions/attest-build-provenance in a reusable workflowA package on a public registry, built on GitHub-hosted runnersYour build steps move into a workflow in another repository, which fights monorepos and one-off build logicThe build cannot be expressed as a shared workflow, or it runs on your own runners
actions/attest-build-provenance in the build jobAny GitHub Actions build you want verifiable with one added stepStops at L2, because the step that attests sits beside the steps it describes and can be edited with themThe artifact’s consumers require L3
GitLab CI artifact provenanceGitLab pipelines that want a record of the build with no signing infrastructureAn unsigned statement, so L1 only, in a file that nothing verifies until you write the toolYou need a signed claim a consumer can check
self-hosted runnersBuilds that need hardware, licenses, or a network the hosted runners cannot reachEvery requirement beyond L1 becomes yours to prove, and a persistent runner with a key on it proves none of themThe build runs on hosted runners without special hardware
slsa-github-generatorAn existing L3 setup, verified with slsa-verifier, that you are not ready to moveNo further maintenance, so a GitHub Actions runtime deprecation can stop it, and verification needs a second toolYou 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.

ArtifactRequireRoute on GitHubRoute elsewhere
A package on a public registryL3actions/attest inside a reusable workflow in a repository the package’s contributors cannot push toA hosted builder that signs outside your job, or L2 stated plainly
An internal library or imageL2actions/attest in the build job, on GitHub-hosted runnersA hosted runner that signs the statement; unsigned metadata stays L1
A build that must stay on your own runnersL1, and say soKeep signing keys off the runner and record the runner in the provenanceThe 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.

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

  1. 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

  2. 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 verify instead. 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

  3. 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.id is 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.