fix(release): publish reviewed release PR merges

This commit is contained in:
morluto 2026-10-09 08:44:47 +08:00
parent 70e5c24718
commit c12bad7f13
6 changed files with 352 additions and 57 deletions

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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(

View File

@ -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" });
}

View File

@ -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"])(