fix(release): publish reviewed release PR merges
This commit is contained in:
parent
70e5c24718
commit
c12bad7f13
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"])(
|
||||
|
|
|
|||
Loading…
Reference in New Issue