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)
This commit is contained in:
N0zoM1z0 committed 2026-10-07 16:38:02 +00:00
1 parent 4a6073810a
commit 49887f0239
6 files changed
+377 -33

No files matched your search

+1 -1
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 }}
+64 -4
View File
@@ -1,14 +1,24 @@
name: Release
on:
push:
branches: [main]
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,13 +31,60 @@ 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: ${{ inputs.release_branch }}
RELEASE_PHASE: ${{ inputs.phase }}
run: |
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
exit 1
fi
gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${RELEASE_BRANCH}" \
--jq '.object.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: ${{ inputs.release_branch }}
skip-github-release: ${{ inputs.phase == 'prepare' }}
skip-github-pull-request: ${{ inputs.phase == 'publish' }}
- 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 dispatched 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 +156,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:
@@ -164,7 +224,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
+10 -28
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. 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.
```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.
+1
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" },
],
},
+107
View File
@@ -0,0 +1,107 @@
# Releasing from a checkpoint
REA releases use an explicit source checkpoint. Main can continue accepting
changes while a release is tested and published. The Release workflow runs
only when a maintainer dispatches it; main pushes do not refresh release PRs.
## 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
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.
## 2. Prepare the bot PR
Run the workflow definition from main and select the frozen source separately:
```bash
gh workflow run release.yml --ref main \
-f release_branch=release/5.0.0 -f phase=prepare
```
Release Please targets that branch and prepares its version, changelog, and
registry metadata. 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. 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. 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. Publish the reviewed merge
After the release PR has merged:
```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. The workflow rejects a mismatch between
the tagged release SHA and the dispatch SHA before either registry publish.
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.
## 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
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 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.
@@ -1,8 +1,202 @@
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { parse } from "yaml";
import { z } from "zod";
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("requires explicit release preparation or publication instead of main pushes", async () => {
const workflow = await readReleaseWorkflow();
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).toMatchObject({
"target-branch": "${{ inputs.release_branch }}",
"skip-github-release": "${{ inputs.phase == 'prepare' }}",
"skip-github-pull-request": "${{ inputs.phase == 'publish' }}",
});
});
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/main",
RELEASE_BRANCH: releaseBranch,
RELEASE_PHASE: "prepare",
},
}),
).rejects.toMatchObject({
code: 1,
stderr: expect.stringMatching(/release_branch/u),
});
},
);
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()
.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",
RELEASE_PHASE: phase,
},
}),
).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining(`Run ${phase} from`),
});
},
);
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 dispatched 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 () => {