diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0680946b1..6be8836ab 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,15 +1,24 @@
name: Release
on:
- push:
- branches: ["release/*"]
workflow_dispatch:
+ inputs:
+ release_branch:
+ description: Frozen release branch (release/VERSION)
+ required: true
+ type: string
+ phase:
+ description: Prepare the release PR, or publish its reviewed merge
+ required: true
+ default: prepare
+ type: choice
+ options: [prepare, publish]
env:
HUSKY: "0"
concurrency:
- group: release-${{ github.ref }}
+ group: release-${{ inputs.release_branch }}
cancel-in-progress: false
jobs:
@@ -21,77 +30,125 @@ jobs:
pull-requests: write
outputs:
release_created: ${{ steps.release.outputs.release_created }}
- tag_name: ${{ steps.release.outputs.tag_name }}
sha: ${{ steps.release.outputs.sha }}
steps:
+ - name: Require a CI-capable release token
+ shell: bash
+ env:
+ RELEASE_PLEASE_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
+ run: |
+ if [[ -z "${RELEASE_PLEASE_TOKEN}" ]]; then
+ echo "RELEASE_PLEASE_TOKEN is required. Pull requests and catalog commits created with GITHUB_TOKEN do not start workflow runs, so the normalized release head would have no CI result to approve." >&2
+ exit 1
+ fi
+
- name: Validate release selection
shell: bash
env:
- GH_TOKEN: ${{ github.token }}
- RELEASE_BRANCH: ${{ github.ref_name }}
+ RELEASE_BRANCH: ${{ inputs.release_branch }}
+ RELEASE_PHASE: ${{ inputs.phase }}
run: |
- if [[ "${GITHUB_REF}" != "refs/heads/${RELEASE_BRANCH}" || "${RELEASE_BRANCH}" != release/* ]]; then
- echo "Select a release/ branch; main and tags are not release checkpoints." >&2
+ if [[ "${RELEASE_PHASE}" != prepare && "${RELEASE_PHASE}" != publish ]]; then
+ echo "phase must be prepare or publish." >&2
+ exit 1
+ fi
+ if [[ "${RELEASE_PHASE}" == prepare && "${GITHUB_REF}" != "refs/heads/main" ]]; then
+ echo "Run prepare from main; release_branch selects the frozen source." >&2
+ exit 1
+ fi
+ if [[ "${RELEASE_PHASE}" == publish && "${GITHUB_REF}" != "refs/heads/${RELEASE_BRANCH}" ]]; then
+ echo "Run publish from the selected frozen release branch so npm provenance records its source." >&2
+ exit 1
+ fi
+ if [[ ! "${RELEASE_BRANCH}" =~ ^release/[^/]+$ ]]; then
+ echo "release_branch must select a release/ branch; main is not a release checkpoint." >&2
exit 1
fi
if ! git check-ref-format "refs/heads/${RELEASE_BRANCH}"; then
- echo "Invalid release branch: ${RELEASE_BRANCH}" >&2
+ echo "Invalid release_branch: ${RELEASE_BRANCH}" >&2
exit 1
fi
- selected_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${RELEASE_BRANCH}" --jq '.object.sha')"
- if [[ "${selected_sha}" != "${GITHUB_SHA}" ]]; then
- echo "The release branch moved after this run was triggered; review its new checkpoint before publication." >&2
+
+ - name: Resolve release branch tip
+ id: branch
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RELEASE_BRANCH: ${{ inputs.release_branch }}
+ run: |
+ sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${RELEASE_BRANCH}" --jq '.object | select(.type == "commit") | .sha')"
+ if [[ ! "${sha}" =~ ^[0-9a-f]{40}$ ]]; then
+ echo "The release branch tip is not a commit." >&2
+ exit 1
+ fi
+ echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
+
+ - name: Require the publish dispatch to match the branch tip
+ if: inputs.phase == 'publish'
+ shell: bash
+ env:
+ BRANCH_SHA: ${{ steps.branch.outputs.sha }}
+ run: |
+ if [[ "${BRANCH_SHA}" != "${GITHUB_SHA}" ]]; then
+ echo "The release branch tip differs from the dispatched checkpoint; refusing to create a release for a moved source." >&2
exit 1
fi
- printf 'Selected release checkpoint: %s\n' "${selected_sha}"
- uses: googleapis/release-please-action@v4
id: release
with:
- # A dedicated credential lets release-PR updates trigger CI. GitHub
- # requires manual approval for pull_request events from GITHUB_TOKEN.
- token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
- target-branch: ${{ github.ref_name }}
+ # A dedicated credential lets release-PR updates trigger CI.
+ # GITHUB_TOKEN-authored pull requests do not start workflow runs.
+ token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
+ target-branch: ${{ inputs.release_branch }}
+ skip-github-release: ${{ inputs.phase == 'prepare' }}
+ skip-github-pull-request: ${{ inputs.phase == 'publish' }}
- - name: Bind publication to the triggering checkpoint
- if: steps.release.outputs.release_created == 'true'
+ - name: Require a merged release PR for publication
+ if: inputs.phase == 'publish' && steps.release.outputs.release_created != 'true'
+ run: |
+ echo "No new release was created. Merge the reviewed release PR into its frozen branch before publishing. For a partial publication, re-run failed jobs in the original run." >&2
+ exit 1
+
+ - name: Bind publication to the dispatched checkpoint
+ if: inputs.phase == 'publish' && steps.release.outputs.release_created == 'true'
shell: bash
env:
RELEASE_SHA: ${{ steps.release.outputs.sha }}
run: |
if [[ "${RELEASE_SHA}" != "${GITHUB_SHA}" ]]; then
- echo "The tagged release SHA differs from the triggering checkpoint; refusing to publish mismatched source provenance." >&2
+ echo "The tagged release SHA differs from the dispatched checkpoint; refusing to publish mismatched source provenance. The GitHub release already exists; do not dispatch publish again." >&2
exit 1
fi
- name: Check out release pull request
- if: steps.release.outputs.prs_created == 'true'
+ if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
uses: actions/checkout@v6
with:
ref: ${{ fromJSON(steps.release.outputs.pr).headBranchName }}
persist-credentials: false
- name: Set up Node.js for generated documentation
- if: steps.release.outputs.prs_created == 'true'
+ if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
package-manager-cache: false
- name: Install dependencies
- if: steps.release.outputs.prs_created == 'true'
+ if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
run: npm ci
- name: Regenerate release documentation
- if: steps.release.outputs.prs_created == 'true'
+ if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
run: npm run docs:generate
- name: Commit canonical release catalog
- if: steps.release.outputs.prs_created == 'true'
+ if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
shell: bash
env:
# Use the same identity for the follow-up commit so it also triggers CI.
- GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
+ GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
RELEASE_PR_JSON: ${{ steps.release.outputs.pr }}
run: |
release_pr_branch="$(node --input-type=module -e '
@@ -127,7 +184,7 @@ jobs:
publish:
name: Publish to npm
needs: release-please
- if: needs.release-please.outputs.release_created == 'true'
+ if: inputs.phase == 'publish' && needs.release-please.outputs.release_created == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
@@ -198,7 +255,7 @@ jobs:
publish-mcp:
name: Publish to MCP Registry
needs: [release-please, publish]
- if: needs.release-please.outputs.release_created == 'true' && needs.publish.result == 'success'
+ if: inputs.phase == 'publish' && needs.release-please.outputs.release_created == 'true' && needs.publish.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
diff --git a/.github/workflows/website-check.yml b/.github/workflows/website-check.yml
index 4f410ef62..2fc7a6874 100644
--- a/.github/workflows/website-check.yml
+++ b/.github/workflows/website-check.yml
@@ -2,7 +2,7 @@ name: Website checks
on:
pull_request:
- branches: [main]
+ branches: [main, "release/*"]
paths:
- "website/**"
- "scripts/verify-website.py"
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 796b7cf22..36d442515 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -28,19 +28,21 @@ Keep dependencies flowing inward through the existing domain, contracts, provide
## Documentation website
-The VitePress site uses the Markdown files in `docs/` and deploys to
-. Run `npm run docs:dev` for live editing,
-`npm run docs:build` to check the production build and links, and
-`npm run docs:preview` to preview that build at `/rea/`.
+The VitePress site uses the Markdown files in `docs/`. Run `npm run docs:dev`
+for live editing, `npm run docs:build` to check the production build and
+links, and `npm run docs:preview` to preview that build at `/rea/`.
Site navigation lives in `docs/.vitepress/config.ts`. Keep links to guides
relative so they work on GitHub and the website; link to repository files
outside `docs/` using their full GitHub URLs. Generated reference documents
still use `npm run docs:generate`; `docs:build` only builds the website.
-The documentation workflow builds pull requests and deploys changes on `main`.
-The repository's **Settings → Pages → Build and deployment → Source** must be
-set to **GitHub Actions** before the first deployment.
+Pull requests run `npm run docs:check`. `.github/workflows/pages.yml` is a
+manual VitePress build and does not publish. The public site at
+ is published only by the manual website
+workflow on `main`. The repository's **Settings → Pages → Build and
+deployment → Source** must be set to **GitHub Actions** before the first
+deployment.
## Development feedback and PR verification
@@ -121,9 +123,9 @@ Describe the behavior change and verification performed in the pull request. Nev
## Maintainer release checklist
Use the [checkpoint release guide](docs/releasing.md). Releases start from an
-explicit `release/VERSION` branch cut at a recorded commit. Pushing the branch prepares
-the bot PR. Wait for its exact-head CI and review; merging into that frozen
-branch triggers publication through the official Release workflow. Main
+explicit `release/VERSION` branch cut at a recorded commit. Manually prepare
+the 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 do not update release PRs or publish packages. Both publishers build
the exact SHA tagged by Release Please.
diff --git a/docs/releasing.md b/docs/releasing.md
index fe453323c..5e8c977c5 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -1,10 +1,10 @@
# Releasing from a checkpoint
-REA releases use a frozen source checkpoint. Main can continue accepting
+REA releases use an explicit source checkpoint. Main can continue accepting
changes while a release is tested and published. The Release workflow runs
-on `release/*` branches; main pushes do not refresh release PRs.
+only when a maintainer dispatches it; main pushes do not refresh release PRs.
-## 1. Cut the release branch
+## 1. Select the source
Choose the next version from the unreleased Conventional Commits, including
breaking changes. Record the full source SHA and create `release/VERSION` at
@@ -25,43 +25,54 @@ older checkpoint, backport only the release infrastructure first and record
that additional commit. Do not merge later implementation changes into the
release branch or rebase the candidate onto a moving main.
-The branch push runs Release Please against that branch and opens a PR for
-the version, changelog, and registry metadata. The generation step normalizes
-the product catalog. To retry preparation after a failed run, select the same
-frozen branch explicitly:
+## 2. Prepare the bot PR
+
+Run the workflow definition from main and select the frozen source separately:
```bash
-gh workflow run release.yml --ref release/5.0.0
+gh workflow run release.yml --ref main \
+ -f release_branch=release/5.0.0 -f phase=prepare
```
-## 2. Review and merge the bot PR
+Release Please targets that branch and prepares its version, changelog, and
+registry metadata. Preparation requires the `RELEASE_PLEASE_TOKEN` repository
+secret. That credential opens the release pull request and pushes the catalog
+normalization commit, so both start CI on the exact normalized head. The
+default `GITHUB_TOKEN` cannot do that: events it authors do not start
+workflow runs, and there is then no blocked run to approve. The existing
+generation step normalizes the product catalog. Preparation cannot create a
+GitHub release or publish a package. Record the final bot PR head after
+normalization and wait for that head's CI.
-Record the final bot PR head after normalization. Approve GitHub-blocked bot
-workflow runs for that head when needed. Review the candidate's version,
-notes, generated metadata, and package contents. Wait for the candidate's CI
-and relevant real-provider checks. Routine local iterations need focused
-checks; CI owns full deterministic coverage and platform lanes. If an artifact
-or real-provider check is unavailable, report that limit before deciding to
-publish.
+Review the candidate's version, notes, generated metadata, and package
+contents. Wait for the candidate's CI and relevant real-provider checks.
+Routine local iterations need focused checks; CI owns full deterministic
+coverage and platform lanes. If an artifact or real-provider check is
+unavailable, report that limit before deciding to publish.
-Merge the reviewed PR into `release/5.0.0` with its head SHA matched. **This
-merge authorizes and triggers publication.** Further main commits do not change
-this candidate. Do not push or merge anything else into the release branch
-while publication is running. A necessary release fix must be reviewed and
-tested before the bot PR merges and establishes a new recorded checkpoint.
+Merge the reviewed PR into `release/5.0.0` with its head SHA matched. Further
+main commits do not change this candidate. A necessary release fix belongs
+on the release branch, must be reviewed and tested, and establishes a new
+recorded checkpoint.
-## 3. Verify publication
+## 3. Publish the reviewed merge
-The merge push runs Release Please again, creates the tag and GitHub release,
-and publishes npm followed by MCP Registry metadata. Both publishers check
-out Release Please's exact release SHA. The triggering workflow also runs
-from that release commit so npm's provenance records the actual source.
+After the release PR has merged:
-Before invoking Release Please, the workflow checks that the release branch
-still equals the triggering SHA and rejects main or tag refs. Before either
-registry publish, it rejects a mismatch between the tagged release SHA and
-the triggering SHA. These checks detect moved branches; maintainers must also
-keep the release branch frozen throughout publication.
+```bash
+gh workflow run release.yml --ref release/5.0.0 \
+ -f release_branch=release/5.0.0 -f phase=publish
+```
+
+Publication creates the release from the merged bot PR without preparing or
+updating another PR. Both npm and MCP Registry jobs check out Release Please's
+exact release SHA. They do not build the current main tip or a mutable branch.
+The publish dispatch runs from the frozen release branch so npm's provenance
+records the actual release commit. Before Release Please creates a tag, the
+workflow accepts only `prepare` or `publish` and requires the selected branch
+tip to equal the dispatch SHA. Registry jobs run only during `publish`. After
+a tag exists, a mismatch between that tag's SHA and the dispatch SHA stops
+both registry publishes.
See [npm's provenance implementation](https://github.com/npm/cli/blob/v11.16.0/workspaces/libnpmpublish/lib/provenance.js)
for the use of GitHub's workflow ref and commit SHA.
@@ -78,7 +89,7 @@ Record these outcomes separately:
A GitHub tag alone does not establish npm or MCP Registry publication.
-## Sync metadata back to main
+## 4. Sync metadata back to main
After publication, open a PR from the release branch back to main. Preserve
main's later implementation changes and resolve generated-file conflicts by
@@ -88,6 +99,9 @@ tag remains in main's ancestry. Keep the released tag unchanged.
Close any superseded rolling release PR. Future releases repeat the checkpoint
procedure from main; never resume automatic release-PR refreshes on main pushes.
+The generated-metadata workflow stays limited to pull requests into main so it
+does not push commits onto a frozen candidate. Preparation normalizes
+`docs/product-catalog.json` on the bot pull request.
## Partial publication and retries
@@ -97,7 +111,8 @@ publishing it again. Re-run failed jobs in the original publication run so its
release SHA and outputs stay fixed. If only MCP publication failed, retry that
job after checking that the npm canary succeeded.
-Do not start a fresh workflow run to repair an already-created release:
-Release Please will not create the same release again. Do not move the tag,
-delete the release, or unpublish npm as a retry. A defective public package
-requires a reviewed correction and a new version.
+A branch tip that moved after dispatch fails before Release Please creates a
+tag. Do not dispatch a fresh publish phase to repair an already-created
+release: Release Please will not create the same release again. Do not move
+the tag, delete the release, or unpublish npm as a retry. A defective public
+package requires a reviewed correction and a new version.
diff --git a/tests/boundary/filesystem/releaseWorkflow.test.ts b/tests/boundary/filesystem/releaseWorkflow.test.ts
index 177514cef..905b0541c 100644
--- a/tests/boundary/filesystem/releaseWorkflow.test.ts
+++ b/tests/boundary/filesystem/releaseWorkflow.test.ts
@@ -1,21 +1,21 @@
import { execFile } from "node:child_process";
-import { readFile, writeFile } from "node:fs/promises";
-import { delimiter, join } from "node:path";
+import { readFile } from "node:fs/promises";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { parse } from "yaml";
import { z } from "zod";
-import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
-
const execFileAsync = promisify(execFile);
const stepSchema = z.object({
name: z.string().optional(),
uses: z.string().optional(),
run: z.string().optional(),
+ if: z.string().optional(),
with: z.record(z.string(), z.unknown()).optional(),
+ env: z.record(z.string(), z.unknown()).optional(),
});
const jobSchema = z.object({
+ if: z.string().optional(),
outputs: z.record(z.string(), z.string()).optional(),
steps: z.array(stepSchema),
});
@@ -40,23 +40,67 @@ async function readReleaseWorkflow() {
);
}
-it("prepares and publishes only from frozen release branches", async () => {
+it("requires explicit release preparation or publication instead of main pushes", async () => {
const workflow = await readReleaseWorkflow();
- expect(workflow.on.push).toEqual({ branches: ["release/*"] });
- expect(workflow.on).toHaveProperty("workflow_dispatch");
- expect(workflow.on.workflow_dispatch).toBeNull();
+ expect(Object.keys(workflow.on)).toEqual(["workflow_dispatch"]);
+ expect(workflow.on.workflow_dispatch).toMatchObject({
+ inputs: {
+ release_branch: { required: true, type: "string" },
+ phase: {
+ required: true,
+ type: "choice",
+ default: "prepare",
+ options: ["prepare", "publish"],
+ },
+ },
+ });
const release = workflow.jobs["release-please"].steps.find((step) =>
step.uses?.startsWith("googleapis/release-please-action@"),
);
- expect(release?.with?.["target-branch"]).toBe("${{ github.ref_name }}");
- expect(release?.with).not.toHaveProperty("skip-github-release");
- expect(release?.with).not.toHaveProperty("skip-github-pull-request");
+ expect(release?.with).toMatchObject({
+ token: "${{ secrets.RELEASE_PLEASE_TOKEN }}",
+ "target-branch": "${{ inputs.release_branch }}",
+ "skip-github-release": "${{ inputs.phase == 'prepare' }}",
+ "skip-github-pull-request": "${{ inputs.phase == 'publish' }}",
+ });
+ const catalogCommit = workflow.jobs["release-please"].steps.find(
+ (step) => step.name === "Commit canonical release catalog",
+ );
+ expect(catalogCommit?.env?.GH_TOKEN).toBe(
+ "${{ secrets.RELEASE_PLEASE_TOKEN }}",
+ );
+});
+
+it("stops preparation when the CI-capable release token is missing", async () => {
+ const workflow = await readReleaseWorkflow();
+ const command = z
+ .string()
+ .parse(
+ workflow.jobs["release-please"].steps.find(
+ (step) => step.name === "Require a CI-capable release token",
+ )?.run,
+ );
+ await expect(
+ execFileAsync("bash", ["-e", "-o", "pipefail", "-c", command], {
+ env: { ...process.env, RELEASE_PLEASE_TOKEN: "" },
+ }),
+ ).rejects.toMatchObject({
+ code: 1,
+ stderr: expect.stringMatching(/RELEASE_PLEASE_TOKEN/u),
+ });
});
it("binds npm and MCP publication to the same immutable release SHA", async () => {
const workflow = await readReleaseWorkflow();
- expect(workflow.jobs["release-please"].outputs?.sha).toBe(
- "${{ steps.release.outputs.sha }}",
+ expect(workflow.jobs["release-please"].outputs).toEqual({
+ 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-mcp"].if).toBe(
+ "inputs.phase == 'publish' && needs.release-please.outputs.release_created == 'true' && needs.publish.result == 'success'",
);
for (const job of [workflow.jobs.publish, workflow.jobs["publish-mcp"]]) {
const checkout = job.steps.find((step) =>
@@ -67,6 +111,21 @@ it("binds npm and MCP publication to the same immutable release SHA", async () =
"persist-credentials": false,
});
}
+ const preparation = [
+ "Check out release pull request",
+ "Set up Node.js for generated documentation",
+ "Install dependencies",
+ "Regenerate release documentation",
+ "Commit canonical release catalog",
+ ];
+ for (const name of preparation) {
+ expect(
+ workflow.jobs["release-please"].steps.find((step) => step.name === name)
+ ?.if,
+ ).toBe(
+ "inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'",
+ );
+ }
});
it
@@ -86,26 +145,25 @@ it
execFileAsync("bash", ["-e", "-o", "pipefail", "-c", command], {
env: {
...process.env,
- GITHUB_REF: `refs/heads/${releaseBranch}`,
+ GITHUB_REF: "refs/heads/main",
RELEASE_BRANCH: releaseBranch,
+ RELEASE_PHASE: "prepare",
},
}),
).rejects.toMatchObject({
code: 1,
- stderr: expect.stringMatching(/release.*branch/u),
+ stderr: expect.stringMatching(/release_branch/u),
});
},
);
-it
- .skipIf(process.platform === "win32")
- .each([
- "refs/heads/main",
- "refs/tags/release/5.0.0",
- "refs/heads/release/another-version",
- ])(
- "rejects a ref that does not select the release branch: %s",
- async (ref) => {
+it.skipIf(process.platform === "win32").each([
+ { phase: "prepare", ref: "refs/heads/feature/unreviewed-workflow" },
+ { phase: "publish", ref: "refs/heads/main" },
+ { phase: "publish", ref: "refs/heads/release/another-version" },
+])(
+ "rejects $phase from $ref before contacting GitHub",
+ async ({ phase, ref }) => {
const workflow = await readReleaseWorkflow();
const command = z
.string()
@@ -120,18 +178,56 @@ it
...process.env,
GITHUB_REF: ref,
RELEASE_BRANCH: "release/5.0.0",
+ RELEASE_PHASE: phase,
},
}),
).rejects.toMatchObject({
code: 1,
- stderr: expect.stringContaining("Select a release/ branch"),
+ stderr: expect.stringContaining(`Run ${phase} from`),
});
},
);
-it.skipIf(process.platform === "win32").each([true, false])(
- "checks the release branch still matches its triggering commit before tagging (match: %s)",
- async (match) => {
+it.skipIf(process.platform === "win32")(
+ "checks the checkpoint locally and resolves the branch tip separately",
+ async () => {
+ const workflow = await readReleaseWorkflow();
+ const validate = z
+ .string()
+ .parse(
+ workflow.jobs["release-please"].steps.find(
+ (step) => step.name === "Validate release selection",
+ )?.run,
+ );
+ expect(validate).not.toMatch(/\bgh\b/u);
+ const resolve = z
+ .string()
+ .parse(
+ workflow.jobs["release-please"].steps.find(
+ (step) => step.name === "Resolve release branch tip",
+ )?.run,
+ );
+ expect(resolve).toContain("gh api");
+ for (const releaseBranch of ["release/5.0.0", "release/5.0.0-rc.1"]) {
+ await expect(
+ execFileAsync("bash", ["-e", "-o", "pipefail", "-c", validate], {
+ env: {
+ ...process.env,
+ GITHUB_REF: "refs/heads/main",
+ RELEASE_BRANCH: releaseBranch,
+ RELEASE_PHASE: "prepare",
+ },
+ }),
+ ).resolves.toMatchObject({ stderr: "" });
+ }
+ },
+);
+
+it
+ .skipIf(process.platform === "win32")
+ .each(["", "release", "prepare ", "true"])(
+ "rejects phase %j before contacting GitHub",
+ async (phase) => {
const workflow = await readReleaseWorkflow();
const command = z
.string()
@@ -140,39 +236,55 @@ it.skipIf(process.platform === "win32").each([true, false])(
(step) => step.name === "Validate release selection",
)?.run,
);
- const directory = await createTestTempDirectory("rea-release-selection-");
- await writeFile(
- join(directory, "gh"),
- '#!/bin/sh\nprintf "%s\\n" "$TEST_RELEASE_REF_SHA"\n',
- { mode: 0o755 },
- );
+ await expect(
+ execFileAsync("bash", ["-e", "-o", "pipefail", "-c", command], {
+ env: {
+ ...process.env,
+ GITHUB_REF: "refs/heads/main",
+ RELEASE_BRANCH: "release/5.0.0",
+ RELEASE_PHASE: phase,
+ },
+ }),
+ ).rejects.toMatchObject({
+ code: 1,
+ stderr: expect.stringContaining("phase must be prepare or publish"),
+ });
+ },
+);
+
+it.skipIf(process.platform === "win32").each([true, false])(
+ "creates a release only when the branch tip is the dispatched checkpoint (match: %s)",
+ async (match) => {
+ const workflow = await readReleaseWorkflow();
+ const command = z
+ .string()
+ .parse(
+ workflow.jobs["release-please"].steps.find(
+ (step) =>
+ step.name ===
+ "Require the publish dispatch to match the branch tip",
+ )?.run,
+ );
const result = execFileAsync(
"bash",
["-e", "-o", "pipefail", "-c", command],
{
env: {
...process.env,
- PATH: `${directory}${delimiter}${process.env.PATH ?? ""}`,
- GITHUB_REF: "refs/heads/release/5.0.0",
- RELEASE_BRANCH: "release/5.0.0",
- GITHUB_REPOSITORY: "fixture/fixture",
GITHUB_SHA: "1111111111111111111111111111111111111111",
- TEST_RELEASE_REF_SHA: match
+ BRANCH_SHA: match
? "1111111111111111111111111111111111111111"
: "2222222222222222222222222222222222222222",
},
},
);
if (match) {
- await expect(result).resolves.toMatchObject({
- stdout: expect.stringContaining("Selected release checkpoint:"),
- stderr: "",
- });
+ await expect(result).resolves.toMatchObject({ stderr: "" });
} else {
await expect(result).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining(
- "release branch moved after this run was triggered",
+ "refusing to create a release for a moved source",
),
});
}
@@ -188,7 +300,7 @@ it.skipIf(process.platform === "win32").each([true, false])(
.parse(
workflow.jobs["release-please"].steps.find(
(step) =>
- step.name === "Bind publication to the triggering checkpoint",
+ step.name === "Bind publication to the dispatched checkpoint",
)?.run,
);
const result = execFileAsync(
@@ -239,6 +351,27 @@ it("runs release PR CI without enabling implementation pushes on release branche
);
});
+it("checks website changes on release-branch pull requests", async () => {
+ const workflow = z
+ .object({
+ on: z.object({
+ pull_request: z.object({ branches: z.array(z.string()) }),
+ }),
+ })
+ .parse(
+ parse(
+ await readFile(
+ new URL(
+ "../../../.github/workflows/website-check.yml",
+ import.meta.url,
+ ),
+ "utf8",
+ ),
+ ),
+ );
+ expect(workflow.on.pull_request.branches).toEqual(["main", "release/*"]);
+});
+
// Publishing is irreversible. Keep the release authority invariant as a static
// boundary check; it is not proof that package installation or publishing works.
it("requires package verification before publishing and retains the registry canary", async () => {