Attesting build artefacts from a self-hosted runner Jump to heading

Hosted runners make provenance comparatively easy: every job gets a fresh machine, and the forge signs attestations with an identity the job’s own steps cannot touch. Self-hosted runners remove both assumptions. A long-lived runner carries state from one job to the next, so a malicious pull request build can leave something behind for the release build. And if the signing key sits on the runner, any build step can use it to attest anything. Provenance from such a runner is a signed statement from a machine that might have been compromised by the previous job. This page fixes that: ephemeral runners, signing that build steps cannot reach, and provenance that records which runner image did the work. It belongs to build provenance and attestations.

When to use this approach Jump to heading

  • Release builds must run on your own hardware β€” for special CPUs, network access, licensing or cost.
  • You want provenance for those builds that is as trustworthy as provenance from hosted runners.
  • Your runners are currently long-lived and shared between pull-request and release jobs.
  • You understand what the build levels require; if not, start with understanding SLSA build levels for Git repositories.

Step 1 β€” Separate release runners from everything else Jump to heading

The first and cheapest fix is a dedicated runner group for release builds that never runs pull-request jobs. Untrusted code and release builds then never share a machine, even before you make runners ephemeral.

# Create a runner group restricted to the release workflow on the default branch
gh api -X POST "orgs/$ORG/actions/runner-groups" \
  -f name=release-builders -f visibility=selected \
  -F restricted_to_workflows=true \
  -f 'selected_workflows[]='"$ORG/$REPO/.github/workflows/release.yml@refs/heads/main"
# release.yml β€” the only workflow that can use these runners
jobs:
  build:
    runs-on: { group: release-builders, labels: [linux, x64] }
Shared runners against a dedicated release groupOn a shared runner, a pull-request job from any branch can run on the same machine as the next release build and leave files, caches or processes behind. A dedicated group restricted to the release workflow on the default branch never executes untrusted code.Shared runner poolDedicated release groupruns PR buildsyesneverruns release buildsyesyescan inherit state fromany previous jobprevious release jobsrestricted tonothingone workflow on mainseparating the pools is an afternoon's work and removes the worst cross-job risk

Step 2 β€” Make release runners ephemeral Jump to heading

A dedicated group still lets one release job affect the next. Register runners with --ephemeral so each one takes exactly one job and then deregisters; a fresh machine or container from a known image takes its place.

# On the runner image's start-up: register, run one job, exit
./config.sh --url "https://github.com/$ORG" --token "$REG_TOKEN" \
  --runnergroup release-builders --labels linux,x64 --ephemeral --unattended
./run.sh
# The host's orchestrator then destroys this VM or container and creates a new one

The orchestrator is the important part: whatever starts runners must start each one from a clean image, never by reusing a stopped machine. Autoscaling controllers do this; the operational side is in autoscaling self-hosted runners.

# Verification: no runner in the group has completed more than one job
gh api "orgs/$ORG/actions/runner-groups/$GROUP_ID/runners" --jq '.runners[] | {name, status}'

Step 3 β€” Keep signing out of the build steps Jump to heading

If the attestation is signed with a key the build steps can read, a compromised build step can sign a false attestation. Use the forge’s attestation service, which signs with a short-lived certificate bound to the workflow identity and is reached through a token that is only minted for the attestation step.

jobs:
  build:
    runs-on: { group: release-builders }
    permissions: { contents: read, id-token: write, attestations: write }
    steps:
      - uses: actions/checkout@v4
      - run: make release            # build steps: no signing material anywhere
      - uses: actions/attest-build-provenance@v1
        with: { subject-path: "dist/*.tar.gz" }
Who can produce a signature on a self-hosted buildThe build steps run on the self-hosted runner and produce an artefact. Only the attestation step requests a workflow identity token, exchanges it for a short-lived certificate, and signs the provenance. No long-lived key is present on the runner for a build step to misuse.build stepsattest stepidentity providersigning serviceartefact digestrequest id-tokentoken for this workflowexchange for short-lived certcert, minutesthe runner never holds a reusable key β€” at worst it can request one certificate for its own run

Step 4 β€” Record what the runner was Jump to heading

Provenance from a hosted runner implicitly names a well-known image. Provenance from your runner should say which image it booted from, so a verifier can confirm the build ran on a machine you built and maintain.

# Bake the image identity into the runner at build time
echo "RUNNER_IMAGE=registry.example.com/runners/release@sha256:$(cat /etc/image-digest)" >> /etc/environment
      - run: |
          jq -n --arg img "$RUNNER_IMAGE" --arg host "$(hostname)" \
            '{runnerImage: $img, runnerHost: $host}' > runner-facts.json
      - uses: actions/attest@v1
        with:
          subject-path: "dist/*.tar.gz"
          predicate-type: "https://example.com/runner-facts/v1"
          predicate-path: runner-facts.json

A verifier can then check both attestations: the standard provenance for source and workflow, and the runner facts for the image digest, against a list of images you approved.

From clean image to verifiable artefactA runner boots from an approved image digest, takes one release job, builds the artefact, has the attestation step sign provenance and runner facts, then is destroyed. The verifier checks the workflow identity, the source commit and the runner image against approved lists.Approved imagedigest pinnedEphemeral runnerone job onlyBuildno keys presentAttestprovenance + runnerDestroynothing persistsevery arrow in this flow is something a verifier can check after the fact

Step 5 β€” Verify the attestations before trusting the artefact Jump to heading

The deploy or publish step should verify that the artefact was attested by the release workflow, from the default branch, and that the runner image is on the approved list.

gh attestation verify dist/app-2.4.0.tar.gz --repo "$ORG/$REPO" \
  --signer-workflow "$ORG/$REPO/.github/workflows/release.yml" --source-ref refs/heads/main
gh attestation verify dist/app-2.4.0.tar.gz --repo "$ORG/$REPO" \
  --predicate-type https://example.com/runner-facts/v1 --format json |
  jq -r '.[0].verificationResult.statement.predicate.runnerImage' |
  grep -qxFf approved-runner-images.txt && echo "runner image approved"

The general pattern for gating deploys is in verifying attestations before deploy.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Can self-hosted runners reach SLSA Build Level 3? Jump to heading

They can, if they are ephemeral, isolated from untrusted jobs, and signing is done by the platform rather than by build steps. The extra burden compared with hosted runners is that you must show the runner images are controlled.

What if our runners cannot reach the public signing service? Jump to heading

Run a private signing and transparency-log stack, or sign with a key held by a separate service the runner calls through an authenticated API. The requirement is that build steps cannot use the key directly, not that the service is public.

Do pull-request builds on self-hosted runners need attestations? Jump to heading

No β€” they produce nothing anyone consumes. They do need to stay off the release runners, which is the point of step 1.