From 36fbd9c0253064771081424e7b9773dc9469d18c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Mon, 26 Oct 2026 10:57:02 +0100 Subject: [PATCH] fix(release): authenticate Release Please with GitHub App tokens Mint repository-scoped installation tokens in each Release Please job so release PR updates trigger CI. Request contents, issues, and pull requests write permissions and reduce the jobs' GITHUB_TOKEN grants. Preserve the baseline guard, recovery, artifact publication, and npm trusted publishing. Cover token scope and routing in the existing release workflow checks. --- .github/workflows/release-please.yml | 36 +++++++++++++++---- cli/scripts/release.test.ts | 53 ++++++++++++++++++++++++++-- 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index fd4131a..c94a35d 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -23,19 +23,29 @@ jobs: if: github.event_name == 'push' runs-on: ubuntu-24.04 timeout-minutes: 10 - permissions: - contents: write - pull-requests: write + permissions: {} outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} steps: + - name: Create release app token + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ vars.RELEASE_PLEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_PLEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-issues: write + permission-pull-requests: write + - name: Prepare release uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 id: release with: - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ steps.app-token.outputs.token }} config-file: release-please-config.json manifest-file: .release-please-manifest.json # Draft releases have no Git tag until artifact publication completes. @@ -357,8 +367,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: - contents: write - pull-requests: write + contents: read steps: - name: Check release baseline tag @@ -378,11 +387,24 @@ jobs: fi echo "ready=true" >> "${GITHUB_OUTPUT}" + - name: Create release app token + if: steps.baseline.outputs.ready == 'true' + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ vars.RELEASE_PLEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_PLEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-issues: write + permission-pull-requests: write + - name: Prepare release PR if: steps.baseline.outputs.ready == 'true' uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 with: - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ steps.app-token.outputs.token }} config-file: release-please-config.json manifest-file: .release-please-manifest.json skip-github-release: true diff --git a/cli/scripts/release.test.ts b/cli/scripts/release.test.ts index faa5693..bb41fdf 100644 --- a/cli/scripts/release.test.ts +++ b/cli/scripts/release.test.ts @@ -50,6 +50,7 @@ const temporaryDirectories: string[] = []; const repositoryRoot = join(import.meta.dir, '../..'); type WorkflowStep = { + id?: string; env?: Record; if?: string; name?: string; @@ -741,8 +742,12 @@ describe('release infrastructure wiring', () => { const workflow = await readWorkflow('release-please.yml'); const preparation = workflow.jobs['release-please'] ?? {}; const pullRequest = workflow.jobs['release-pull-request'] ?? {}; - const releaseAction = preparation.steps?.find(step => step.uses); - const pullRequestAction = pullRequest.steps?.find(step => step.uses); + const releaseAction = preparation.steps?.find(step => + step.uses?.startsWith('googleapis/release-please-action@') + ); + const pullRequestAction = pullRequest.steps?.find(step => + step.uses?.startsWith('googleapis/release-please-action@') + ); expect(releaseAction?.with?.['skip-github-pull-request']).toBe(true); expect(workflowNeeds(pullRequest)).toEqual([ @@ -769,6 +774,50 @@ describe('release infrastructure wiring', () => { ).toBeLessThan(workflowStepIndex(pullRequest, 'Prepare release PR')); }); + test('scopes release app tokens to the two Release Please jobs', async () => { + const workflow = await readWorkflow('release-please.yml'); + for (const [name, job] of Object.entries(workflow.jobs)) { + const tokenSteps = + job.steps?.filter(step => + step.uses?.startsWith('actions/create-github-app-token@') + ) ?? []; + if (!['release-please', 'release-pull-request'].includes(name)) { + expect(tokenSteps).toHaveLength(0); + continue; + } + + expect(tokenSteps).toHaveLength(1); + const token = tokenSteps[0]; + expect(token?.id).toBe('app-token'); + expect(token?.with).toEqual({ + 'client-id': '${{ vars.RELEASE_PLEASE_APP_CLIENT_ID }}', + 'private-key': '${{ secrets.RELEASE_PLEASE_APP_PRIVATE_KEY }}', + owner: '${{ github.repository_owner }}', + repositories: '${{ github.event.repository.name }}', + 'permission-contents': 'write', + 'permission-issues': 'write', + 'permission-pull-requests': 'write', + }); + const release = job.steps?.find(step => + step.uses?.startsWith('googleapis/release-please-action@') + ); + expect(release?.with?.token).toBe('${{ steps.app-token.outputs.token }}'); + expect(job.steps?.indexOf(token!)).toBeLessThan( + job.steps?.indexOf(release!) ?? -1 + ); + if (name === 'release-pull-request') { + expect(token?.if).toBe("steps.baseline.outputs.ready == 'true'"); + expect( + workflowStepIndex(job, 'Check release baseline tag') + ).toBeLessThan(job.steps?.indexOf(token!) ?? -1); + expect(job.permissions).toEqual({ contents: 'read' }); + } else { + expect(token?.if).toBeUndefined(); + expect(job.permissions).toEqual({}); + } + } + }); + test.each([ { tagExists: true, apiFails: '', ready: true, exitCode: 0 }, { tagExists: false, apiFails: '', ready: false, exitCode: 0 },