From eb64042cbdc7d39d65d6ebdd40c20b8b6d6e2f56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Tue, 20 Oct 2026 16:37:59 +0200 Subject: [PATCH] fix(release): prepare release PRs after draft publication Release Please cannot resolve the manifest baseline until a draft release is published and its Git tag exists. Creating the next PR immediately after drafting a release scans the full history and proposes a spurious version bump. Split release creation from PR generation, wait for successful publication, and check the current manifest tag before generating PRs on ordinary pushes or recovery runs. API failures remain visible rather than masquerading as missing tags. Verify the workflow dependency contract and execute its baseline guard against published, missing, and failed API responses. --- .github/workflows/release-please.yml | 43 ++++++++++++++ cli/scripts/release.test.ts | 88 ++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index cb61a89..fd4131a 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -38,6 +38,8 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} config-file: release-please-config.json manifest-file: .release-please-manifest.json + # Draft releases have no Git tag until artifact publication completes. + skip-github-pull-request: true release-context: name: Resolve release context @@ -343,3 +345,44 @@ jobs: RELEASE_ROOT: ${{ github.workspace }} RELEASE_TAG: ${{ needs.release-context.outputs.tag_name }} run: bun run .release-orchestration/cli/scripts/publish-release.ts publish-github + + release-pull-request: + name: Prepare next release PR + needs: [release-context, release-artifacts] + if: >- + !cancelled() && + needs.release-context.result == 'success' && + (needs.release-artifacts.result == 'success' || + needs.release-context.outputs.should_release != 'true') + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + + steps: + - name: Check release baseline tag + id: baseline + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + version="$(gh api "repos/${GITHUB_REPOSITORY}/contents/.release-please-manifest.json?ref=main" \ + --jq '.content | @base64d | fromjson | .["."]')" + tag_exists="$(gh api "repos/${GITHUB_REPOSITORY}/git/matching-refs/tags/v${version}" \ + --jq "any(.[]; .ref == \"refs/tags/v${version}\")")" + if [[ "${tag_exists}" != "true" ]]; then + echo "::warning::Release v${version} has no Git tag yet; complete release publication before preparing the next PR" + exit 0 + fi + echo "ready=true" >> "${GITHUB_OUTPUT}" + + - name: Prepare release PR + if: steps.baseline.outputs.ready == 'true' + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + token: ${{ secrets.GITHUB_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 96dd89b..faa5693 100644 --- a/cli/scripts/release.test.ts +++ b/cli/scripts/release.test.ts @@ -51,6 +51,7 @@ const repositoryRoot = join(import.meta.dir, '../..'); type WorkflowStep = { env?: Record; + if?: string; name?: string; run?: string; uses?: string; @@ -736,6 +737,93 @@ describe('release infrastructure wiring', () => { } }); + test('prepares release PRs only after publication has supplied a baseline tag', async () => { + 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); + + expect(releaseAction?.with?.['skip-github-pull-request']).toBe(true); + expect(workflowNeeds(pullRequest)).toEqual([ + 'release-context', + 'release-artifacts', + ]); + expect(pullRequest.if).toContain('!cancelled()'); + expect(pullRequest.if).toContain( + "needs.release-context.result == 'success'" + ); + expect(pullRequest.if).toContain( + "needs.release-artifacts.result == 'success' ||" + ); + expect(pullRequest.if).toContain( + "needs.release-context.outputs.should_release != 'true'" + ); + expect(pullRequestAction?.if).toBe( + "steps.baseline.outputs.ready == 'true'" + ); + expect(pullRequestAction?.with?.['skip-github-release']).toBe(true); + expect(pullRequestAction?.uses).toBe(releaseAction?.uses); + expect( + workflowStepIndex(pullRequest, 'Check release baseline tag') + ).toBeLessThan(workflowStepIndex(pullRequest, 'Prepare release PR')); + }); + + test.each([ + { tagExists: true, apiFails: '', ready: true, exitCode: 0 }, + { tagExists: false, apiFails: '', ready: false, exitCode: 0 }, + { tagExists: true, apiFails: 'manifest', ready: false, exitCode: 1 }, + { tagExists: true, apiFails: 'tags', ready: false, exitCode: 1 }, + ])( + 'checks the live manifest baseline before generating a PR: %j', + async scenario => { + const workflow = await readWorkflow('release-please.yml'); + const script = workflow.jobs['release-pull-request']?.steps?.find( + step => step.name === 'Check release baseline tag' + )?.run; + if (!script) throw new Error('Missing release baseline check'); + const directory = await mkdtemp(join(tmpdir(), 'release-baseline-')); + temporaryDirectories.push(directory); + const outputPath = join(directory, 'output'); + await Bun.write(outputPath, ''); + // Only GitHub is mocked; execute the workflow's actual shell script. + const githubStub = ` + gh() { + case "$2" in + 'repos/example/repository/contents/.release-please-manifest.json?ref=main') + if [[ "$API_FAILS" == 'manifest' ]]; then return 1; fi + echo '1.13.0' + ;; + 'repos/example/repository/git/matching-refs/tags/v1.13.0') + if [[ "$API_FAILS" == 'tags' ]]; then return 1; fi + echo "$TAG_EXISTS" + ;; + *) return 99 ;; + esac + } + `; + const process = Bun.spawn(['bash', '-c', githubStub + script], { + env: { + ...Bun.env, + GITHUB_REPOSITORY: 'example/repository', + GITHUB_OUTPUT: outputPath, + API_FAILS: scenario.apiFails, + TAG_EXISTS: String(scenario.tagExists), + }, + stdout: 'pipe', + stderr: 'pipe', + }); + const stdout = await new Response(process.stdout).text(); + expect(await process.exited).toBe(scenario.exitCode); + expect(await readFile(outputPath, 'utf8')).toBe( + scenario.ready ? 'ready=true\n' : '' + ); + if (!scenario.tagExists) { + expect(stdout).toContain('complete release publication'); + } + } + ); + test('builds release publication from only the orchestration checkout', async () => { const workflow = await readWorkflow('release-please.yml'); const checkout = workflow.jobs['release-artifacts']?.steps?.find(