mirror of
https://github.com/BillyOutlast/rea.git
synced 2026-10-12 02:26:55 +00:00
fix(release): publish from explicit source checkpoints (#1006)
* 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): stop publication when the checkpoint has moved Reject unknown phases and a release-branch tip that differs from the dispatch SHA before Release Please creates a tag. Keep registry jobs and catalog pushes on their own phase, and run website checks for release PRs. * fix(release): require a token that can trigger release CI Stop falling back to GITHUB_TOKEN, which cannot start workflow runs for the release pull request it creates. * test(release): require the CI-capable release token Lock the workflow to RELEASE_PLEASE_TOKEN and fail preparation when that secret is missing. * style(release): format the release-token test --------- Co-authored-by: N0zoM1z0 <[email protected]> Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
co-authored by
N0zoM1z0
Cursor Agent
parent
64c71880ae
commit
b4fc0ecaa1
@@ -1,15 +1,24 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["release/*"]
|
||||
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,77 +30,125 @@ jobs:
|
||||
pull-requests: write
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
sha: ${{ steps.release.outputs.sha }}
|
||||
steps:
|
||||
- name: Require a CI-capable release token
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_PLEASE_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
||||
run: |
|
||||
if [[ -z "${RELEASE_PLEASE_TOKEN}" ]]; then
|
||||
echo "RELEASE_PLEASE_TOKEN is required. Pull requests and catalog commits created with GITHUB_TOKEN do not start workflow runs, so the normalized release head would have no CI result to approve." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate release selection
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_BRANCH: ${{ github.ref_name }}
|
||||
RELEASE_BRANCH: ${{ inputs.release_branch }}
|
||||
RELEASE_PHASE: ${{ inputs.phase }}
|
||||
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
|
||||
if [[ "${RELEASE_PHASE}" != prepare && "${RELEASE_PHASE}" != publish ]]; then
|
||||
echo "phase must be prepare or publish." >&2
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
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
|
||||
|
||||
- name: Resolve release branch tip
|
||||
id: branch
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_BRANCH: ${{ inputs.release_branch }}
|
||||
run: |
|
||||
sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${RELEASE_BRANCH}" --jq '.object | select(.type == "commit") | .sha')"
|
||||
if [[ ! "${sha}" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "The release branch tip is not a commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Require the publish dispatch to match the branch tip
|
||||
if: inputs.phase == 'publish'
|
||||
shell: bash
|
||||
env:
|
||||
BRANCH_SHA: ${{ steps.branch.outputs.sha }}
|
||||
run: |
|
||||
if [[ "${BRANCH_SHA}" != "${GITHUB_SHA}" ]]; then
|
||||
echo "The release branch tip differs from the dispatched checkpoint; refusing to create a release for a moved source." >&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 }}
|
||||
# A dedicated credential lets release-PR updates trigger CI.
|
||||
# GITHUB_TOKEN-authored pull requests do not start workflow runs.
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
||||
target-branch: ${{ inputs.release_branch }}
|
||||
skip-github-release: ${{ inputs.phase == 'prepare' }}
|
||||
skip-github-pull-request: ${{ inputs.phase == 'publish' }}
|
||||
|
||||
- name: Bind publication to the triggering checkpoint
|
||||
if: steps.release.outputs.release_created == 'true'
|
||||
- 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 triggering checkpoint; refusing to publish mismatched source provenance." >&2
|
||||
echo "The tagged release SHA differs from the dispatched checkpoint; refusing to publish mismatched source provenance. The GitHub release already exists; do not dispatch publish again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check out release pull request
|
||||
if: steps.release.outputs.prs_created == 'true'
|
||||
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ fromJSON(steps.release.outputs.pr).headBranchName }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js for generated documentation
|
||||
if: steps.release.outputs.prs_created == 'true'
|
||||
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.release.outputs.prs_created == 'true'
|
||||
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Regenerate release documentation
|
||||
if: steps.release.outputs.prs_created == 'true'
|
||||
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
|
||||
run: npm run docs:generate
|
||||
|
||||
- name: Commit canonical release catalog
|
||||
if: steps.release.outputs.prs_created == 'true'
|
||||
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
# Use the same identity for the follow-up commit so it also triggers CI.
|
||||
GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
||||
RELEASE_PR_JSON: ${{ steps.release.outputs.pr }}
|
||||
run: |
|
||||
release_pr_branch="$(node --input-type=module -e '
|
||||
@@ -127,7 +184,7 @@ jobs:
|
||||
publish:
|
||||
name: Publish to npm
|
||||
needs: release-please
|
||||
if: needs.release-please.outputs.release_created == 'true'
|
||||
if: inputs.phase == 'publish' && needs.release-please.outputs.release_created == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -198,7 +255,7 @@ jobs:
|
||||
publish-mcp:
|
||||
name: Publish to MCP Registry
|
||||
needs: [release-please, publish]
|
||||
if: needs.release-please.outputs.release_created == 'true' && needs.publish.result == 'success'
|
||||
if: inputs.phase == 'publish' && needs.release-please.outputs.release_created == 'true' && needs.publish.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Website checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, "release/*"]
|
||||
paths:
|
||||
- "website/**"
|
||||
- "scripts/verify-website.py"
|
||||
|
||||
+12
-10
@@ -28,19 +28,21 @@ Keep dependencies flowing inward through the existing domain, contracts, provide
|
||||
|
||||
## Documentation website
|
||||
|
||||
The VitePress site uses the Markdown files in `docs/` and deploys to
|
||||
<https://morluto.github.io/rea/>. Run `npm run docs:dev` for live editing,
|
||||
`npm run docs:build` to check the production build and links, and
|
||||
`npm run docs:preview` to preview that build at `/rea/`.
|
||||
The VitePress site uses the Markdown files in `docs/`. Run `npm run docs:dev`
|
||||
for live editing, `npm run docs:build` to check the production build and
|
||||
links, and `npm run docs:preview` to preview that build at `/rea/`.
|
||||
|
||||
Site navigation lives in `docs/.vitepress/config.ts`. Keep links to guides
|
||||
relative so they work on GitHub and the website; link to repository files
|
||||
outside `docs/` using their full GitHub URLs. Generated reference documents
|
||||
still use `npm run docs:generate`; `docs:build` only builds the website.
|
||||
|
||||
The documentation workflow builds pull requests and deploys changes on `main`.
|
||||
The repository's **Settings → Pages → Build and deployment → Source** must be
|
||||
set to **GitHub Actions** before the first deployment.
|
||||
Pull requests run `npm run docs:check`. `.github/workflows/pages.yml` is a
|
||||
manual VitePress build and does not publish. The public site at
|
||||
<https://morluto.github.io/rea/> is published only by the manual website
|
||||
workflow on `main`. The repository's **Settings → Pages → Build and
|
||||
deployment → Source** must be set to **GitHub Actions** before the first
|
||||
deployment.
|
||||
|
||||
## Development feedback and PR verification
|
||||
|
||||
@@ -121,9 +123,9 @@ Describe the behavior change and verification performed in the pull request. Nev
|
||||
## Maintainer release checklist
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
|
||||
+51
-36
@@ -1,10 +1,10 @@
|
||||
# Releasing from a checkpoint
|
||||
|
||||
REA releases use a frozen source checkpoint. Main can continue accepting
|
||||
REA releases use an explicit 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.
|
||||
only when a maintainer dispatches it; main pushes do not refresh release PRs.
|
||||
|
||||
## 1. Cut the release branch
|
||||
## 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
|
||||
@@ -25,43 +25,54 @@ 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:
|
||||
## 2. Prepare the bot PR
|
||||
|
||||
Run the workflow definition from main and select the frozen source separately:
|
||||
|
||||
```bash
|
||||
gh workflow run release.yml --ref release/5.0.0
|
||||
gh workflow run release.yml --ref main \
|
||||
-f release_branch=release/5.0.0 -f phase=prepare
|
||||
```
|
||||
|
||||
## 2. Review and merge the bot PR
|
||||
Release Please targets that branch and prepares its version, changelog, and
|
||||
registry metadata. Preparation requires the `RELEASE_PLEASE_TOKEN` repository
|
||||
secret. That credential opens the release pull request and pushes the catalog
|
||||
normalization commit, so both start CI on the exact normalized head. The
|
||||
default `GITHUB_TOKEN` cannot do that: events it authors do not start
|
||||
workflow runs, and there is then no blocked run to approve. 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 and wait for that head's CI.
|
||||
|
||||
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.
|
||||
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.
|
||||
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. Verify publication
|
||||
## 3. Publish the reviewed merge
|
||||
|
||||
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.
|
||||
After the release PR has merged:
|
||||
|
||||
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.
|
||||
```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. Before Release Please creates a tag, the
|
||||
workflow accepts only `prepare` or `publish` and requires the selected branch
|
||||
tip to equal the dispatch SHA. Registry jobs run only during `publish`. After
|
||||
a tag exists, a mismatch between that tag's SHA and the dispatch SHA stops
|
||||
both registry publishes.
|
||||
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.
|
||||
|
||||
@@ -78,7 +89,7 @@ Record these outcomes separately:
|
||||
|
||||
A GitHub tag alone does not establish npm or MCP Registry publication.
|
||||
|
||||
## Sync metadata back to main
|
||||
## 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
|
||||
@@ -88,6 +99,9 @@ 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.
|
||||
The generated-metadata workflow stays limited to pull requests into main so it
|
||||
does not push commits onto a frozen candidate. Preparation normalizes
|
||||
`docs/product-catalog.json` on the bot pull request.
|
||||
|
||||
## Partial publication and retries
|
||||
|
||||
@@ -97,7 +111,8 @@ 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.
|
||||
A branch tip that moved after dispatch fails before Release Please creates a
|
||||
tag. 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,21 +1,21 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
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(),
|
||||
if: z.string().optional(),
|
||||
with: z.record(z.string(), z.unknown()).optional(),
|
||||
env: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
const jobSchema = z.object({
|
||||
if: z.string().optional(),
|
||||
outputs: z.record(z.string(), z.string()).optional(),
|
||||
steps: z.array(stepSchema),
|
||||
});
|
||||
@@ -40,23 +40,67 @@ async function readReleaseWorkflow() {
|
||||
);
|
||||
}
|
||||
|
||||
it("prepares and publishes only from frozen release branches", async () => {
|
||||
it("requires explicit release preparation or publication instead of main pushes", async () => {
|
||||
const workflow = await readReleaseWorkflow();
|
||||
expect(workflow.on.push).toEqual({ branches: ["release/*"] });
|
||||
expect(workflow.on).toHaveProperty("workflow_dispatch");
|
||||
expect(workflow.on.workflow_dispatch).toBeNull();
|
||||
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?.["target-branch"]).toBe("${{ github.ref_name }}");
|
||||
expect(release?.with).not.toHaveProperty("skip-github-release");
|
||||
expect(release?.with).not.toHaveProperty("skip-github-pull-request");
|
||||
expect(release?.with).toMatchObject({
|
||||
token: "${{ secrets.RELEASE_PLEASE_TOKEN }}",
|
||||
"target-branch": "${{ inputs.release_branch }}",
|
||||
"skip-github-release": "${{ inputs.phase == 'prepare' }}",
|
||||
"skip-github-pull-request": "${{ inputs.phase == 'publish' }}",
|
||||
});
|
||||
const catalogCommit = workflow.jobs["release-please"].steps.find(
|
||||
(step) => step.name === "Commit canonical release catalog",
|
||||
);
|
||||
expect(catalogCommit?.env?.GH_TOKEN).toBe(
|
||||
"${{ secrets.RELEASE_PLEASE_TOKEN }}",
|
||||
);
|
||||
});
|
||||
|
||||
it("stops preparation when the CI-capable release token is missing", async () => {
|
||||
const workflow = await readReleaseWorkflow();
|
||||
const command = z
|
||||
.string()
|
||||
.parse(
|
||||
workflow.jobs["release-please"].steps.find(
|
||||
(step) => step.name === "Require a CI-capable release token",
|
||||
)?.run,
|
||||
);
|
||||
await expect(
|
||||
execFileAsync("bash", ["-e", "-o", "pipefail", "-c", command], {
|
||||
env: { ...process.env, RELEASE_PLEASE_TOKEN: "" },
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stderr: expect.stringMatching(/RELEASE_PLEASE_TOKEN/u),
|
||||
});
|
||||
});
|
||||
|
||||
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 }}",
|
||||
expect(workflow.jobs["release-please"].outputs).toEqual({
|
||||
release_created: "${{ steps.release.outputs.release_created }}",
|
||||
sha: "${{ steps.release.outputs.sha }}",
|
||||
});
|
||||
expect(workflow.jobs.publish.if).toBe(
|
||||
"inputs.phase == 'publish' && needs.release-please.outputs.release_created == 'true'",
|
||||
);
|
||||
expect(workflow.jobs["publish-mcp"].if).toBe(
|
||||
"inputs.phase == 'publish' && needs.release-please.outputs.release_created == 'true' && needs.publish.result == 'success'",
|
||||
);
|
||||
for (const job of [workflow.jobs.publish, workflow.jobs["publish-mcp"]]) {
|
||||
const checkout = job.steps.find((step) =>
|
||||
@@ -67,6 +111,21 @@ it("binds npm and MCP publication to the same immutable release SHA", async () =
|
||||
"persist-credentials": false,
|
||||
});
|
||||
}
|
||||
const preparation = [
|
||||
"Check out release pull request",
|
||||
"Set up Node.js for generated documentation",
|
||||
"Install dependencies",
|
||||
"Regenerate release documentation",
|
||||
"Commit canonical release catalog",
|
||||
];
|
||||
for (const name of preparation) {
|
||||
expect(
|
||||
workflow.jobs["release-please"].steps.find((step) => step.name === name)
|
||||
?.if,
|
||||
).toBe(
|
||||
"inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it
|
||||
@@ -86,26 +145,25 @@ it
|
||||
execFileAsync("bash", ["-e", "-o", "pipefail", "-c", command], {
|
||||
env: {
|
||||
...process.env,
|
||||
GITHUB_REF: `refs/heads/${releaseBranch}`,
|
||||
GITHUB_REF: "refs/heads/main",
|
||||
RELEASE_BRANCH: releaseBranch,
|
||||
RELEASE_PHASE: "prepare",
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stderr: expect.stringMatching(/release.*branch/u),
|
||||
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) => {
|
||||
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()
|
||||
@@ -120,18 +178,56 @@ it
|
||||
...process.env,
|
||||
GITHUB_REF: ref,
|
||||
RELEASE_BRANCH: "release/5.0.0",
|
||||
RELEASE_PHASE: phase,
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stderr: expect.stringContaining("Select a release/ branch"),
|
||||
stderr: expect.stringContaining(`Run ${phase} from`),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32").each([true, false])(
|
||||
"checks the release branch still matches its triggering commit before tagging (match: %s)",
|
||||
async (match) => {
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"checks the checkpoint locally and resolves the branch tip separately",
|
||||
async () => {
|
||||
const workflow = await readReleaseWorkflow();
|
||||
const validate = z
|
||||
.string()
|
||||
.parse(
|
||||
workflow.jobs["release-please"].steps.find(
|
||||
(step) => step.name === "Validate release selection",
|
||||
)?.run,
|
||||
);
|
||||
expect(validate).not.toMatch(/\bgh\b/u);
|
||||
const resolve = z
|
||||
.string()
|
||||
.parse(
|
||||
workflow.jobs["release-please"].steps.find(
|
||||
(step) => step.name === "Resolve release branch tip",
|
||||
)?.run,
|
||||
);
|
||||
expect(resolve).toContain("gh api");
|
||||
for (const releaseBranch of ["release/5.0.0", "release/5.0.0-rc.1"]) {
|
||||
await expect(
|
||||
execFileAsync("bash", ["-e", "-o", "pipefail", "-c", validate], {
|
||||
env: {
|
||||
...process.env,
|
||||
GITHUB_REF: "refs/heads/main",
|
||||
RELEASE_BRANCH: releaseBranch,
|
||||
RELEASE_PHASE: "prepare",
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({ stderr: "" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it
|
||||
.skipIf(process.platform === "win32")
|
||||
.each(["", "release", "prepare ", "true"])(
|
||||
"rejects phase %j before contacting GitHub",
|
||||
async (phase) => {
|
||||
const workflow = await readReleaseWorkflow();
|
||||
const command = z
|
||||
.string()
|
||||
@@ -140,39 +236,55 @@ it.skipIf(process.platform === "win32").each([true, false])(
|
||||
(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 },
|
||||
);
|
||||
await expect(
|
||||
execFileAsync("bash", ["-e", "-o", "pipefail", "-c", command], {
|
||||
env: {
|
||||
...process.env,
|
||||
GITHUB_REF: "refs/heads/main",
|
||||
RELEASE_BRANCH: "release/5.0.0",
|
||||
RELEASE_PHASE: phase,
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stderr: expect.stringContaining("phase must be prepare or publish"),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32").each([true, false])(
|
||||
"creates a release only when the branch tip is the dispatched checkpoint (match: %s)",
|
||||
async (match) => {
|
||||
const workflow = await readReleaseWorkflow();
|
||||
const command = z
|
||||
.string()
|
||||
.parse(
|
||||
workflow.jobs["release-please"].steps.find(
|
||||
(step) =>
|
||||
step.name ===
|
||||
"Require the publish dispatch to match the branch tip",
|
||||
)?.run,
|
||||
);
|
||||
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
|
||||
BRANCH_SHA: match
|
||||
? "1111111111111111111111111111111111111111"
|
||||
: "2222222222222222222222222222222222222222",
|
||||
},
|
||||
},
|
||||
);
|
||||
if (match) {
|
||||
await expect(result).resolves.toMatchObject({
|
||||
stdout: expect.stringContaining("Selected release checkpoint:"),
|
||||
stderr: "",
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({ stderr: "" });
|
||||
} else {
|
||||
await expect(result).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stderr: expect.stringContaining(
|
||||
"release branch moved after this run was triggered",
|
||||
"refusing to create a release for a moved source",
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -188,7 +300,7 @@ it.skipIf(process.platform === "win32").each([true, false])(
|
||||
.parse(
|
||||
workflow.jobs["release-please"].steps.find(
|
||||
(step) =>
|
||||
step.name === "Bind publication to the triggering checkpoint",
|
||||
step.name === "Bind publication to the dispatched checkpoint",
|
||||
)?.run,
|
||||
);
|
||||
const result = execFileAsync(
|
||||
@@ -239,6 +351,27 @@ it("runs release PR CI without enabling implementation pushes on release branche
|
||||
);
|
||||
});
|
||||
|
||||
it("checks website changes on release-branch pull requests", async () => {
|
||||
const workflow = z
|
||||
.object({
|
||||
on: z.object({
|
||||
pull_request: z.object({ branches: z.array(z.string()) }),
|
||||
}),
|
||||
})
|
||||
.parse(
|
||||
parse(
|
||||
await readFile(
|
||||
new URL(
|
||||
"../../../.github/workflows/website-check.yml",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(workflow.on.pull_request.branches).toEqual(["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