From 13443aa36be0512433a8793d9befaf08fffb764e Mon Sep 17 00:00:00 2001 From: NiftyAndy Date: Sun, 2 Aug 2026 14:14:24 -0400 Subject: [PATCH] fix(release): validate automation token before Release Please --- .github/workflows/release.yml | 89 ++++++++++++++++++++++------ docs/RELEASES.md | 31 ++++++---- test/cli.test.mjs | 106 ++++++++++++++++++++++++++++++++-- 3 files changed, 193 insertions(+), 33 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e687a0..667fae3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,6 +54,7 @@ jobs: release_created: ${{ steps.release.outputs.release_created || 'false' }} tag_name: ${{ steps.release.outputs.tag_name }} npm_publish: ${{ steps.profile.outputs.npm_publish }} + token_source: ${{ steps.credentials.outputs.token_source }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -116,25 +117,80 @@ jobs: id: credentials env: HAS_AUTOMATION_TOKEN: ${{ secrets.CODE_FOUNDRY_TOKEN != '' || secrets.RELEASE_PLEASE_TOKEN != '' }} + GH_TOKEN: ${{ secrets.CODE_FOUNDRY_TOKEN || secrets.RELEASE_PLEASE_TOKEN }} run: | - if [ "$HAS_AUTOMATION_TOKEN" = true ]; then + set -euo pipefail + # NiftyLeague rejects long-lived fine-grained automation tokens with + # HTTP 403 even on REST (observed in the release-pr promotion flow), + # so the configured automation token is validated against the current + # repository before any write. gh api authenticates through the + # GH_TOKEN environment variable and never prints the token; the + # response body is discarded and only the exit status selects the + # credential for the rest of the job. + if [ "$HAS_AUTOMATION_TOKEN" = true ] && gh api "repos/${GITHUB_REPOSITORY}" --jq '.full_name' >/dev/null 2>&1; then echo "auto_merge=true" >> "$GITHUB_OUTPUT" + echo "token_source=configured" >> "$GITHUB_OUTPUT" else echo "auto_merge=false" >> "$GITHUB_OUTPUT" + echo "token_source=fallback" >> "$GITHUB_OUTPUT" + if [ "$HAS_AUTOMATION_TOKEN" = true ]; then + echo "::warning title=Release token fallback::The configured CODE_FOUNDRY_TOKEN or RELEASE_PLEASE_TOKEN was rejected by GitHub; failing over to the short-lived workflow token. The version pull request will be left for manual merge." + fi fi - - name: Release Please - id: release - if: steps.profile.outputs.release_type != 'none' + - name: Release Please (automation token) + id: release_automation + if: steps.profile.outputs.release_type != 'none' && steps.credentials.outputs.token_source == 'configured' uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5 with: - token: ${{ secrets.CODE_FOUNDRY_TOKEN || secrets.RELEASE_PLEASE_TOKEN || github.token }} + token: ${{ secrets.CODE_FOUNDRY_TOKEN || secrets.RELEASE_PLEASE_TOKEN }} config-file: release-please-config.json release-type: ${{ steps.profile.outputs.legacy_release_type }} + - name: Release Please (workflow token) + id: release_workflow + if: steps.profile.outputs.release_type != 'none' && steps.credentials.outputs.token_source != 'configured' + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5 + with: + token: ${{ github.token }} + config-file: release-please-config.json + release-type: ${{ steps.profile.outputs.legacy_release_type }} + - name: Normalize Release Please outputs + id: release + if: steps.profile.outputs.release_type != 'none' + env: + AUTOMATION_RELEASE_CREATED: ${{ steps.release_automation.outputs.release_created }} + AUTOMATION_TAG_NAME: ${{ steps.release_automation.outputs.tag_name }} + AUTOMATION_PRS_CREATED: ${{ steps.release_automation.outputs.prs_created }} + WORKFLOW_RELEASE_CREATED: ${{ steps.release_workflow.outputs.release_created }} + WORKFLOW_TAG_NAME: ${{ steps.release_workflow.outputs.tag_name }} + WORKFLOW_PRS_CREATED: ${{ steps.release_workflow.outputs.prs_created }} + run: | + set -euo pipefail + # Exactly one of the two Release Please steps runs, so selecting the + # source by a non-empty release_created is deterministic. The stable + # release step id keeps the job outputs and downstream jobs reading + # steps.release.outputs.* unchanged. + if [ -n "$AUTOMATION_RELEASE_CREATED" ]; then + { + echo "release_created=$AUTOMATION_RELEASE_CREATED" + echo "tag_name=$AUTOMATION_TAG_NAME" + echo "prs_created=$AUTOMATION_PRS_CREATED" + } >> "$GITHUB_OUTPUT" + else + { + echo "release_created=$WORKFLOW_RELEASE_CREATED" + echo "tag_name=$WORKFLOW_TAG_NAME" + echo "prs_created=$WORKFLOW_PRS_CREATED" + } >> "$GITHUB_OUTPUT" + fi - name: Normalize generated release PR draft state if: steps.release.outcome == 'success' env: - HAS_AUTOMATION_TOKEN: ${{ secrets.CODE_FOUNDRY_TOKEN != '' || secrets.RELEASE_PLEASE_TOKEN != '' }} - GH_TOKEN: ${{ secrets.CODE_FOUNDRY_TOKEN || secrets.RELEASE_PLEASE_TOKEN || github.token }} + # The credential selected by the validation step is reused verbatim + # for every release-PR shell operation: the configured token only + # when it validated, github.token after fallback. The token value + # lives only in this masked env expression, never in step outputs. + AUTO_MERGE: ${{ steps.credentials.outputs.auto_merge }} + GH_TOKEN: ${{ steps.credentials.outputs.token_source == 'configured' && (secrets.CODE_FOUNDRY_TOKEN || secrets.RELEASE_PLEASE_TOKEN) || github.token }} run: | set -euo pipefail mapfile -t release_prs < <( @@ -152,7 +208,7 @@ jobs: for pr in "${release_prs[@]}"; do PR_DRAFT=$(gh pr view "$pr" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft') - if [ "$HAS_AUTOMATION_TOKEN" = true ]; then + if [ "$AUTO_MERGE" = true ]; then if [ "$PR_DRAFT" = true ]; then gh pr ready "$pr" --repo "$GITHUB_REPOSITORY" fi @@ -160,22 +216,18 @@ jobs: if [ "$PR_DRAFT" = false ]; then gh pr ready --undo "$pr" --repo "$GITHUB_REPOSITORY" fi - echo "::notice title=Manual release merge required::Release Please created or updated a version pull request. No CODE_FOUNDRY_TOKEN or RELEASE_PLEASE_TOKEN is configured, so release PRs are left in draft and need manual readiness before validation and merge workflows trigger." + echo "::notice title=Manual release merge required::Release Please created or updated a version pull request. No valid automation token is available (absent or rejected by GitHub), so release PRs are left in draft and need manual readiness before validation and merge workflows trigger." fi done - name: Leave release pull request for manual merge if: steps.release.outputs.prs_created == 'true' && steps.credentials.outputs.auto_merge != 'true' - env: - HAS_AUTOMATION_TOKEN: ${{ secrets.CODE_FOUNDRY_TOKEN != '' || secrets.RELEASE_PLEASE_TOKEN != '' }} run: | - if [ "$HAS_AUTOMATION_TOKEN" != true ]; then - echo "::notice title=Manual release merge required::Release Please created or updated a version pull request. Configure CODE_FOUNDRY_TOKEN or RELEASE_PLEASE_TOKEN to enable guarded auto-merge when downstream release workflows require it." - fi + echo "::notice title=Manual release merge required::Release Please created or updated a version pull request. Configure a valid CODE_FOUNDRY_TOKEN or RELEASE_PLEASE_TOKEN to enable guarded auto-merge when downstream release workflows require it." - name: Merge generated version pull requests if: steps.credentials.outputs.auto_merge == 'true' env: - GH_TOKEN: ${{ secrets.CODE_FOUNDRY_TOKEN || secrets.RELEASE_PLEASE_TOKEN || github.token }} + GH_TOKEN: ${{ steps.credentials.outputs.token_source == 'configured' && (secrets.CODE_FOUNDRY_TOKEN || secrets.RELEASE_PLEASE_TOKEN) || github.token }} run: | set -euo pipefail mapfile -t release_prs < <( @@ -388,8 +440,11 @@ jobs: actions: write contents: read env: - RELEASE_PLEASE_TOKEN: ${{ secrets.CODE_FOUNDRY_TOKEN || secrets.RELEASE_PLEASE_TOKEN }} - GH_TOKEN: ${{ secrets.CODE_FOUNDRY_TOKEN || secrets.RELEASE_PLEASE_TOKEN || github.token }} + # The release job validates the configured automation token and exposes + # the selection; the post hook reuses the same selected credential so a + # rejected long-lived token is never used after fallback. + RELEASE_PLEASE_TOKEN: ${{ needs.release.outputs.token_source == 'configured' && (secrets.CODE_FOUNDRY_TOKEN || secrets.RELEASE_PLEASE_TOKEN) || github.token }} + GH_TOKEN: ${{ needs.release.outputs.token_source == 'configured' && (secrets.CODE_FOUNDRY_TOKEN || secrets.RELEASE_PLEASE_TOKEN) || github.token }} steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/docs/RELEASES.md b/docs/RELEASES.md index f6d660e..ad08720 100644 --- a/docs/RELEASES.md +++ b/docs/RELEASES.md @@ -71,17 +71,26 @@ its manifest. ## Pull request permissions -Release Please falls back to the repository's `GITHUB_TOKEN` when a -`RELEASE_PLEASE_TOKEN` secret is unavailable. In that mode, Code Foundry opens -or updates the version pull request, leaves it for manual merge, and completes -the release job successfully. - -Configure a narrowly scoped `RELEASE_PLEASE_TOKEN` repository or organization -secret to enable guarded automatic merging and downstream workflows triggered -by the resulting release. The token needs `contents`, `issues`, and -`pull-requests` write permissions. Code Foundry validates every changed path in -the generated version pull request before using the token to merge it, waits -for the required checks to finish, and then polls the pull request's +The release job validates the configured automation token +(`CODE_FOUNDRY_TOKEN`, falling back to `RELEASE_PLEASE_TOKEN`) against the +current repository with an authenticated REST probe before any write. When no +automation token is configured, or the configured token is rejected by GitHub +(observed with long-lived fine-grained tokens that GitHub rejects with HTTP +403 even on REST), the release job fails over to the repository's short-lived +`GITHUB_TOKEN`. In that mode, Code Foundry opens or updates the version pull +request, leaves it for manual merge, and completes the release job +successfully. The token value is never printed or written to step outputs. + +Guarded automatic merging and the downstream workflows triggered by the +resulting release are enabled only when the configured automation token was +validated successfully; a rejected token never falls through to any write. +Configure a valid, narrowly scoped `CODE_FOUNDRY_TOKEN` or +`RELEASE_PLEASE_TOKEN` repository or organization secret to enable guarded +automatic merging and downstream workflows triggered by the resulting +release. The token needs `contents`, `issues`, and `pull-requests` write +permissions. Code Foundry validates every changed path in the generated +version pull request before using the token to merge it, waits for the +required checks to finish, and then polls the pull request's `mergeStateStatus` until it is `CLEAN`, or `UNSTABLE` with `mergeable` `MERGEABLE`, before merging. Non-required checks that branch policy does not require (for example external code review still running after every required diff --git a/test/cli.test.mjs b/test/cli.test.mjs index b30ef14..22a70c1 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -876,16 +876,112 @@ describe('code-foundry CLI', () => { assert.match(workflow, /name: Detect release credentials/) assert.match(workflow, /auto_merge=false/) - assert.match(workflow, /token: \$\{\{ secrets\.CODE_FOUNDRY_TOKEN \|\| secrets\.RELEASE_PLEASE_TOKEN \|\| github\.token \}\}/) assert.match(workflow, /name: Normalize generated release PR draft state/) - assert.match(workflow, /GH_TOKEN: \$\{\{ secrets\.CODE_FOUNDRY_TOKEN \|\| secrets\.RELEASE_PLEASE_TOKEN \|\| github\.token \}\}/) assert.match(workflow, /gh pr ready --undo/) - assert.match(workflow, /No CODE_FOUNDRY_TOKEN or RELEASE_PLEASE_TOKEN is configured/) + assert.match(workflow, /No valid automation token is available \(absent or rejected by GitHub\)/) assert.match(workflow, /name: Leave release pull request for manual merge/) assert.match(workflow, /steps\.credentials\.outputs\.auto_merge != 'true'/) assert.match(workflow, /steps\.credentials\.outputs\.auto_merge == 'true'/) - assert.match(workflow, /RELEASE_PLEASE_TOKEN: \$\{\{ secrets\.CODE_FOUNDRY_TOKEN \|\| secrets\.RELEASE_PLEASE_TOKEN \}\}/) - assert.match(workflow, /GH_TOKEN: \$\{\{ secrets\.CODE_FOUNDRY_TOKEN \|\| secrets\.RELEASE_PLEASE_TOKEN \|\| github\.token \}\}/) + assert.match(workflow, /RELEASE_PLEASE_TOKEN: \$\{\{ needs\.release\.outputs\.token_source == 'configured' && \(secrets\.CODE_FOUNDRY_TOKEN \|\| secrets\.RELEASE_PLEASE_TOKEN\) \|\| github\.token \}\}/) + assert.match(workflow, /GH_TOKEN: \$\{\{ needs\.release\.outputs\.token_source == 'configured' && \(secrets\.CODE_FOUNDRY_TOKEN \|\| secrets\.RELEASE_PLEASE_TOKEN\) \|\| github\.token \}\}/) + }) + it('validates release automation credentials and fails over to the workflow token', () => { + const workflow = readFileSync('.github/workflows/release.yml', 'utf8') + const stepSlice = (name) => { + const start = workflow.indexOf(`- name: ${name}\n`) + assert.ok(start !== -1, `workflow has a ${name} step`) + const next = workflow.indexOf('- name: ', start + 1) + return next === -1 ? workflow.slice(start) : workflow.slice(start, next) + } + + // The configured automation token is validated with authenticated REST + // against the current repository (NiftyLeague rejects long-lived + // fine-grained tokens with HTTP 403 even on REST, so the gh api probe is + // the single source of truth for the credential selection). The response + // body is discarded and only the exit status selects the credential. + const credentialsStep = stepSlice('Detect release credentials') + assert.match(credentialsStep, /GH_TOKEN: \$\{\{ secrets\.CODE_FOUNDRY_TOKEN \|\| secrets\.RELEASE_PLEASE_TOKEN \}\}/) + assert.match(credentialsStep, /if \[ "\$HAS_AUTOMATION_TOKEN" = true \] && gh api "repos\/\$\{GITHUB_REPOSITORY\}" --jq '\.full_name' >\/dev\/null 2>&1/) + assert.match(credentialsStep, /token_source=configured/) + assert.match(credentialsStep, /token_source=fallback/) + assert.match(credentialsStep, /::warning title=Release token fallback::/) + + // auto_merge stays true only in the branch where the configured token was + // validated; any absence or rejection selects the short-lived workflow + // token and leaves the release PR for manual merge. + const ghProbe = credentialsStep.indexOf('gh api') + const autoMergeTrue = credentialsStep.indexOf('echo "auto_merge=true"') + const configured = credentialsStep.indexOf('token_source=configured') + const fallback = credentialsStep.indexOf('token_source=fallback') + const autoMergeFalse = credentialsStep.indexOf('echo "auto_merge=false"') + assert.ok(ghProbe !== -1 && autoMergeTrue !== -1 && configured !== -1 && fallback !== -1 && autoMergeFalse !== -1) + assert.ok(ghProbe < autoMergeTrue && autoMergeTrue < configured, 'auto_merge=true must follow a successful gh api validation') + assert.ok(autoMergeFalse < fallback, 'fallback selection must follow auto_merge=false') + + // The token value must never be echoed, exported to outputs, or otherwise + // logged: the step env carries the (masked) secret, the run block only + // lets gh read it from the environment. + const credentialsRun = credentialsStep.slice(credentialsStep.indexOf('run: |')) + assert.doesNotMatch(credentialsRun, /(?:\$|\$\{)GH_TOKEN/) + assert.doesNotMatch(credentialsRun, /printenv/) + assert.doesNotMatch(credentialsRun, /GH_TOKEN[^\n]*GITHUB_OUTPUT/) + + // Two mutually-exclusive Release Please steps: the configured secret only + // when it validated, github.token otherwise. Action inputs cannot carry a + // shell-selected secret, so the selection happens in the if conditions. + const automationStep = stepSlice('Release Please (automation token)') + assert.match(automationStep, /id: release_automation/) + assert.match(automationStep, /if: steps\.profile\.outputs\.release_type != 'none' && steps\.credentials\.outputs\.token_source == 'configured'/) + assert.match(automationStep, /token: \$\{\{ secrets\.CODE_FOUNDRY_TOKEN \|\| secrets\.RELEASE_PLEASE_TOKEN \}\}/) + assert.match(automationStep, /config-file: release-please-config\.json/) + assert.match(automationStep, /release-type: \$\{\{ steps\.profile\.outputs\.legacy_release_type \}\}/) + assert.doesNotMatch(automationStep, /github\.token/) + + const workflowStep = stepSlice('Release Please (workflow token)') + assert.match(workflowStep, /id: release_workflow/) + assert.match(workflowStep, /if: steps\.profile\.outputs\.release_type != 'none' && steps\.credentials\.outputs\.token_source != 'configured'/) + assert.match(workflowStep, /token: \$\{\{ github\.token \}\}/) + assert.match(workflowStep, /config-file: release-please-config\.json/) + assert.doesNotMatch(workflowStep, /CODE_FOUNDRY_TOKEN|RELEASE_PLEASE_TOKEN/) + assert.doesNotMatch(workflow, /token: \$\{\{ secrets\.CODE_FOUNDRY_TOKEN \|\| secrets\.RELEASE_PLEASE_TOKEN \|\| github\.token \}\}/) + + // The normalize step keeps the stable release id so job outputs and + // downstream jobs keep reading steps.release.outputs.* unchanged, and it + // forwards release_created, tag_name, and prs_created from whichever of + // the two action steps ran. + const normalizeStep = stepSlice('Normalize Release Please outputs') + assert.match(normalizeStep, /id: release/) + for (const [source, prefix] of [['release_automation', 'AUTOMATION'], ['release_workflow', 'WORKFLOW']]) { + assert.match(normalizeStep, new RegExp(`${prefix}_RELEASE_CREATED: \\$\\{\\{ steps\\.${source}\\.outputs\\.release_created \\}\\}`)) + assert.match(normalizeStep, new RegExp(`${prefix}_TAG_NAME: \\$\\{\\{ steps\\.${source}\\.outputs\\.tag_name \\}\\}`)) + assert.match(normalizeStep, new RegExp(`${prefix}_PRS_CREATED: \\$\\{\\{ steps\\.${source}\\.outputs\\.prs_created \\}\\}`)) + assert.match(normalizeStep, new RegExp(`release_created=\\$${prefix}_RELEASE_CREATED`)) + assert.match(normalizeStep, new RegExp(`tag_name=\\$${prefix}_TAG_NAME`)) + assert.match(normalizeStep, new RegExp(`prs_created=\\$${prefix}_PRS_CREATED`)) + } + assert.match(workflow, /release_created: \$\{\{ steps\.release\.outputs\.release_created \|\| 'false' \}\}/) + assert.match(workflow, /tag_name: \$\{\{ steps\.release\.outputs\.tag_name \}\}/) + assert.match(workflow, /token_source: \$\{\{ steps\.credentials\.outputs\.token_source \}\}/) + + // The same selected credential backs every shell step that lists, readies, + // edits, validates, and merges generated release PRs: the draft-state step + // and the merge step must use the identical selection expression, and the + // post-release job must reuse the release job's token_source output. + const selected = /steps\.credentials\.outputs\.token_source == 'configured' && \(secrets\.CODE_FOUNDRY_TOKEN \|\| secrets\.RELEASE_PLEASE_TOKEN\) \|\| github\.token/ + const draftStep = stepSlice('Normalize generated release PR draft state') + const mergeStep = stepSlice('Merge generated version pull requests') + assert.match(draftStep, new RegExp(`GH_TOKEN: \\$\\{\\{ ${selected.source} \\}\\}`)) + assert.match(mergeStep, new RegExp(`GH_TOKEN: \\$\\{\\{ ${selected.source} \\}\\}`)) + const draftLine = draftStep.split('\n').find((line) => line.includes('GH_TOKEN:')) + const mergeLine = mergeStep.split('\n').find((line) => line.includes('GH_TOKEN:')) + assert.equal(draftLine, mergeLine, 'draft and merge steps must use the same selected credential') + assert.match(draftStep, /AUTO_MERGE: \$\{\{ steps\.credentials\.outputs\.auto_merge \}\}/) + assert.match(draftStep, /if \[ "\$AUTO_MERGE" = true \]/) + assert.doesNotMatch(workflow, /GH_TOKEN: \$\{\{ secrets\.CODE_FOUNDRY_TOKEN \|\| secrets\.RELEASE_PLEASE_TOKEN \|\| github\.token \}\}/) + + const postReleaseEnv = workflow.slice(workflow.indexOf('post-release:\n')) + assert.match(postReleaseEnv, /RELEASE_PLEASE_TOKEN: \$\{\{ needs\.release\.outputs\.token_source == 'configured' && \(secrets\.CODE_FOUNDRY_TOKEN \|\| secrets\.RELEASE_PLEASE_TOKEN\) \|\| github\.token \}\}/) + assert.match(postReleaseEnv, /GH_TOKEN: \$\{\{ needs\.release\.outputs\.token_source == 'configured' && \(secrets\.CODE_FOUNDRY_TOKEN \|\| secrets\.RELEASE_PLEASE_TOKEN\) \|\| github\.token \}\}/) }) it('allows only release metadata during post-release reconciliation', () => { const allowed = approvedReleaseFiles({