From 46a28c52ed56b867791a46b702469129e97e852b Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Thu, 24 Sep 2026 23:53:09 +0200 Subject: [PATCH] fix(ci)!: only a push to the caller's own repo may release A workflow_run trigger's `branches: [main]` filter matches the triggering run's head branch, so a fork PR from a branch named main passed it. The release job then ran in the base repository with RELEASE_PAT, contents: write and id-token: write, checked out the fork's commit and ran its install and publish scripts. npm Trusted Publishing does not stop it: the OIDC claims name the right repository and workflow file. - release-reusable.yml: the job itself refuses a workflow_run that is not a successful push-triggered run of the caller's repository, protecting every caller; the header example shows the guard and a SHA pin. - release.yml (this repo's own release): same guard. - Pin every action by commit SHA (changesets/action@v2 was a branch) and let Renovate maintain the digests. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/actions/setup/action.yml | 4 +- .github/workflows/ci-reusable.yml | 76 +++++++++++++------------- .github/workflows/ci.yml | 8 +-- .github/workflows/release-reusable.yml | 36 ++++++++++-- .github/workflows/release.yml | 14 ++++- renovate.json | 2 +- 6 files changed, 86 insertions(+), 54 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 59c17a1..2338d5e 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -5,10 +5,10 @@ runs: using: "composite" steps: - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .node-version cache: pnpm diff --git a/.github/workflows/ci-reusable.yml b/.github/workflows/ci-reusable.yml index 7caa2f6..d3935b4 100644 --- a/.github/workflows/ci-reusable.yml +++ b/.github/workflows/ci-reusable.yml @@ -72,13 +72,13 @@ jobs: name: Format runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: ${{ inputs.node-version-file }} cache: pnpm - - uses: actions/cache@v4 + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .turbo key: ${{ runner.os }}-turbo-${{ github.sha }} @@ -90,13 +90,13 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: ${{ inputs.node-version-file }} cache: pnpm - - uses: actions/cache@v4 + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .turbo key: ${{ runner.os }}-turbo-${{ github.sha }} @@ -108,13 +108,13 @@ jobs: name: Type Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: ${{ inputs.node-version-file }} cache: pnpm - - uses: actions/cache@v4 + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .turbo key: ${{ runner.os }}-turbo-${{ github.sha }} @@ -126,13 +126,13 @@ jobs: name: Build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: ${{ inputs.node-version-file }} cache: pnpm - - uses: actions/cache@v4 + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .turbo key: ${{ runner.os }}-turbo-${{ github.sha }} @@ -145,13 +145,13 @@ jobs: if: ${{ inputs.knip }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: ${{ inputs.node-version-file }} cache: pnpm - - uses: actions/cache@v4 + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .turbo key: ${{ runner.os }}-turbo-${{ github.sha }} @@ -168,15 +168,15 @@ jobs: matrix: node: ${{ fromJSON(inputs.node-versions) }} steps: - - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: # setup-node ignores an empty node-version and falls back to the file. node-version: ${{ matrix.node }} node-version-file: ${{ inputs.node-version-file }} cache: pnpm - - uses: actions/cache@v4 + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .turbo key: ${{ runner.os }}-turbo-node${{ matrix.node }}-${{ github.sha }} @@ -189,9 +189,9 @@ jobs: if: ${{ inputs.security-audit }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: ${{ inputs.node-version-file }} cache: pnpm @@ -207,11 +207,11 @@ jobs: && github.event.pull_request.head.repo.full_name == github.repository) }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: ${{ inputs.node-version-file }} cache: pnpm @@ -225,13 +225,13 @@ jobs: if: ${{ inputs.bundle-size }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: ${{ inputs.node-version-file }} cache: pnpm - - uses: actions/cache@v4 + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .turbo key: ${{ runner.os }}-turbo-${{ github.sha }} @@ -257,13 +257,13 @@ jobs: if: ${{ inputs.integration-tests }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: ${{ inputs.node-version-file }} cache: pnpm - - uses: actions/cache@v4 + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .turbo key: ${{ runner.os }}-turbo-${{ github.sha }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 049f2a3..a6d43b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup uses: ./.github/actions/setup @@ -34,7 +34,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup uses: ./.github/actions/setup @@ -47,7 +47,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup uses: ./.github/actions/setup @@ -60,7 +60,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup uses: ./.github/actions/setup diff --git a/.github/workflows/release-reusable.yml b/.github/workflows/release-reusable.yml index 7dc9f00..645db74 100644 --- a/.github/workflows/release-reusable.yml +++ b/.github/workflows/release-reusable.yml @@ -9,8 +9,16 @@ # branches: [main] # jobs: # release: -# if: ${{ github.event.workflow_run.conclusion == 'success' }} -# uses: btravstack/tools/.github/workflows/release-reusable.yml@main +# # `branches: [main]` matches the triggering run's HEAD branch, so a +# # fork PR from a branch named `main` passes it. Gate on the event and +# # the head repository, not on the branch name. +# if: >- +# ${{ +# github.event.workflow_run.conclusion == 'success' && +# github.event.workflow_run.event == 'push' && +# github.event.workflow_run.head_repository.full_name == github.repository +# }} +# uses: btravstack/tools/.github/workflows/release-reusable.yml@ # workflows-v1 # with: # ref: ${{ github.event.workflow_run.head_sha }} # secrets: @@ -57,9 +65,25 @@ jobs: release: name: Release runs-on: ubuntu-latest + # Defence in depth for every caller: a called workflow sees the CALLER's + # `github` context. A `workflow_run` caller's `branches:` filter matches the + # triggering run's head branch, so a fork PR from a branch named `main` + # would otherwise reach this job with the caller's secrets and OIDC token, + # check out the fork's commit and run its install and publish scripts. + # Only a successful push-triggered run of the caller's own repository may + # release; other triggers (e.g. workflow_dispatch) are unaffected. + if: >- + ${{ + github.event_name != 'workflow_run' || + ( + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_repository.full_name == github.repository + ) + }} steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.ref }} # PAT so the release PR is created with credentials that DO fire @@ -68,10 +92,10 @@ jobs: token: ${{ secrets.RELEASE_PAT }} - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - name: Setup Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .node-version cache: pnpm @@ -91,7 +115,7 @@ jobs: # v2 renamed every input; the pairs are version->version-script, # publish->publish-script, commit->commit-message, title->pr-title. - name: Create Release Pull Request or Publish to npm - uses: changesets/action@v2 + uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2 with: # An INPUT now, not just the env below. v2 gives `github-token` a # default of `${{ github.token }}`, so passing the PAT only through diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8becb6c..2cc3db5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,15 @@ jobs: release: name: Release runs-on: ubuntu-latest - if: ${{ github.event.workflow_run.conclusion == 'success' }} + # `branches: [main]` above filters on the triggering run's HEAD branch, so a + # fork PR from a branch named `main` passes it. Only a push to this repo's + # own main may release: a `pull_request` CI run (fork or not) never does. + if: >- + ${{ + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_repository.full_name == github.repository + }} permissions: contents: write pull-requests: write @@ -25,7 +33,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # The exact commit the green CI run measured, never the branch tip: a # `workflow_run` checkout otherwise takes the default branch's CURRENT @@ -47,7 +55,7 @@ jobs: # repository that keeps `.changeset/CLAUDE.md` beside its changesets. # `@changesets/read@1.0.0` — v2's — ignores `README.md`, `AGENTS.md`, # `CLAUDE.md` and `GEMINI.md`. v2 also renamed every input. - uses: changesets/action@v2 + uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2 with: # An INPUT now: v2 defaults `github-token` to `${{ github.token }}`, # so passing the PAT only through the environment below would diff --git a/renovate.json b/renovate.json index 929f1b6..4564c51 100644 --- a/renovate.json +++ b/renovate.json @@ -1,6 +1,6 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["config:recommended", "schedule:weekly"], + "extends": ["config:recommended", "schedule:weekly", "helpers:pinGitHubActionDigests"], "semanticCommits": "enabled", "minimumReleaseAge": "7 days", "prConcurrentLimit": 10,