From 22d6e9fa89bb51585b6804137d7573e6e8a49f05 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Tue, 6 Oct 2026 17:47:13 +1100 Subject: [PATCH 1/2] ci(release): push the Version Packages PR as a GitHub App, so its CI runs GitHub starts no workflow run for a push made with GITHUB_TOKEN. So the Version Packages PR, which changesets/action pushes with that token, carried no CI: #1020 merged untested with stale skill pins, and #1054 needed a close and reopen before any check ran. - Mint a token for the cipherstash-release-plz GitHub App with actions/create-github-app-token, pinned to v3.2.0 (bcd2ba49), and give it to changesets/action as GITHUB_TOKEN. A push made with an App's token starts CI. commitMode stays 'github-api', so GitHub still signs the commits. - Narrow the token to this repository and to contents and pull-requests write, whatever the App holds. The action's post step revokes it. - Run the release job in the `release` environment, which holds the App's client ID and key and allows the `main` branch only. No other branch's workflow can mint the token. - Add the action to lint-no-workflow-caching's allowlist. It has no cache and no cache input. scripts/__tests__/release-app-token.test.mjs pins each property. Seven mutations, one per property, each fail a test. Part of #1044. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .github/workflows/release.yml | 24 +++++- scripts/__tests__/release-app-token.test.mjs | 81 ++++++++++++++++++++ scripts/lint-no-workflow-caching.mjs | 4 + 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 scripts/__tests__/release-app-token.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff0453d5e..ed7d0b10c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -497,6 +497,10 @@ jobs: # generated automatically by OIDC trusted publishing, are only accepted # from github-hosted runners — self-hosted runners are rejected with E422. runs-on: ubuntu-latest + # Holds the GitHub App key that pushes the Version Packages PR. The + # environment allows `main` only, so no other branch's workflow can mint + # that token. Asserted by scripts/__tests__/release-app-token.test.mjs. + environment: release permissions: id-token: write # npm OIDC trusted publishing contents: write # changesets commits and pushes the Version Packages branch @@ -618,6 +622,21 @@ jobs: AUTH_PUBLISHED: ${{ needs.publish-auth.outputs.published }} run: node scripts/wait-for-npm-versions.mjs + # GitHub starts no workflow run for a push made with GITHUB_TOKEN, so a + # Version Packages PR pushed with it carries no CI (#1020 merged that way, + # #1044). A push made with a GitHub App's token does start CI. The token is + # narrowed to this repository and the two permissions changesets needs, + # whatever the App itself holds; the post step revokes it. + - name: Mint the Version Packages token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.RELEASE_PLZ_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }} + repositories: stack + permission-contents: write + permission-pull-requests: write + - name: Publish to npm id: changesets uses: changesets/action@v1.9.0 @@ -638,7 +657,10 @@ jobs: # publishing (id-token: write above). If NPM_TOKEN is set, # changesets/action writes a token .npmrc that shadows OIDC and # every publish fails with E404 (see npm/cli#8976). - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # + # The App token from the step above, not secrets.GITHUB_TOKEN, so the + # Version Packages PR it pushes runs CI. + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} # Embeds the CLI's PostHog project key at build time (see # languages/typescript/packages/cli/tsup.config.ts). A repo *variable*, not a secret: the # key is public and write-only (like a web SDK key). Unset until GA, so diff --git a/scripts/__tests__/release-app-token.test.mjs b/scripts/__tests__/release-app-token.test.mjs new file mode 100644 index 000000000..be629d685 --- /dev/null +++ b/scripts/__tests__/release-app-token.test.mjs @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { readWorkflow } from './lib/workflows.mjs' + +/** + * The Version Packages PR must be pushed with a GitHub App's token, not with + * the workflow's own `GITHUB_TOKEN`. GitHub starts no workflow run for a push + * made with `GITHUB_TOKEN`, so a Version Packages PR pushed that way carries no + * CI at all, and #1020 merged untested with stale skill pins (#1044). + * + * The App's key is held in the `release` environment, which only `main` may + * deploy to, so a workflow on any other branch cannot mint a token. The token + * is narrowed to this repository and to the two permissions changesets needs, + * whatever the App itself is granted. + * + * Each property below fails open if it is removed: the release still works, + * and the only symptom is a later Version Packages PR with no checks, or a key + * reachable from every branch. + */ + +const RELEASE_WORKFLOW = '.github/workflows/release.yml' +const TOKEN_ACTION = 'actions/create-github-app-token' +const CHANGESETS_ACTION = 'changesets/action' + +const release = readWorkflow(RELEASE_WORKFLOW)?.jobs?.release +const steps = release?.steps ?? [] +const tokenIndex = steps.findIndex((step) => + String(step?.uses ?? '').startsWith(`${TOKEN_ACTION}@`), +) +const changesetsIndex = steps.findIndex((step) => + String(step?.uses ?? '').startsWith(`${CHANGESETS_ACTION}@`), +) +const tokenStep = steps[tokenIndex] +const changesetsStep = steps[changesetsIndex] + +describe('release.yml pushes the Version Packages PR as a GitHub App', () => { + it('finds the release job, the token step and the changesets step', () => { + expect(release).toBeTruthy() + expect(tokenIndex).toBeGreaterThanOrEqual(0) + expect(changesetsIndex).toBeGreaterThanOrEqual(0) + }) + + it('runs the release job in the release environment', () => { + expect(release.environment).toBe('release') + }) + + it('pins the token action to a commit', () => { + expect(tokenStep.uses).toMatch(new RegExp(`^${TOKEN_ACTION}@[0-9a-f]{40}$`)) + }) + + it('mints the token from the release environment secrets', () => { + expect(tokenStep.with?.['client-id']).toBe( + '${{ secrets.RELEASE_PLZ_APP_CLIENT_ID }}', + ) + expect(tokenStep.with?.['private-key']).toBe( + '${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }}', + ) + }) + + it('narrows the token to this repository and two permissions', () => { + expect(tokenStep.with?.repositories).toBe('stack') + const permissions = Object.entries(tokenStep.with ?? {}) + .filter(([key]) => key.startsWith('permission-')) + .sort(([a], [b]) => a.localeCompare(b)) + expect(permissions).toEqual([ + ['permission-contents', 'write'], + ['permission-pull-requests', 'write'], + ]) + }) + + it('mints the token before changesets runs, and hands it to changesets', () => { + expect(tokenStep.id).toBeTruthy() + expect(tokenIndex).toBeLessThan(changesetsIndex) + expect(changesetsStep.env?.GITHUB_TOKEN).toBe( + `\${{ steps.${tokenStep.id}.outputs.token }}`, + ) + }) + + it('keeps the commits signed by GitHub', () => { + expect(changesetsStep.with?.commitMode).toBe('github-api') + }) +}) diff --git a/scripts/lint-no-workflow-caching.mjs b/scripts/lint-no-workflow-caching.mjs index 18bd6f121..78a0b9efc 100644 --- a/scripts/lint-no-workflow-caching.mjs +++ b/scripts/lint-no-workflow-caching.mjs @@ -161,6 +161,10 @@ const AUDITED_ACTIONS = new Map([ // release.yml's publish step. Runs `pnpm run release` and talks to npm over // OIDC; no cache, no cache input. ['changesets/action', { cacheInput: null }], + // Mints the GitHub App token that pushes the Version Packages PR. Read at the + // pinned v3.2.0 (bcd2ba49): a node24 action with a `post` step that revokes + // the token; no cache, no cache input. + ['actions/create-github-app-token', { cacheInput: null }], // Artifact transport between the build matrix and the publish job. Neither // touches the GitHub Actions cache: they use the artifact API, a different // per-run store with no cross-run key. From 90e5f8ce2c89aa267b29f75a7e4d11acc452abff Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Wed, 7 Oct 2026 16:06:54 +1100 Subject: [PATCH 2/2] ci(release): publish with GITHUB_TOKEN when the App token cannot be minted The mint step gates `changeset publish` in the same job, and it fails on a rotated secret, a suspended App or an environment rule, none of which bears on whether the merged release is good to publish. The step is now `continue-on-error` and changesets falls back to GITHUB_TOKEN, so the release publishes as it did before #1092 and only the Version Packages PR's CI is lost; a warning step names that loss and what to check. Raised by @freshtonic in review of #1092. --- .github/workflows/release.yml | 18 +++++++++++++++--- scripts/__tests__/release-app-token.test.mjs | 13 ++++++++++++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed7d0b10c..0cf458729 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -627,8 +627,15 @@ jobs: # #1044). A push made with a GitHub App's token does start CI. The token is # narrowed to this repository and the two permissions changesets needs, # whatever the App itself holds; the post step revokes it. + # + # `continue-on-error`: the same step gates `changeset publish` below, and + # a mint fails on a rotated secret, a suspended App or an environment + # rule — none of which bears on whether the merged release is good to + # publish. The fallback is GITHUB_TOKEN, which publishes as before and + # loses only the PR's CI; the step after says so where the run is read. - name: Mint the Version Packages token id: app-token + continue-on-error: true uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ secrets.RELEASE_PLZ_APP_CLIENT_ID }} @@ -637,6 +644,10 @@ jobs: permission-contents: write permission-pull-requests: write + - name: Warn when the Version Packages PR will carry no CI + if: ${{ steps.app-token.outputs.token == '' }} + run: echo "::warning title=No App token::Minting the cipherstash-release-plz token failed, so changesets is pushing the Version Packages PR with GITHUB_TOKEN and that PR will run no CI. Close and reopen it to start checks, and check the release environment's RELEASE_PLZ_APP_* secrets." + - name: Publish to npm id: changesets uses: changesets/action@v1.9.0 @@ -658,9 +669,10 @@ jobs: # changesets/action writes a token .npmrc that shadows OIDC and # every publish fails with E404 (see npm/cli#8976). # - # The App token from the step above, not secrets.GITHUB_TOKEN, so the - # Version Packages PR it pushes runs CI. - GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + # The App token from the step above, so the Version Packages PR it + # pushes runs CI; GITHUB_TOKEN when the mint failed, so a release + # still publishes. + GITHUB_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} # Embeds the CLI's PostHog project key at build time (see # languages/typescript/packages/cli/tsup.config.ts). A repo *variable*, not a secret: the # key is public and write-only (like a web SDK key). Unset until GA, so diff --git a/scripts/__tests__/release-app-token.test.mjs b/scripts/__tests__/release-app-token.test.mjs index be629d685..e690ba014 100644 --- a/scripts/__tests__/release-app-token.test.mjs +++ b/scripts/__tests__/release-app-token.test.mjs @@ -71,10 +71,21 @@ describe('release.yml pushes the Version Packages PR as a GitHub App', () => { expect(tokenStep.id).toBeTruthy() expect(tokenIndex).toBeLessThan(changesetsIndex) expect(changesetsStep.env?.GITHUB_TOKEN).toBe( - `\${{ steps.${tokenStep.id}.outputs.token }}`, + `\${{ steps.${tokenStep.id}.outputs.token || secrets.GITHUB_TOKEN }}`, ) }) + it('publishes even when the mint fails, and says the PR will carry no CI', () => { + // The mint gates `changeset publish` in the same job. A rotated secret or + // a suspended App must cost the PR its CI, not the release its publish. + expect(tokenStep['continue-on-error']).toBe(true) + const warning = steps + .slice(tokenIndex + 1, changesetsIndex) + .find((step) => String(step?.run ?? '').includes('::warning')) + expect(warning, 'no step warns when the token is empty').toBeDefined() + expect(warning.if).toBe(`\${{ steps.${tokenStep.id}.outputs.token == '' }}`) + }) + it('keeps the commits signed by GitHub', () => { expect(changesetsStep.with?.commitMode).toBe('github-api') })