---
key: "ci-build-integrity"
title: "Protect the path from pull request to release"
url: "https://softwaresecurity.com/domains/supply-chain/ci-cd/build-integrity/"
api: "https://softwaresecurity.com/api/v1/articles/ci-build-integrity.json"
domain: "supply-chain"
topic: "Software supply chain"
type: "Guide"
identifiers: ["CWE-829","A03:2025","A08:2025","CAPEC-444"]
tags: ["CI/CD security","Build integrity and provenance"]
stages: ["build","release"]
roles: ["developer","platform-sre"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Protect the path from pull request to release

Separate untrusted contribution checks from privileged release work and verify the identity of the artifact that is actually deployed.

Canonical URL: https://softwaresecurity.com/domains/supply-chain/ci-cd/build-integrity/

## In short

Run contributor code only in `pull_request` jobs with a read-only token, no secrets and a disposable runner. Rebuild releases in a separate workflow that runs on the protected branch and alone holds signing or publishing identity. Pin every action to a full commit SHA with a version comment and let Dependabot or Renovate update it. Attest the build, verify the attestation before promotion, and deploy the verified digest, never a tag.

- Passing tests on a pull request say nothing about who may release; rebuild from the approved commit on a protected worker.
- `pull_request_target` and `workflow_run` run with the base repository token and secrets, so never check out or execute fork code in them.
- A tag such as `@v4` can be moved: tj-actions/changed-files tags were repointed to a secret-dumping commit in March 2025.
- A SHA pin is immutable, so it also freezes out fixes such as the July 2026 checkout guard; automate pin updates.
- Promotion must check builder, source commit and subject digest together, then deploy that exact digest.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Trigger for jobs that run pull request code | `pull_request`, never `pull_request_target` or a fork-triggered `workflow_run` | — | GitHub Actions: secure use reference |
| Default `GITHUB_TOKEN` permissions | `contents: read`; grant more per job | — | GitHub Actions: secure use reference |
| actions/checkout | `3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1` with `persist-credentials: false` | v7 refuses fork head or merge checkouts under `pull_request_target` and fork `workflow_run` unless `allow-unsafe-pr-checkout: true`. | GitHub changelog: safer pull_request_target defaults for actions/checkout (2026-06-18) |
| Third-party action references | Full 40-character commit SHA plus a `# vX.Y.Z` comment | Enforce with the allowed-actions policy option that requires SHA pinning. | GitHub changelog: SHA pinning and blocking in the allowed actions policy (2025-08-15) |
| Dependabot version-update cooldown | 3 days by default since 2026-07-14; security updates are not delayed | — | GitHub changelog: Dependabot default package cooldown (2026-07-14) |

## Verify it

1. Run `zizmor .github/workflows/` and confirm there are no `dangerous-triggers`, `unpinned-uses` or `template-injection` findings.
2. Confirm every job that runs pull request code uses the `pull_request` trigger and a token limited to `contents: read`.
3. In Settings &gt; Actions &gt; General, confirm the allowed-actions policy requires actions to be pinned to a full-length commit SHA.
4. Run `gh attestation verify dist/acorn.tar.gz --repo acorn/service --signer-workflow acorn/service/.github/workflows/release.yml --source-ref refs/heads/main --deny-self-hosted-runners` and expect exit code 0.
5. Compare the digest the deployment actually runs with the attested digest; a matching tag name is not enough.

## What you will learn

- Trace contributor influence through build, statement and deployment boundaries.
- Apply a conjunctive release policy to concrete evidence.
- Repair a privileged contribution workflow and identify external settings it cannot fix.

Audience: Engineers who maintain CI/CD workflows and release pipelines, and reviewers who approve changes to them

Before you start:

- Read a CI job's triggers, credentials, artifact outputs and approval steps.

Time: About 31 minutes: 9 to read and 22 for the exercise

## Find the path that gives code authority

A build has two outputs: bytes and claims about those bytes. An attacker can change either, or arrange for a deployment job to consume different bytes from the ones reviewed. Start by asking who controls each input and which job can publish, sign, or deploy. Source code, test scripts, dependency installation hooks, workflow definitions, action references, restored caches, and downloaded archives can all execute or influence execution. Reviewing application code while ignoring those paths leaves the release boundary incomplete.

Consider the fictional Acorn service. A fork pull request runs tests on worker P. A merge to main runs a fresh build on worker R. Production accepts an image digest after a separate verification job. Worker P is deliberately outside the release trust boundary even when its tests pass. Test success answers whether assertions passed under those conditions; it says nothing about the contributor's authorization to issue a production release. Worker R must start from the approved immutable commit and resolve protected build inputs. Rebuilding a pull request after merge is a cost worth accepting when an earlier artifact came from a less trusted path.

## Remove the authority before executing contributor code

GitHub's secure-use guidance supports minimal token permissions and immutable action references. Those choices are necessary but not sufficient. A read-only repository token does not remove a runner's cloud credentials, network access, or writable shared disk. Separate contribution workers from release workers, give a signing identity only to the job that needs it, and constrain the external identity trust policy to the repository, workflow and protected release context. An environment approval does not sanitize an already compromised worker.

The workflow below shows the pattern to remove. `pull_request_target` runs with the base repository token and secrets, `write-all` grants every scope, the checkout fetches the fork head commit, and the persistent self-hosted release runner keeps whatever the contributor script leaves behind. `actions/checkout` v7, since 2026-06-18, and the floating v2 to v6 major tags, since 2026-07-20, refuse that checkout unless `allow-unsafe-pr-checkout: true` is set. A SHA pin taken before that date does not get the guard, and `run:` steps that fetch the head with `git` or `gh` are not covered at all.

```yaml
name: Test
on: pull_request_target
permissions: write-all
jobs:
  test:
    runs-on: [self-hosted, release]
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - run: ./ci/test.sh
```

## Run contributor tests without release authority

The repair runs the same tests on `pull_request`, which gives fork pull requests a read-only token and no secrets. It also sets `contents: read` explicitly, uses a GitHub-hosted runner that is discarded after the job, and pins checkout to the full commit SHA of v7.0.1 with the version in a comment. `persist-credentials: false` stops checkout from leaving the token for later steps; v6 and later store it under `$RUNNER_TEMP` instead of `.git/config`, where any step can still read it.

This job only runs tests; it does not publish. A distinct release workflow starts on a protected `main` push, checks out that exact event commit, builds from scratch, and passes an immutable digest to promotion. Repository rulesets and the identity provider trust policy are separate configuration, so this file alone is not evidence that the whole boundary exists.

```yaml
name: Test
on: pull_request
permissions:
  contents: read
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - run: ./ci/test.sh
```

## Build and attest the release from the protected commit

The release workflow runs only for pushes to `main`. It starts with no token permissions at the workflow level and grants the build job only what attestation needs: `id-token: write` to obtain the OIDC token that Sigstore binds to the workflow identity, and `attestations: write` and `artifact-metadata: write` to store the result. `actions/attest` then signs SLSA build provenance for the exact file. The `release` environment can require reviewers and restrict deployment branches, and its name appears in the OIDC subject (`repo:acorn/service:environment:release`), so a cloud trust policy can require it.

Before promotion, run `gh attestation verify dist/acorn.tar.gz --repo acorn/service --signer-workflow acorn/service/.github/workflows/release.yml --source-ref refs/heads/main --deny-self-hosted-runners` and deploy the digest it verified. The release-verification project on this page builds and verifies a signed release end to end with the same checks.

```yaml
name: Release
on:
  push:
    branches: [main]
permissions: {}
jobs:
  build:
    runs-on: ubuntu-latest
    environment: release
    permissions:
      contents: read # check out the protected commit
      id-token: write # OIDC token that Sigstore binds to this workflow
      attestations: write # store the provenance attestation
      artifact-metadata: write # record the attested artifact
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - run: ./ci/build.sh dist/acorn.tar.gz
      - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
        with:
          subject-path: dist/acorn.tar.gz
```

## Pin actions to commit SHAs and keep the pins current

A tag is a movable pointer. In March 2025 an attacker repointed existing tags of tj-actions/changed-files, used by over 23,000 repositories, to a commit that dumped runner memory and printed secrets into public workflow logs (CVE-2025-30066, added to the CISA Known Exploited Vulnerabilities catalog). A full 40-character commit SHA from the action repository itself cannot be moved that way. Keep the release name in a comment, such as `# v7.0.1`, so reviewers and update tools know what the SHA is.

An immutable pin also freezes out fixes: the July 2026 checkout guard reached floating major tags only. Dependabot and Renovate both update SHA pins together with the version comment; the configuration below groups action updates weekly and waits seven days before proposing a new release. Since 2025-08-15 an enterprise, organization or repository policy can require full-SHA pins, so workflows with unpinned actions fail, and can block a specific action or version by prefixing it with `!`. Immutable releases, generally available since 2025-10-28, stop a publisher from moving or replacing a release tag and its assets, but floating major tags such as `v4` are moved by design.

Run `zizmor` and `actionlint` on every workflow change. zizmor flags unpinned actions, dangerous triggers, template injection and credential persistence; GitHub code scanning default setup can also run CodeQL queries for Actions workflows.

```yaml
version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly
    cooldown:
      default-days: 7
    groups:
      actions:
        patterns: ["*"]
```

## Account for the 2025–2026 GitHub Actions changes

Several platform changes since mid-2025 alter what a workflow review should expect. Check each against the workflows and pins you actually run.

| Date | Change | What to do |
| --- | --- | --- |
| 2025-08-15 | The allowed-actions policy can require full-SHA pins and block actions with a `!` prefix. | Require SHA pinning at organization or enterprise level and block actions you have retired. |
| 2025-10-28 | Immutable releases are generally available: release tags and assets cannot be moved or replaced, and releases get Sigstore-format attestations. | Enable it for repositories you publish from; consumers can check a release with `gh release verify`. |
| 2025-12-08 | `pull_request_target` always uses the workflow file and checkout commit from the default branch; environment branch rules evaluate `GITHUB_REF`. | Fix vulnerable `pull_request_target` workflows on the default branch; edits on other branches no longer help or hurt. |
| 2026-06-18 and 2026-07-20 | actions/checkout v7 refuses fork pull request head or merge checkouts under `pull_request_target` and fork `workflow_run`; backported to floating v2 to v6 tags. | Update SHA pins to a guarded release. Treat `allow-unsafe-pr-checkout: true` as a security exception that needs review. |
| 2026-06-26 | Untrusted triggers such as `pull_request_target`, `issue_comment` and fork `workflow_run` get read-only cache tokens in the default-branch scope. | Save caches from a `push` or `schedule` workflow, and do not set `cache-mode: write` on untrusted triggers. |
| 2026-07-14 | Dependabot version updates wait 3 days after a release by default. | Keep or lengthen the cooldown; security updates still open immediately. |

## Trace a release through an adversarial evidence table

Use a small decision table before trying the change on a live service. The release invariant is a conjunction: approved source, expected builder, authenticated statement, matching subject digest, and deployment of that same digest. One false or missing condition prevents ordinary promotion. A signed statement naming main without a commit cannot resolve a branch that moved between approval and build. A matching tag is also insufficient because tags can refer to new bytes later.

For the fixture, the approved source is `c111` and the approved image is `sha256:aa11`; these are deliberately shortened desk-exercise identifiers. Run 72 is a pull-request artifact. Run 73 was produced by the release workflow but used `c112`, which was not approved. Run 74 satisfies the stated policy. If production fetches a mutable tag after verifying run 74, the check and the use can still diverge. Carry `sha256:aa11` into the deployment specification and compare the runtime image identity afterward. Retain the verifier result and policy revision as well as the statement, because future policy changes may otherwise make an old decision impossible to explain.

## Explain the remaining trust and failure modes

The builder must be trusted to report its inputs accurately. SLSA provenance supplies structured build evidence; a statement alone does not establish a SLSA level or show that the source is benign. A compromised approved dependency could be reproduced and signed correctly. Application tests, dependency review and incident detection remain relevant, each with a different claim. Evidence about build integrity must not be relabeled as proof of application security. The case studies mark the edges of this boundary: a compromised build server in /domains/supply-chain/builds/case-study-solarwinds/, a downloaded CI tool that sent every job secret to an attacker in /domains/supply-chain/ci-cd/case-study-codecov/, and the repository rules that protect what the build starts from in /domains/supply-chain/ci-cd/source-repository-protection/.

Operating this boundary trades speed and availability for stronger release identity. Clean rebuilds consume time, while a verifier or identity-provider outage can stop promotion. Define a separately authorized emergency process with an exact digest, reason, expiry and post-release review; silently turning verification off changes the policy for every release. In a real validation use synthetic credentials and harmless marker files to test isolation. The fixture below is a desk analysis; it does not run those isolation probes. For hands-on practice, the release-verification project on this page builds, signs and verifies a release, and the lab at /labs/artifact-policy/ has you reject a validly signed release from the wrong producer.

## Guided practice

Acorn must decide which of three candidate runs may deploy. All records below are fictional and complete for the stated decision.

### Supplied material

```text
Policy P3: promote only approved commit c111, release.yml builder, authenticated attestation, subject digest equal deployment digest.
Run | event | worker | commit | builder | authenticated | subject | proposed deployment
72 | fork PR | P | c111 | test.yml | yes | sha256:aa11 | sha256:aa11
73 | main push | R | c112 | release.yml | yes | sha256:aa11 | sha256:aa11
74 | main push | R | c111 | release.yml | yes | sha256:aa11 | sha256:aa11
Runtime observation for 74: tag latest currently resolves to sha256:bb22.
Worker P has read-only contents token, no cloud identity, no shared storage with R.
Approval A9 binds c111 and sha256:aa11. Owner: fictional release lead Imani.
```

### 1. Draw the authority boundary

Name the worker that can run arbitrary contributor scripts and explain why passing tests do not cross the boundary.

Worked answer: P runs contributor scripts. R and the verifier constitute the release path. Run 72 remains untrusted for publication regardless of its test result or matching digest.

### 2. Apply every predicate

Evaluate runs 72, 73 and 74 against P3; name a precise failing predicate for each rejected run.

Worked answer: 72 fails expected builder; 73 fails approved commit; 74 satisfies all stated predicates. Matching bytes alone cannot substitute for the expected production process.

### 3. Close the time-of-check gap

Compare approval A9 to the runtime tag observation and write the exact deployment selector.

Worked answer: Use `image@sha256:aa11` in the desk model. Reject a deployment of `latest` resolving to `sha256:bb22`; record expected `aa11` and observed `bb22`. Do not change A9 to fit the tag.

### 4. Design one boundary probe

Specify a harmless persistent-influence test and the exact expected result.

Worked answer: P writes marker PR72 only to its permitted scratch storage. A clean R starts without that marker and without P-writable cache inputs. A marker in R is a failed isolation test even if secrets were never printed.

### 5. Write the decision record

Produce decision D74 with owner, policy, evidence, outcome and remaining limitation.

Worked answer: D74: Imani; P3; A9 and run 74; permit only `aa11`; runtime check required; application safety unknown. Runs 72 and 73 and tag `bb22` are rejected with separate reasons. No real approval is implied.

Deliverable: A release decision D74 plus two rejection records and one proposed isolation test with explicit pass/fail criteria.

Scope: Short digest labels are for desk comparison only; real records require complete digests. The exercise does not execute CI, prove worker isolation, or award a SLSA level.

## Executable practice

- [Build and verify a signed software release: project ZIP](https://softwaresecurity.com/downloads/projects/release-verification-bd914fcbede61c32.zip) — Node.js 22+ · no dependencies

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [GitHub Actions: secure use reference](https://docs.github.com/en/actions/reference/security/secure-use)
- [SLSA v1.2: build provenance](https://slsa.dev/spec/v1.2/build-provenance). v1.2 (approved November 2025).
- [GitHub Docs: script injections](https://docs.github.com/en/actions/concepts/security/script-injections)
- [GitHub changelog: SHA pinning and blocking in the allowed actions policy (2025-08-15)](https://github.blog/changelog/2025-08-15-github-actions-policy-now-supports-blocking-and-sha-pinning-actions/)
- [GitHub changelog: immutable releases are generally available (2025-10-28)](https://github.blog/changelog/2025-10-28-immutable-releases-are-now-generally-available/)
- [GitHub changelog: pull_request_target and environment branch protection changes (2025-11-07)](https://github.blog/changelog/2025-11-07-actions-pull_request_target-and-environment-branch-protections-changes/)
- [GitHub changelog: safer pull_request_target defaults for actions/checkout (2026-06-18)](https://github.blog/changelog/2026-06-18-safer-pull_request_target-defaults-for-github-actions-checkout/)
- [GitHub changelog: read-only Actions cache for untrusted triggers (2026-06-26)](https://github.blog/changelog/2026-06-26-read-only-actions-cache-for-untrusted-triggers/)
- [GitHub changelog: Dependabot default package cooldown (2026-07-14)](https://github.blog/changelog/2026-07-14-dependabot-version-updates-introduce-default-package-cooldown/)
- [CISA: Supply chain compromise of third-party tj-actions/changed-files (CVE-2025-30066) and reviewdog/action-setup@v1 (CVE-2025-30154)](https://www.cisa.gov/news-events/alerts/2025/03/18/supply-chain-compromise-third-party-tj-actionschanged-files-cve-2025-30066-and-reviewdogaction). 18 March 2025.
- [actions/attest: generate signed attestations](https://github.com/actions/attest)
- [GitHub CLI manual: gh attestation verify](https://cli.github.com/manual/gh_attestation_verify)
- [zizmor audit rules](https://docs.zizmor.sh/audits/)
- [OWASP Top 10 CI/CD Security Risks: CICD-SEC-9 Improper Artifact Integrity Validation](https://owasp.org/www-project-top-10-ci-cd-security-risks/CICD-SEC-09-Improper-Artifact-Integrity-Validation). v1.0 (2022).
- [CISA, NSA and ODNI (Enduring Security Framework): Securing the Software Supply Chain, Recommended Practices Guide for Developers, section 2.4 Harden the Build Environment](https://www.cisa.gov/sites/default/files/publications/ESF_SECURING_THE_SOFTWARE_SUPPLY_CHAIN_DEVELOPERS.PDF). August 2022.