mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
1 parent
4a6073810a
commit
49887f0239
6 files changed
+377
-33
No files matched your search
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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,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 () => {
|
||||
|
||||
Reference in new issue
Block a user