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:
parent
fe64fd65d9
commit
64c71880ae
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue