From 531644c15f4fedbd78bbe8b612d1d60d0dd5fe13 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Fri, 15 May 2026 11:08:29 -0700 Subject: [PATCH 1/3] ci(backport): use gh-sts-action to mint a scoped GitHub token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default `GITHUB_TOKEN` recently lost the ability to call the `createCommitOnBranch` GraphQL mutation under our org's GHA token policy (returns "Resource not accessible by integration" / FORBIDDEN). That mutation is how we push a GitHub-signed commit to the backport branch — required by the enterprise "require signed branch commits" ruleset that targets ~ALL refs with no bypass actors. Switch to vercel/gh-sts-action to exchange the workflow's OIDC token for a scoped GitHub App installation token (via the new `vercel-workflow-backport-to-stable` gh-sts policy). Use that token for both the createCommitOnBranch push and the PR creation; leave the comment-on-PR steps on the default GITHUB_TOKEN since posting issue comments still works with it. Failing run that motivated this: https://github.com/vercel/workflow/actions/runs/25893078275 --- .github/workflows/backport.yml | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 08b4b10980..40bf09a4d3 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -32,6 +32,10 @@ jobs: # via `workflow_dispatch` with the relevant commit SHA. runs-on: ubuntu-latest permissions: + # `id-token: write` is required so we can mint a GitHub Actions + # OIDC token and exchange it for a scoped GitHub App installation + # token via gh-sts. + id-token: write contents: write pull-requests: write issues: write @@ -593,6 +597,21 @@ jobs: ;; esac + - name: Mint scoped GitHub token via gh-sts + # The default `GITHUB_TOKEN` cannot call `createCommitOnBranch` + # (the push step below) under our org's GHA token policy. We + # exchange the workflow's OIDC token for a scoped GitHub App + # installation token instead. The token is revoked automatically + # at the end of the job by the action's post step. + if: | + steps.cherry-pick.outputs.status == 'clean' || + (steps.cherry-pick.outputs.status == 'conflict' && steps.ai-resolve.outputs.resolved == 'true') + id: gh-sts + uses: vercel/gh-sts-action@c30f0b7a16e0766c4ffbc0d210b54d0e75053fd2 # main as of 2026-05-14 + with: + repos: vercel/workflow + permissions: '{"contents":"write","issues":"write","pull_requests":"write"}' + - name: Push backport branch via GitHub API # Branch protection on this repo requires verified signatures on # every ref (an enterprise-level ruleset matching `~ALL`). A normal @@ -602,7 +621,7 @@ jobs: # mutation, which signs commits automatically with GitHub's # internal key (the same way commits made via the web UI are # signed). The resulting commit is attributed to the token owner - # (`github-actions[bot]`), not the original author. + # (the gh-sts App installation), not the original author. if: | steps.cherry-pick.outputs.status == 'clean' || (steps.cherry-pick.outputs.status == 'conflict' && steps.ai-resolve.outputs.resolved == 'true') @@ -612,7 +631,7 @@ jobs: BRANCH: ${{ steps.existing-pr.outputs.branch }} SHA: ${{ steps.resolve.outputs.sha }} with: - github-token: ${{ secrets.GITHUB_TOKEN }} + github-token: ${{ steps.gh-sts.outputs.token }} script: | const { execFileSync } = require('node:child_process'); @@ -810,7 +829,9 @@ jobs: if: steps.push-branch.outputs.pushed == 'true' id: backport-pr env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Reuse the gh-sts token from the push step so the branch + # commit and the PR are attributed to the same identity. + GITHUB_TOKEN: ${{ steps.gh-sts.outputs.token }} SHA: ${{ steps.resolve.outputs.sha }} PR_NUMBER: ${{ steps.pr-lookup.outputs.pr_number }} PR_TITLE: ${{ steps.pr-lookup.outputs.pr_title }} From f1be166bcbd86d6b378d807d3c8a97005a74f869 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Fri, 15 May 2026 11:21:50 -0700 Subject: [PATCH 2/3] ci: migrate release.yml, tests.yml, and benchmarks.yml to gh-sts These workflows hit the same GHA token policy as backport.yml: - release.yml: changesets/action with commitMode: github-api uses createCommitOnBranch, which the default GITHUB_TOKEN can no longer call (FORBIDDEN). Same scoped-token swap as backport.yml. - tests.yml + benchmarks.yml: publish JSON results to the gh-pages branch on every push to main. Both have been failing on every push since 2026-04-22 because the enterprise 'require signed commits on ~ALL refs' ruleset rejects the unsigned commit emitted by peaceiris/actions-gh-pages. Replace that step with an inline createCommitOnBranch call (GitHub signs the commit automatically) using a gh-sts-minted scoped token. --- .github/workflows/benchmarks.yml | 62 ++++++++++++++++++++++++++++---- .github/workflows/release.yml | 16 +++++++-- .github/workflows/tests.yml | 62 ++++++++++++++++++++++++++++---- 3 files changed, 126 insertions(+), 14 deletions(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 0e5bd24798..3ad0108f67 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -615,6 +615,10 @@ jobs: timeout-minutes: 5 permissions: + # `id-token: write` is required to mint a scoped GitHub App + # installation token via gh-sts (used to push a GitHub-signed + # commit to `gh-pages`). + id-token: write contents: write steps: @@ -637,10 +641,56 @@ jobs: --commit "${{ github.sha }}" \ --branch "${{ github.ref_name }}" - - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v4 + - name: Mint scoped GitHub token via gh-sts + # The `gh-pages` branch is subject to the enterprise "require + # signed commits" ruleset (targets ~ALL refs, no bypass actors), + # which rejects a normal `git push` of an unsigned commit. We + # use `createCommitOnBranch` instead so GitHub signs the commit + # automatically; that mutation requires a scoped installation + # token because the default `GITHUB_TOKEN` cannot call it under + # our org's GHA token policy. + id: gh-sts + uses: vercel/gh-sts-action@c30f0b7a16e0766c4ffbc0d210b54d0e75053fd2 # main as of 2026-05-14 + with: + repos: vercel/workflow + permissions: '{"contents":"write"}' + + - name: Publish to gh-pages via createCommitOnBranch + uses: actions/github-script@v7 + env: + DEPLOY_SHA: ${{ github.sha }} with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./docs-data - destination_dir: ci - keep_files: true + github-token: ${{ steps.gh-sts.outputs.token }} + script: | + const fs = require('node:fs'); + const contents = fs + .readFileSync('docs-data/benchmark-results.json') + .toString('base64'); + const { data: ref } = await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: 'heads/gh-pages', + }); + const result = await github.graphql( + `mutation($input: CreateCommitOnBranchInput!) { + createCommitOnBranch(input: $input) { + commit { oid url } + } + }`, + { + input: { + branch: { + repositoryNameWithOwner: `${context.repo.owner}/${context.repo.repo}`, + branchName: 'gh-pages', + }, + expectedHeadOid: ref.object.sha, + message: { headline: `deploy: ${process.env.DEPLOY_SHA}` }, + fileChanges: { + additions: [{ path: 'ci/benchmark-results.json', contents }], + }, + }, + }, + ); + core.info( + `Pushed benchmark results as ${result.createCommitOnBranch.commit.oid} (${result.createCommitOnBranch.commit.url})`, + ); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 49357598af..44803e6f67 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,6 +51,18 @@ jobs: - name: Install Dependencies run: pnpm install --frozen-lockfile + - name: Mint scoped GitHub token via gh-sts + # The default `GITHUB_TOKEN` cannot call `createCommitOnBranch` + # under our org's GHA token policy. changesets/action with + # `commitMode: github-api` uses that mutation to update the + # "Version Packages" PR, so we mint a scoped installation token + # instead. The token is revoked at the end of the job. + id: gh-sts + uses: vercel/gh-sts-action@c30f0b7a16e0766c4ffbc0d210b54d0e75053fd2 # main as of 2026-05-14 + with: + repos: vercel/workflow + permissions: '{"contents":"write","pull_requests":"write"}' + - name: Create Release Pull Request or Publish to npm id: changesets uses: changesets/action@v1 @@ -62,12 +74,12 @@ jobs: # Use GitHub API for GPG-signed commits (required by branch rules). commitMode: github-api env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.gh-sts.outputs.token }} - name: Create GitHub Release if: steps.changesets.outputs.published == 'true' env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.gh-sts.outputs.token }} PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }} run: | # Generate release notes (PUBLISHED_PACKAGES filters to only include packages from this release) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b6b6095985..0a72f93b97 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -936,6 +936,10 @@ jobs: timeout-minutes: 5 permissions: + # `id-token: write` is required to mint a scoped GitHub App + # installation token via gh-sts (used to push a GitHub-signed + # commit to `gh-pages`). + id-token: write contents: write steps: @@ -960,10 +964,56 @@ jobs: --commit "${{ github.sha }}" \ --branch "${{ github.ref_name }}" - - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v4 + - name: Mint scoped GitHub token via gh-sts + # The `gh-pages` branch is subject to the enterprise "require + # signed commits" ruleset (targets ~ALL refs, no bypass actors), + # which rejects a normal `git push` of an unsigned commit. We + # use `createCommitOnBranch` instead so GitHub signs the commit + # automatically; that mutation requires a scoped installation + # token because the default `GITHUB_TOKEN` cannot call it under + # our org's GHA token policy. + id: gh-sts + uses: vercel/gh-sts-action@c30f0b7a16e0766c4ffbc0d210b54d0e75053fd2 # main as of 2026-05-14 + with: + repos: vercel/workflow + permissions: '{"contents":"write"}' + + - name: Publish to gh-pages via createCommitOnBranch + uses: actions/github-script@v7 + env: + DEPLOY_SHA: ${{ github.sha }} with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./docs-data - destination_dir: ci - keep_files: true + github-token: ${{ steps.gh-sts.outputs.token }} + script: | + const fs = require('node:fs'); + const contents = fs + .readFileSync('docs-data/e2e-results.json') + .toString('base64'); + const { data: ref } = await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: 'heads/gh-pages', + }); + const result = await github.graphql( + `mutation($input: CreateCommitOnBranchInput!) { + createCommitOnBranch(input: $input) { + commit { oid url } + } + }`, + { + input: { + branch: { + repositoryNameWithOwner: `${context.repo.owner}/${context.repo.repo}`, + branchName: 'gh-pages', + }, + expectedHeadOid: ref.object.sha, + message: { headline: `deploy: ${process.env.DEPLOY_SHA}` }, + fileChanges: { + additions: [{ path: 'ci/e2e-results.json', contents }], + }, + }, + }, + ); + core.info( + `Pushed E2E results as ${result.createCommitOnBranch.commit.oid} (${result.createCommitOnBranch.commit.url})`, + ); From 1ba1b1df52cab8cd655e72d839c659c3e716ecaa Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Fri, 15 May 2026 14:20:50 -0700 Subject: [PATCH 3/3] ci: trim default GITHUB_TOKEN perms now that gh-sts handles writes The default GITHUB_TOKEN in these workflows is only used for read-only lookups (gh api, gh pr list, actions/checkout) and for posting issue comments back on the source PR; everything that writes to the repo goes through the scoped gh-sts token. Trim the job-level permissions to reflect that: - backport.yml: contents/pull-requests downgraded from write to read; drop unused issues:write from the gh-sts request (we only post comments via the default token). - release.yml: contents downgraded from write to read; drop unused pull-requests permission entirely. - tests.yml + benchmarks.yml (publish-results job): contents downgraded from write to read (only used for checkout). --- .github/workflows/backport.yml | 14 ++++++++++---- .github/workflows/benchmarks.yml | 4 ++-- .github/workflows/release.yml | 9 +++++++-- .github/workflows/tests.yml | 4 ++-- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 40bf09a4d3..a3fde9e658 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -34,10 +34,16 @@ jobs: permissions: # `id-token: write` is required so we can mint a GitHub Actions # OIDC token and exchange it for a scoped GitHub App installation - # token via gh-sts. + # token via gh-sts. The gh-sts token (not the default GITHUB_TOKEN) + # is used for everything that actually writes to the repo — + # creating the backport branch, pushing the signed commit, and + # opening the PR. The default GITHUB_TOKEN is only used for + # read-only lookups (gh api / gh pr list) and for posting issue + # comments on the source PR, so read perms suffice for contents + # and pull-requests; only `issues: write` is needed for comments. id-token: write - contents: write - pull-requests: write + contents: read + pull-requests: read issues: write steps: - name: Resolve commit SHA @@ -610,7 +616,7 @@ jobs: uses: vercel/gh-sts-action@c30f0b7a16e0766c4ffbc0d210b54d0e75053fd2 # main as of 2026-05-14 with: repos: vercel/workflow - permissions: '{"contents":"write","issues":"write","pull_requests":"write"}' + permissions: '{"contents":"write","pull_requests":"write"}' - name: Push backport branch via GitHub API # Branch protection on this repo requires verified signatures on diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 3ad0108f67..960b1a7543 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -617,9 +617,9 @@ jobs: permissions: # `id-token: write` is required to mint a scoped GitHub App # installation token via gh-sts (used to push a GitHub-signed - # commit to `gh-pages`). + # commit to `gh-pages`). `contents: read` is for actions/checkout. id-token: write - contents: write + contents: read steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44803e6f67..ac7838b7eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,9 +21,14 @@ jobs: name: Release runs-on: ubuntu-latest permissions: - contents: write - pull-requests: write + # `id-token: write` is required so we can mint a GitHub Actions + # OIDC token and exchange it for a scoped GitHub App installation + # token via gh-sts. The gh-sts token (not the default GITHUB_TOKEN) + # is used by changesets/action to update the "Version Packages" PR + # and to create the GitHub Release, so we only need `contents: + # read` here for the checkout step. id-token: write + contents: read env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ vars.TURBO_TEAM }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0a72f93b97..e11aa40fe4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -938,9 +938,9 @@ jobs: permissions: # `id-token: write` is required to mint a scoped GitHub App # installation token via gh-sts (used to push a GitHub-signed - # commit to `gh-pages`). + # commit to `gh-pages`). `contents: read` is for actions/checkout. id-token: write - contents: write + contents: read steps: - uses: actions/checkout@v4