From c12bad7f139431fc0db7a8b7c9db1eab95f96dc2 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:44:47 +0800 Subject: [PATCH] fix(release): publish reviewed release PR merges --- .github/workflows/release.yml | 85 +++++++++++-- CONTRIBUTING.md | 18 ++- docs/releasing.md | 39 +++--- scripts/lib/release-checkpoint.mjs | 33 ++++- .../filesystem/releaseCheckpoint.test.ts | 117 ++++++++++++++++-- .../filesystem/releaseWorkflow.test.ts | 117 ++++++++++++++++-- 6 files changed, 352 insertions(+), 57 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d4c6f80b..77ef18839 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,9 @@ name: Release on: push: branches: [main] + pull_request: + branches: [main] + types: [closed] workflow_dispatch: inputs: release_branch: @@ -20,7 +23,7 @@ env: HUSKY: "0" concurrency: - group: release-${{ inputs.release_branch || github.ref_name }} + group: release-${{ inputs.release_branch || github.event.pull_request.merge_commit_sha || github.ref_name }} cancel-in-progress: false jobs: @@ -38,6 +41,68 @@ jobs: target-branch: main skip-github-release: true + merged-release: + name: Release reviewed main merge + if: >- + github.event_name == 'pull_request' && github.event.pull_request.merged == true && + github.event.pull_request.head.repo.full_name == github.repository && + startsWith(github.event.pull_request.head.ref, 'release-please--branches--main--') + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + release_created: ${{ steps.release.outputs.release_created }} + sha: ${{ steps.release.outputs.sha }} + steps: + - name: Check out reviewed release merge + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + package-manager-cache: false + + - run: npm ci + + - name: Validate reviewed merge before release creation + env: + SOURCE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + if [[ "${SOURCE_SHA}" != "${GITHUB_SHA}" ]]; then + echo "The reviewed merge differs from the workflow source; refusing mismatched publication provenance." >&2 + exit 1 + fi + node scripts/verify-release-checkpoint.mjs \ + --phase publish --release-branch main --source-sha "$SOURCE_SHA" + + - uses: googleapis/release-please-action@v4 + id: release + with: + token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + target-branch: main + skip-github-release: false + skip-github-pull-request: true + + - name: Bind publication to the reviewed merge + env: + RELEASE_CREATED: ${{ steps.release.outputs.release_created }} + RELEASE_SHA: ${{ steps.release.outputs.sha }} + SOURCE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + if [[ "${RELEASE_CREATED}" != true ]]; then + echo "Release Please did not create a release for the reviewed merge. Check its pending label and release metadata; for a partial publication, re-run failed jobs in the original run." >&2 + exit 1 + fi + if [[ "${RELEASE_SHA}" != "${SOURCE_SHA}" ]]; then + echo "The tagged release differs from the reviewed merge; refusing mismatched publication provenance. The GitHub release already exists; do not create another release as a retry." >&2 + exit 1 + fi + release-please: name: Release Please if: github.event_name == 'workflow_dispatch' @@ -193,8 +258,11 @@ jobs: publish: name: Publish to npm - needs: release-please - if: inputs.phase == 'publish' && needs.release-please.outputs.release_created == 'true' + needs: [merged-release, release-please] + if: >- + always() && !cancelled() && + ((needs.merged-release.result == 'success' && needs.merged-release.outputs.release_created == 'true') || + (needs.release-please.result == 'success' && inputs.phase == 'publish' && needs.release-please.outputs.release_created == 'true')) runs-on: ubuntu-latest permissions: contents: read @@ -202,7 +270,7 @@ jobs: steps: - uses: actions/checkout@v6 with: - ref: ${{ needs.release-please.outputs.sha }} + ref: ${{ needs.merged-release.outputs.sha || needs.release-please.outputs.sha }} persist-credentials: false - uses: actions/setup-node@v6 @@ -265,8 +333,11 @@ jobs: publish-mcp: name: Publish to MCP Registry - needs: [release-please, publish] - if: inputs.phase == 'publish' && needs.release-please.outputs.release_created == 'true' && needs.publish.result == 'success' + needs: [merged-release, release-please, publish] + if: >- + always() && !cancelled() && needs.publish.result == 'success' && + ((needs.merged-release.result == 'success' && needs.merged-release.outputs.release_created == 'true') || + (needs.release-please.result == 'success' && inputs.phase == 'publish' && needs.release-please.outputs.release_created == 'true')) runs-on: ubuntu-latest permissions: contents: read @@ -274,7 +345,7 @@ jobs: steps: - uses: actions/checkout@v6 with: - ref: ${{ needs.release-please.outputs.sha }} + ref: ${{ needs.merged-release.outputs.sha || needs.release-please.outputs.sha }} persist-credentials: false - name: Verify package and registry metadata versions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd87b8c2f..937c4cd2c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -165,16 +165,14 @@ Describe the behavior change and verification performed in the pull request. Nev ## Maintainer release checklist -Use the [checkpoint release guide](docs/releasing.md). Main pushes automatically -refresh a release proposal with the next version and changelog. Keep this preview -PR unmerged until it is superseded by the published release's metadata sync. -Publication starts from an explicit `release/VERSION` branch cut at a recorded -application commit. Manually prepare its bot PR, wait for its exact-head CI and -review, merge into that frozen branch, and manually publish through the official -Release workflow. Main pushes cannot publish packages. Both publishers build -the exact SHA tagged by Release Please. +Use the [release guide](docs/releasing.md). Main pushes automatically refresh a +Release Please PR with the next version and changelog. Review its migration +notes, wait for the current CI checks to pass, and merge it into main to publish. +The Release workflow validates and tags that merge; both publishers build its +exact SHA. Ordinary main pushes cannot publish packages. An optional manual +`release/VERSION` checkpoint retains explicit preparation and publication. Keep new implementation commits on main for the next release. The workflow owns packaged-artifact verification, npm publication, the published CLI/MCP -canary, and MCP Registry publication. Sync release metadata back to main after -publication; see the guide for partial-publication recovery and verification. +canary, and MCP Registry publication. Sync manual checkpoint metadata back to +main after publication; see the guide for recovery and verification. diff --git a/docs/releasing.md b/docs/releasing.md index 9d40b5e37..c18ea88d0 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,25 +1,32 @@ -# Releasing from a checkpoint +# Releasing REA -Pushes to main automatically open or refresh a Release Please proposal with -the next version and changelog. This proposal cannot create a GitHub release -or publish packages. It previews the next release as main continues changing. +Pushes to main automatically open or refresh a Release Please PR with the next +version and changelog. Review its migration notes and wait for its current CI +checks to pass. Merging that PR into main starts publication automatically. -Publication uses an explicit source checkpoint. A maintainer selects a frozen -release branch and dispatches preparation and publication, so main can keep -accepting changes without changing the candidate being tested. +The Release workflow validates the merged release metadata and ancestry before +creating a tag. npm and MCP Registry publication use the exact reviewed merge +SHA returned by Release Please. Later main commits cannot change that source. +Ordinary main pushes and closed, unmerged PRs cannot publish packages. Check +both publication jobs and the published-package canary; a merged PR or GitHub +tag alone does not establish that publication finished. Retry failed jobs in +the original run after a partial publication. -## 1. Select the source +For a release that needs an independently frozen application checkpoint, use +the optional manual path below. It retains explicit prepare and publish phases. + +## 1. Select a source for a manual checkpoint Use the next minor version proposed by Release Please, including releases with breaking changes. Record the full source SHA and create `release/VERSION` at that commit. The selected version and Release Please's proposed version must agree before publication. -Use the automatic main proposal to review the suggested version and notes. -Keep that proposal unmerged: select the application commit from main, create -the frozen branch, and prepare its own release PR using the steps below. -Merging the proposal into main would advance the release baseline before -publication. The reviewed frozen-branch PR is the publication candidate. +When choosing the manual path, leave the automatic main release PR unmerged. +Select the application commit from main, create the frozen branch, and prepare +its own release PR using the steps below. Merging the main release PR starts +automatic publication instead. Close the superseded main PR after the manual +release has published and its metadata has been synchronized back to main. `release/VERSION` also sets the expected version for the workflow. Use an exact SemVer such as `release/6.1.0` or `release/6.1.0-rc.1`, without build metadata. @@ -155,7 +162,7 @@ A GitHub tag alone does not establish npm or MCP Registry publication. ## 4. Sync metadata back to main -Post-release synchronization is part of completing the release. Finish it +Manual checkpoint synchronization is part of completing that release. Finish it before cutting the next checkpoint; otherwise main retains the previous release baseline and can propose an already-published version again. @@ -179,8 +186,8 @@ git merge-base --is-ancestor rea-agents-5.0.0 origin/main Close the superseded main proposal before merging the synchronization PR. That merge's main push opens a proposal for the following release using the -updated baseline. Future publication repeats the checkpoint procedure; -automatic proposals do not move frozen branches. +updated baseline. Future releases can use the automatic main PR or repeat the +manual checkpoint procedure; automatic proposals do not move frozen branches. Preparation generates and validates the catalog, portable conformance projections, and packaged skill from the candidate checkout without committing them. The checkpoint validator binds tracked version authority to the source diff --git a/scripts/lib/release-checkpoint.mjs b/scripts/lib/release-checkpoint.mjs index a8c8831df..82ebc6ae4 100644 --- a/scripts/lib/release-checkpoint.mjs +++ b/scripts/lib/release-checkpoint.mjs @@ -73,6 +73,14 @@ function readCommits(text) { breaking: /^[a-z]+(?:\([^)]*\))?!: /u.test(title) || /^BREAKING(?: CHANGE|-CHANGE):\s*\S/mu.test(body), + breakingNotes: Array.from( + body + .replace(/\r\n/gu, "\n") + .matchAll( + /^BREAKING(?: CHANGE|-CHANGE):[ \t]*(\S[^\n]*(?:\n(?!\n|[A-Za-z-]+: )[^\n]+)*)/gmu, + ), + (match) => match[1].replace(/\s+/gu, " ").trim(), + ), }); } return commits; @@ -207,8 +215,15 @@ async function validateCandidate(git, sha, version, commits) { version, ); const breakingNotes = breakingSection(notes); + const normalizedBreakingNotes = breakingNotes.replace(/\s+/gu, " "); const missingBreaking = commits.filter( - (commit) => commit.breaking && !referencesCommit(breakingNotes, commit), + (commit) => + commit.breaking && + !referencesCommit(breakingNotes, commit) && + (commit.breakingNotes.length === 0 || + commit.breakingNotes.some( + (note) => !normalizedBreakingNotes.includes(note), + )), ); if (missingBreaking.length > 0) { throw new Error( @@ -224,14 +239,24 @@ export async function inspectReleaseCheckpoint(git, options) { .object({ phase: z.enum(["prepare", "publish"]), stage: z.enum(["source", "candidate"]), - releaseBranch: z.string().startsWith("release/"), + releaseBranch: z.union([ + z.literal("main"), + z.string().startsWith("release/"), + ]), sourceSha: shaSchema, candidateSha: shaSchema.optional(), }) .parse(options); + if (releaseBranch === "main" && (phase !== "publish" || stage !== "source")) { + throw new Error( + "Main publication validates its reviewed merged source SHA.", + ); + } const expectedVersion = releaseVersion( - releaseBranch.slice("release/".length), - "release/VERSION", + releaseBranch === "main" + ? (await readJson(git, sourceSha, "package.json", versionSchema)).version + : releaseBranch.slice("release/".length), + releaseBranch === "main" ? "reviewed main release" : "release/VERSION", ); if (phase === "publish" && stage === "candidate") { throw new Error( diff --git a/tests/boundary/filesystem/releaseCheckpoint.test.ts b/tests/boundary/filesystem/releaseCheckpoint.test.ts index 8f720a22e..e7dc61e41 100644 --- a/tests/boundary/filesystem/releaseCheckpoint.test.ts +++ b/tests/boundary/filesystem/releaseCheckpoint.test.ts @@ -177,7 +177,7 @@ function verify( "--stage", stage, "--release-branch", - `release/${version}`, + version === "main" ? "main" : `release/${version}`, "--source-sha", f.sourceSha, ], @@ -295,6 +295,90 @@ it("still requires migration notes for breaking minor releases", async () => { }); }); +it.each([ + { + notes: + "### ⚠ BREAKING CHANGES\n\n* **contracts:** Use absolute MCP paths. Relative paths are rejected.", + accepted: true, + }, + { + notes: + "### ⚠ BREAKING CHANGES\n\n* **contracts:** Use absolute MCP paths.\n Relative paths are rejected.", + accepted: true, + }, + { + notes: "### ⚠ BREAKING CHANGES\n\n* Use absolute MCP paths.", + accepted: false, + }, + { + notes: + "### Bug Fixes\n\n* Use absolute MCP paths. Relative paths are rejected.", + accepted: false, + }, + { + notes: + "### ⚠ BREAKING CHANGES\n\n* Use absolute MCP paths. Relative paths are rejected.", + footer: + "BREAKING CHANGE: Use absolute MCP paths. Relative paths are rejected.\n\nBREAKING CHANGE: Rename the old CLI option.", + accepted: false, + }, + { + notes: + "### ⚠ BREAKING CHANGES\n\n* Use absolute MCP paths. Relative paths are rejected.\n* Rename the old CLI option.", + footer: + "BREAKING CHANGE: Use absolute MCP paths.\r\nRelative paths are rejected.\r\n\r\nBREAKING CHANGE: Rename the old CLI option.", + accepted: true, + }, +])( + "validates generated migration text on a main release merge ($accepted): $notes", + async ({ notes, accepted, footer }) => { + const f = await fixture( + "fix(contracts)!: change path inputs", + footer ?? + "BREAKING CHANGE: Use absolute MCP paths.\nRelative paths are rejected.", + { versioning: "always-bump-minor" }, + ); + const sourceSha = await candidate(f, "5.1.0", notes); + const result = verify({ ...f, sourceSha }, "main", "publish", "source"); + if (!accepted) { + await expect(result).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining( + "migration notes omit unreleased breaking changes", + ), + }); + return; + } + const original = JSON.parse((await result).stdout); + const report = reportSchema.parse(original); + expect(report).toMatchObject({ + expectedVersion: "5.1.0", + baselineVersion: "5.0.0", + applicationSha: f.sourceSha, + }); + await writeFile(join(f.directory, "later-work.txt"), "Later main change\n"); + await git(f.directory, ["add", "."]); + await git(f.directory, ["commit", "-m", "fix: unrelated later work"]); + // A moved checkout does not change the selected reviewed merge. + expect( + JSON.parse( + (await verify({ ...f, sourceSha }, "main", "publish", "source")).stdout, + ), + ).toEqual(original); + await expect( + verify( + { ...f, sourceSha: await git(f.directory, ["rev-parse", "HEAD"]) }, + "main", + "publish", + "source", + ), + ).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining("must advance published baseline"), + }); + }, +); + it("rejects a minor when a GitHub merge commit carries its bang marker in the PR title", async () => { const f = await fixture("fix(contracts)!: require absolute paths", "", { mergePullRequest: true, @@ -528,15 +612,21 @@ it("rejects a candidate from a different source even when versions and notes mat }); }); -it.skipIf(process.platform === "win32").each([true, false])( - "the actual publish preflight admits the next action only for valid metadata (%s)", - async (valid) => { +it.skipIf(process.platform === "win32").each([ + { job: "release-please", valid: true }, + { job: "release-please", valid: false }, + { job: "merged-release", valid: true }, + { job: "merged-release", valid: false }, +])( + "the actual $job preflight admits the next action only for valid metadata ($valid)", + async ({ job, valid }) => { const f = await fixture(); const sourceSha = await candidate(f, valid ? "6.0.0" : "5.1.0"); const workflow = z .object({ - jobs: z.object({ - "release-please": z.object({ + jobs: z.record( + z.string(), + z.object({ steps: z.array( z.object({ name: z.string().optional(), @@ -544,7 +634,7 @@ it.skipIf(process.platform === "win32").each([true, false])( }), ), }), - }), + ), }) .parse( parse( @@ -557,10 +647,12 @@ it.skipIf(process.platform === "win32").each([true, false])( const command = z .string() .parse( - workflow.jobs["release-please"].steps.find( + workflow.jobs[job]?.steps.find( (step) => step.name === - "Validate checkpoint version and ancestry before release creation", + (job === "merged-release" + ? "Validate reviewed merge before release creation" + : "Validate checkpoint version and ancestry before release creation"), )?.run, ); const marker = join(f.directory, "next-release-action"); @@ -582,6 +674,7 @@ it.skipIf(process.platform === "win32").each([true, false])( RELEASE_PHASE: "publish", RELEASE_BRANCH: "release/6.0.0", SOURCE_SHA: sourceSha, + GITHUB_SHA: sourceSha, NEXT_RELEASE_ACTION_MARKER: marker, }, }, @@ -592,7 +685,11 @@ it.skipIf(process.platform === "win32").each([true, false])( } else { await expect(execution).rejects.toMatchObject({ code: 1, - stderr: expect.stringContaining("release branch requires 6.0.0"), + stderr: expect.stringContaining( + job === "merged-release" + ? "contains unreleased breaking markers" + : "release branch requires 6.0.0", + ), }); await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" }); } diff --git a/tests/boundary/filesystem/releaseWorkflow.test.ts b/tests/boundary/filesystem/releaseWorkflow.test.ts index aff2fbb7b..faf65ff35 100644 --- a/tests/boundary/filesystem/releaseWorkflow.test.ts +++ b/tests/boundary/filesystem/releaseWorkflow.test.ts @@ -10,6 +10,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const execFileAsync = promisify(execFile); const stepSchema = z.object({ + id: z.string().optional(), name: z.string().optional(), uses: z.string().optional(), run: z.string().optional(), @@ -19,6 +20,7 @@ const stepSchema = z.object({ }); const jobSchema = z.object({ if: z.string().optional(), + needs: z.union([z.string(), z.array(z.string())]).optional(), outputs: z.record(z.string(), z.string()).optional(), steps: z.array(stepSchema), }); @@ -31,6 +33,7 @@ async function readReleaseWorkflow() { jobs: z.object({ "release-proposal": jobSchema, "release-please": jobSchema, + "merged-release": jobSchema, publish: jobSchema, "publish-mcp": jobSchema, }), @@ -82,9 +85,13 @@ it("validates the source before release creation and the prepared candidate befo } }); -it("refreshes a release proposal on main pushes without creating or publishing a release", async () => { +it("keeps main pushes proposal-only and gates merged-release automation", async () => { const workflow = await readReleaseWorkflow(); expect(workflow.on.push).toEqual({ branches: ["main"] }); + expect(workflow.on.pull_request).toMatchObject({ + types: ["closed"], + branches: ["main"], + }); const proposal = workflow.jobs["release-proposal"]; expect(proposal.if).toBe("github.event_name == 'push'"); const action = proposal.steps.find((step) => @@ -96,17 +103,52 @@ it("refreshes a release proposal on main pushes without creating or publishing a "skip-github-release": true, }); expect(action?.with?.["skip-github-pull-request"]).not.toBe(true); + const merged = workflow.jobs["merged-release"]; + expect(merged.if).toContain("github.event.pull_request.merged == true"); + expect(merged.if).toContain("github.repository"); + expect(merged.if).toContain("release-please--branches--main--"); + expect(merged.outputs).toEqual({ + release_created: "${{ steps.release.outputs.release_created }}", + sha: "${{ steps.release.outputs.sha }}", + }); expect(workflow.jobs["release-please"].if).toBe( "github.event_name == 'workflow_dispatch'", ); - for (const job of [workflow.jobs.publish, workflow.jobs["publish-mcp"]]) { - expect(job.if).toContain("inputs.phase == 'publish'"); - } expect(workflow.concurrency.group).toBe( - "release-${{ inputs.release_branch || github.ref_name }}", + "release-${{ inputs.release_branch || github.event.pull_request.merge_commit_sha || github.ref_name }}", ); }); +it("validates the reviewed merge before creating its release", async () => { + const workflow = await readReleaseWorkflow(); + const steps = workflow.jobs["merged-release"].steps; + const checkout = steps.find((step) => + step.uses?.startsWith("actions/checkout@"), + ); + expect(checkout?.with).toMatchObject({ + ref: "${{ github.event.pull_request.merge_commit_sha }}", + "fetch-depth": 0, + }); + const checkpoint = steps.find((step) => + step.run?.includes("verify-release-checkpoint.mjs"), + ); + expect(checkpoint?.run).toContain("--phase publish --release-branch main"); + expect(checkpoint?.run).toContain('--source-sha "$SOURCE_SHA"'); + const release = steps.find((step) => step.id === "release"); + expect(release?.with).toMatchObject({ + "target-branch": "main", + "skip-github-release": false, + "skip-github-pull-request": true, + }); + const bind = steps.find( + (step) => step.name === "Bind publication to the reviewed merge", + ); + expect(bind?.env).toMatchObject({ + SOURCE_SHA: "${{ github.event.pull_request.merge_commit_sha }}", + RELEASE_SHA: "${{ steps.release.outputs.sha }}", + }); +}); + it("keeps frozen candidate preparation and publication explicit", async () => { const workflow = await readReleaseWorkflow(); expect(workflow.on.workflow_dispatch).toMatchObject({ @@ -209,18 +251,21 @@ it("binds npm and MCP publication to the same immutable release SHA", async () = release_created: "${{ steps.release.outputs.release_created }}", sha: "${{ steps.release.outputs.sha }}", }); - expect(workflow.jobs.publish.if).toBe( - "inputs.phase == 'publish' && needs.release-please.outputs.release_created == 'true'", + expect(workflow.jobs.publish.if).toContain("inputs.phase == 'publish'"); + expect(workflow.jobs.publish.if).toContain( + "needs.release-please.outputs.release_created", ); - expect(workflow.jobs["publish-mcp"].if).toBe( - "inputs.phase == 'publish' && needs.release-please.outputs.release_created == 'true' && needs.publish.result == 'success'", + expect(workflow.jobs["publish-mcp"].if).toContain( + "needs.publish.result == 'success'", ); + expect(workflow.jobs.publish.needs).toContain("merged-release"); + expect(workflow.jobs["publish-mcp"].needs).toContain("merged-release"); for (const job of [workflow.jobs.publish, workflow.jobs["publish-mcp"]]) { const checkout = job.steps.find((step) => step.uses?.startsWith("actions/checkout@"), ); expect(checkout?.with).toMatchObject({ - ref: "${{ needs.release-please.outputs.sha }}", + ref: "${{ needs.merged-release.outputs.sha || needs.release-please.outputs.sha }}", "persist-credentials": false, }); } @@ -262,6 +307,58 @@ it("binds npm and MCP publication to the same immutable release SHA", async () = } }); +it.skipIf(process.platform === "win32").each([ + { + created: "false", + releaseSha: "1111111111111111111111111111111111111111", + succeeds: false, + reason: "did not create a release", + }, + { + created: "true", + releaseSha: "1111111111111111111111111111111111111111", + succeeds: true, + }, + { + created: "true", + releaseSha: "2222222222222222222222222222222222222222", + succeeds: false, + reason: "refusing mismatched publication provenance", + }, +])( + "publishes a merged release only when Release Please creates it at the reviewed SHA: $created/$releaseSha", + async ({ created, releaseSha, succeeds, reason }) => { + const workflow = await readReleaseWorkflow(); + const command = z + .string() + .parse( + workflow.jobs["merged-release"].steps.find( + (step) => step.name === "Bind publication to the reviewed merge", + )?.run, + ); + const result = execFileAsync( + "bash", + ["-e", "-o", "pipefail", "-c", command], + { + env: { + ...process.env, + SOURCE_SHA: "1111111111111111111111111111111111111111", + RELEASE_CREATED: created, + RELEASE_SHA: releaseSha, + }, + }, + ); + if (succeeds) { + await expect(result).resolves.toMatchObject({ stderr: "" }); + } else { + await expect(result).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining(z.string().parse(reason)), + }); + } + }, +); + it .skipIf(process.platform === "win32") .each(["main", "feature/new-work", "release/", "release/../main"])(