Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -618,6 +622,32 @@ 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.
#
# `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 }}
private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }}
repositories: stack
permission-contents: write
permission-pull-requests: write
Comment on lines +636 to +645

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This step makes npm publishing depend on the App. The same token step runs before changeset publish in the same job. If the mint fails, the job stops and nothing publishes. A mint can fail if the secret is missing or rotated, if the App is uninstalled or suspended, or if the release environment rejects the job. A bad CI convenience then blocks a release that is already merged. The PR body says this is intended, but a fallback is cheap. Set continue-on-error: true on this step. Then set GITHUB_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}. Publishing keeps working, and only the PR CI is lost. Add a ::warning:: step when the output is empty so the failure is visible. If you want the hard stop, say so in a comment, and note that the test asserts the exact GITHUB_TOKEN expression and must change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@freshtonic verified and taken: the mint sits before changesets/action, which runs changeset publish in the same step, so a failed mint did stop the publish. Fixed in 90e5f8c — continue-on-error: true on the mint, GITHUB_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}, and a ::warning step when the token is empty naming what was lost and what to check. The test asserts the fallback expression, the continue-on-error, and the warning step's if.


- 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
Expand All @@ -638,7 +668,11 @@ 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, 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
Expand Down
92 changes: 92 additions & 0 deletions scripts/__tests__/release-app-token.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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 || 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')
})
})
4 changes: 4 additions & 0 deletions scripts/lint-no-workflow-caching.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading