fix(release): publish from explicit source checkpoints (#1006)

* fix(release): publish from explicit source checkpoints

Dispatch preparation and publication against a frozen release branch.
Bind package builds and provenance to the same tagged commit, enable
release-branch PR CI, and document the maintainer workflow.

Assisted-by: Sakuya (gensokyo-skills:sakuya-checkpointed-execution)

* fix(release): stop publication when the checkpoint has moved

Reject unknown phases and a release-branch tip that differs from the
dispatch SHA before Release Please creates a tag. Keep registry jobs and
catalog pushes on their own phase, and run website checks for release PRs.

* fix(release): require a token that can trigger release CI

Stop falling back to GITHUB_TOKEN, which cannot start workflow runs for the release pull request it creates.

* test(release): require the CI-capable release token

Lock the workflow to RELEASE_PLEASE_TOKEN and fail preparation when that secret is missing.

* style(release): format the release-token test

---------

Co-authored-by: N0zoM1z0 <[email protected]>
Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
morluto
2026-10-08 01:15:54 +08:00
committed by GitHub
co-authored by N0zoM1z0 Cursor Agent
parent 64c71880ae
commit b4fc0ecaa1
5 changed files with 327 additions and 120 deletions
+85 -28
View File
@@ -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
+1 -1
View File
@@ -2,7 +2,7 @@ name: Website checks
on:
pull_request:
branches: [main]
branches: [main, "release/*"]
paths:
- "website/**"
- "scripts/verify-website.py"
+12 -10
View File
@@ -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
<https://morluto.github.io/rea/>. 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
<https://morluto.github.io/rea/> 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.
+51 -36
View File
@@ -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.
+178 -45
View File
@@ -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 () => {