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(