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

* 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): drive checkpoint releases from frozen branches

Use branch pushes for Release Please preparation and reviewed-merge publication.
Reject moved source refs before tagging and bind registry provenance to the
triggering release commit. Remove phase inputs and document the three steps.

Assisted-by: Sakuya (gensokyo-skills:sakuya-checkpointed-execution)
Assisted-by: Cirno (gensokyo-skills:cirno-radical-simplification)
This commit is contained in:
Ysopwnable 2026-10-08 01:12:18 +08:00 committed by GitHub
parent fe64fd65d9
commit 64c71880ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 392 additions and 32 deletions

View File

@ -4,7 +4,7 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
branches: [main, "release/*"]
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}

View File

@ -2,7 +2,8 @@ name: Release
on:
push:
branches: [main]
branches: ["release/*"]
workflow_dispatch:
env:
HUSKY: "0"
@ -21,13 +22,47 @@ jobs:
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
sha: ${{ steps.release.outputs.sha }}
steps:
- name: Validate release selection
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_BRANCH: ${{ github.ref_name }}
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
exit 1
fi
if ! git check-ref-format "refs/heads/${RELEASE_BRANCH}"; then
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
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 }}
- name: Bind publication to the triggering checkpoint
if: 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
exit 1
fi
- name: Check out release pull request
if: steps.release.outputs.prs_created == 'true'
@ -99,6 +134,9 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.release-please.outputs.sha }}
persist-credentials: false
- uses: actions/setup-node@v6
with:
@ -168,7 +206,7 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.release-please.outputs.tag_name }}
ref: ${{ needs.release-please.outputs.sha }}
persist-credentials: false
- name: Verify package and registry metadata versions

View File

@ -120,32 +120,14 @@ Describe the behavior change and verification performed in the pull request. Nev
## Maintainer release checklist
Run `npm run check:pr`, the isolated package verifier, package dry run, and two-target real-Hopper verifier described above. Build a local tarball and exercise the executable through the package boundary:
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
pushes do not update release PRs or publish packages. Both publishers build
the exact SHA tagged by Release Please.
```bash
npm pack
```
Use the exact filename printed by `npm pack` to run the packaged executable:
```bash
npm exec --yes --package ./rea-agents-VERSION.tgz -- rea --help
```
Replace `VERSION` with the packed version; do not use a tarball from an earlier
build.
Publish the public package:
```bash
npm publish --access public
```
After npm registry propagation, verify the published CLI and connect the client SDK version pinned in `package.json` to the published server to confirm the canonical tool catalog:
```bash
npx -y rea-agents@latest --help
npx -y rea-agents@latest doctor
npx -y rea-agents@latest setup --yes --all-detected
npx -y rea-agents@latest mcp
```
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.

View File

@ -64,6 +64,7 @@ export default defineConfig({
{ text: "MCP contracts", link: "/mcp-contracts" },
{ text: "Tool design", link: "/tool-design" },
{ text: "Testing", link: "/testing" },
{ text: "Releasing", link: "/releasing" },
{ text: "Architecture decisions", link: "/adr/README" },
],
},

103
docs/releasing.md Normal file
View File

@ -0,0 +1,103 @@
# Releasing from a checkpoint
REA releases use a frozen 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.
## 1. Cut the release branch
Choose the next version from the unreleased Conventional Commits, including
breaking changes. Record the full source SHA and create `release/VERSION` at
that commit. The version is a maintainer choice; Release Please's proposed
version must agree before publication.
For example, after selecting a reviewed commit for 5.0.0:
```bash
git fetch origin main
git branch release/5.0.0 SOURCE_SHA
git push origin release/5.0.0
```
Replace `SOURCE_SHA` with the selected full commit SHA. The checkpoint must
include this release workflow and CI support for release branches. For an
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:
```bash
gh workflow run release.yml --ref release/5.0.0
```
## 2. Review and merge the bot PR
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.
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.
## 3. Verify publication
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.
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.
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.
The workflow builds the bundled Windows controls and verifies the packaged
artifact before npm publication. It then verifies the published CLI, the
capability-scoped MCP catalog, and the isolated package update path before
publishing MCP Registry metadata.
Record these outcomes separately:
- GitHub release and tag, including the resolved commit SHA.
- npm's exact version and integrity, with the published-package canary passed.
- MCP Registry's exact server version and matching npm package version.
A GitHub tag alone does not establish npm or MCP Registry publication.
## 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
regenerating from the combined contracts with the released package version.
Review and test this synchronization PR, then use a merge commit so the release
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.
## Partial publication and retries
Inspect the failed job and public registry state before retrying. When npm is
already published, the npm job verifies that exact version instead of
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.

