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

Source: https://voxgig.com/howto/choose-a-slsa-build-level

- Audience: platform-team
- Level: beginner
- Verified: 2026-10-21
- Published: 2026-10-19

## 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](https://slsa.dev/spec/v1.0/levels),
version 1.0,[^1] and the requirements behind them are on its
[producing artifacts](https://slsa.dev/spec/v1.0/requirements) 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](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/increase-security-rating) | 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](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations) | 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](https://docs.gitlab.com/ci/runners/configure_runners/#artifact-provenance-metadata) | 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](https://docs.github.com/en/actions/concepts/runners/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](https://github.com/slsa-framework/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.

```js title="levels.mjs"
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](https://slsa.dev/spec/v1.0/requirements#isolation-strength)
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.

```yaml title="release-policy.yml"
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.

```js title="facts.mjs"
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'
}
```

```bash
node assess.mjs
```

```text output
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](https://docs.github.com/en/actions/concepts/security/artifact-attestations),
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](https://github.com/actions/toolkit/blob/main/packages/attest/src/provenance.ts),
records the signing workflow as `builder.id` and the runner kind under `internalParameters`,
both copied from the job's OIDC token.

```bash
node read-provenance.mjs provenance/npm-package.json provenance/python-wheel.json
```

```text output
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`](https://cli.github.com/manual/gh_attestation_verify) and
its `--signer-workflow` flag. The `runner_environment` claim is `github-hosted` or
`self-hosted`, as the [OIDC token reference](https://docs.github.com/en/actions/concepts/security/openid-connect)
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](https://slsa.dev/spec/v1.0/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.

```bash
node --test policy.test.mjs
```

```text output
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.

```bash
node pitfall.mjs
```

```text output
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](https://docs.github.com/en/actions/reference/security/secure-use) 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

- [Automate dependency updates with Dependabot or Renovate](/howto/automate-dependency-updates-with-dependabot-or-renovate)

- [Write a CVE response runbook for an SDK](/howto/write-a-cve-response-runbook-for-an-sdk)

- [Choose a release automation tool for an SDK repository](/howto/choose-a-release-automation-tool-for-an-sdk)

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

[^1]: Version 1.0 is marked retired on its own pages, which point at
[version 1.2](https://slsa.dev/spec/v1.2/build-requirements) 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.

[^2]: Both repositories open with the same notice. The
[generator](https://github.com/slsa-framework/slsa-github-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](https://github.com/slsa-framework/slsa-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.

[^3]: GitLab's statement is an
[in-toto v0.1 Statement](https://docs.gitlab.com/ci/runners/configure_runners/#artifact-provenance-metadata)
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.