From 64c71880ae4f7bc658ffce46eb729c4141dbdf19 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:12:18 +0800 Subject: [PATCH] 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) --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 42 +++- CONTRIBUTING.md | 38 +-- docs/.vitepress/config.ts | 1 + docs/releasing.md | 103 ++++++++ .../filesystem/releaseWorkflow.test.ts | 238 +++++++++++++++++- 6 files changed, 392 insertions(+), 32 deletions(-) create mode 100644 docs/releasing.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b27bb0ce7..d6ce23b97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1addae3c3..0680946b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 488f5ab04..796b7cf22 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 8d25bfc85..0e36c4ad8 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -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" }, ], }, diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 000000000..fe453323c --- /dev/null +++ b/docs/releasing.md @@ -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. diff --git a/tests/boundary/filesystem/releaseWorkflow.test.ts b/tests/boundary/filesystem/releaseWorkflow.test.ts index e7a0c0935..177514cef 100644 --- a/tests/boundary/filesystem/releaseWorkflow.test.ts +++ b/tests/boundary/filesystem/releaseWorkflow.test.ts @@ -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 () => {