View File

@ -1,8 +1,244 @@
import { readFile } from "node:fs/promises";
import { execFile } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import { delimiter, join } from "node:path";
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(),
with: z.record(z.string(), z.unknown()).optional(),
});
const jobSchema = z.object({
outputs: z.record(z.string(), z.string()).optional(),
steps: z.array(stepSchema),
});
async function readReleaseWorkflow() {
return z
.object({
on: z.record(z.string(), z.unknown()),
jobs: z.object({
"release-please": jobSchema,
publish: jobSchema,
"publish-mcp": jobSchema,
}),
})
.parse(
parse(
await readFile(
new URL("../../../.github/workflows/release.yml", import.meta.url),
"utf8",
),
),
);
}
it("prepares and publishes only from frozen release branches", async () => {
const workflow = await readReleaseWorkflow();
expect(workflow.on.push).toEqual({ branches: ["release/*"] });
expect(workflow.on).toHaveProperty("workflow_dispatch");
expect(workflow.on.workflow_dispatch).toBeNull();
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");
});
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 }}",
);
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 }}",
"persist-credentials": false,
});
}
});
it
.skipIf(process.platform === "win32")
.each(["main", "feature/new-work", "release/", "release/../main"])(
"rejects %s as a checkpoint before contacting GitHub",
async (releaseBranch) => {
const workflow = await readReleaseWorkflow();
const command = z
.string()
.parse(
workflow.jobs["release-please"].steps.find(
(step) => step.name === "Validate release selection",
)?.run,
);
await expect(
execFileAsync("bash", ["-e", "-o", "pipefail", "-c", command], {
env: {
...process.env,
GITHUB_REF: `refs/heads/${releaseBranch}`,
RELEASE_BRANCH: releaseBranch,
},
}),
).rejects.toMatchObject({
code: 1,
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) => {
const workflow = await readReleaseWorkflow();
const command = z
.string()
.parse(
workflow.jobs["release-please"].steps.find(
(step) => step.name === "Validate release selection",
)?.run,
);
await expect(
execFileAsync("bash", ["-e", "-o", "pipefail", "-c", command], {
env: {
...process.env,
GITHUB_REF: ref,
RELEASE_BRANCH: "release/5.0.0",
},
}),
).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("Select a release/ branch"),
});
},
);
it.skipIf(process.platform === "win32").each([true, false])(
"checks the release branch still matches its triggering commit before tagging (match: %s)",
async (match) => {
const workflow = await readReleaseWorkflow();
const command = z
.string()
.parse(
workflow.jobs["release-please"].steps.find(
(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 },
);
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
? "1111111111111111111111111111111111111111"
: "2222222222222222222222222222222222222222",
},
},
);
if (match) {
await expect(result).resolves.toMatchObject({
stdout: expect.stringContaining("Selected release checkpoint:"),
stderr: "",
});
} else {
await expect(result).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining(
"release branch moved after this run was triggered",
),
});
}
},
);
it.skipIf(process.platform === "win32").each([true, false])(
"publishes only when the tag and provenance source match (match: %s)",
async (match) => {
const workflow = await readReleaseWorkflow();
const command = z
.string()
.parse(
workflow.jobs["release-please"].steps.find(
(step) =>
step.name === "Bind publication to the triggering checkpoint",
)?.run,
);
const result = execFileAsync(
"bash",
["-e", "-o", "pipefail", "-c", command],
{
env: {
...process.env,
GITHUB_SHA: "1111111111111111111111111111111111111111",
RELEASE_SHA: match
? "1111111111111111111111111111111111111111"
: "2222222222222222222222222222222222222222",
},
},
);
if (match) {
await expect(result).resolves.toMatchObject({ stderr: "" });
} else {
await expect(result).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining(
"refusing to publish mismatched source provenance",
),
});
}
},
);
it("runs release PR CI without enabling implementation pushes on release branches", async () => {
const workflow = z
.object({
on: z.object({
push: z.object({ branches: z.array(z.string()) }),
pull_request: z.object({ branches: z.array(z.string()) }),
}),
})
.parse(
parse(
await readFile(
new URL("../../../.github/workflows/ci.yml", import.meta.url),
"utf8",
),
),
);
expect(workflow.on.push.branches).toEqual(["main"]);
expect(workflow.on.pull_request.branches).toEqual(
expect.arrayContaining(["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 () => {