Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
351 changes: 287 additions & 64 deletions .github/workflows/release.yml

Large diffs are not rendered by default.

18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,17 +373,17 @@ Normal PRs merge into `main` without publishing. Each push is classified as rele
flowchart LR
change["Conventional PR merged"] --> releasePR["Generated release PR"]
releasePR --> review["Review version and CHANGELOG"]
review --> merge["Two-parent merge into main"]
merge --> admit["Admit and persist one candidate SHA"]
review --> merge["Squash or merge into main"]
merge --> admit["Verify topology and persist one candidate SHA"]
admit --> proof["Proof pinned to candidate SHA"]
proof --> publish["Immutable tag, GitHub Release, archive, checksums"]
```

### One-time GitHub setup

1. Open **Settings → Actions → General → Workflow permissions**. Keep the default workflow permission read-only and allow GitHub Actions to create and approve pull requests.
2. Enable squash merging for normal PRs and merge commits for release PRs. Remove any **Require linear history** rule that applies to `main`. Publication admits only a two-parent release-PR merge commit.
3. Protect `main`. Require `Conventional Commit title`, `Release impact`, `Hosted public and private Git canaries`, all four `Compatibility` checks, and `Deterministic package`.
2. Enable squash merging for all PRs, including Release Please PRs. Merge commits may remain available as an optional release-PR path. Publication does not trust a merge-mode label: it verifies either a one-parent candidate or a two-parent merge candidate against the reviewed PR and its frozen base.
3. Protect `main` with an active branch ruleset that has no bypass actors. Enable **Require a pull request before merging** and **Block force pushes**. Require `Conventional Commit title`, `Release impact`, `Hosted public and private Git canaries`, all four `Compatibility` checks, and `Deterministic package`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
4. Open **Settings → Rules → Rulesets**. Create an active tag ruleset for `v*` that restricts tag deletion and updates with no bypass actors.
5. Open **Settings → Environments**. Create `release` and configure required reviewers for publication and same-tag asset replacement.
6. Create the public and private canary repositories named by `plugin.config.json`, then create the `hosted-canary-qualification` environment. Add `CANARY_GH_TOKEN`: a fine-grained token for the exact configured `canary.actor`, scoped only to both canary repositories, with Contents read/write, Actions read, and metadata read. Add `CANARY_SSH_PRIVATE_KEY` and `CANARY_SSH_KNOWN_HOSTS` for the same canary identity. Qualification uses the token-backed GitHub API identity and SSH Git identity, and limits writes to create-only immutable candidate refs. Keeping Git transport on SSH allows candidates containing workflow files without broadening the API token.
Expand All @@ -394,7 +394,7 @@ flowchart LR
9. Authenticate `gh` with read access to repository settings, then run `bun run readiness -- --repo OWNER/REPOSITORY`.
10. Enable release automation only after readiness reports `READY`.

The immutable `v*` tag ruleset is a human-owned safeguard outside the workflow. Release automation never receives repository-administration authority; it cannot change the ruleset or its own release environment. `bun run readiness` is read-only and fails closed when the default branch, merge mode, effective merge-history policy, required checks, Actions permissions, tag ruleset, hosted-canary environment and secret names, or workflow authority cannot be proved. It reads secret metadata only, never secret values.
The immutable `v*` tag ruleset and the no-bypass `main` ruleset are human-owned safeguards outside the workflow. The `main` ruleset prevents a force push from steering the push event's trusted pre-merge base. Release automation never receives repository-administration authority; it cannot change either ruleset or its own release environment. `bun run readiness` is read-only and fails closed when the default branch, squash path, direct-push protection, effective merge-history policy, required checks, Actions permissions, tag ruleset, hosted-canary environment and secret names, or workflow authority cannot be proved. It reads secret metadata only, never secret values.

Release automation requires `RELEASE_PLEASE_TOKEN`; it does not fall back to `GITHUB_TOKEN`. GitHub suppresses workflow runs caused by `GITHUB_TOKEN`, which would leave the generated release PR without its required checks. The separate repository variable `RELEASE_PLEASE_AUTOMATION_LOGIN` records the exact login that owns the token; both the release-impact gate and publication admission bind that identity.

Expand All @@ -403,15 +403,17 @@ Release automation requires `RELEASE_PLEASE_TOKEN`; it does not fall back to `GI
1. Merge normal PRs with valid Conventional Commit titles.
2. Wait for the `Release` workflow's maintenance path to create or update the release PR. No tag or GitHub Release is created here.
3. Confirm the first release is `v0.1.0`; review the proposed semantic version, exact version projection, and generated `CHANGELOG.md`.
4. Merge the release PR into `main` with a merge commit. Do not squash it.
5. Wait for the workflow to admit exactly one merged release PR bound to `github.sha`: base `main`, configured Release Please automation identity, two parents, and only the allowed version projection.
6. Confirm the workflow persisted `publication-candidate-<SHA>` before proof and checked out that candidate SHA. Publication embeds that admission record in the annotated immutable release tag, so repair remains possible after the workflow artifact expires. Later movement of `main` does not change the candidate.
4. Squash-merge the release PR into `main`. A two-parent merge commit is also supported when merge commits are enabled and `main` does not require linear history.
5. Wait for the workflow to admit exactly one merged release PR bound to `github.sha`: base `main`, configured Release Please automation identity, only the allowed version projection, and a verified one-parent or two-parent topology. In both cases the first parent must equal both the trusted pre-merge base and the merged PR's frozen base, and every changed candidate blob must equal the corresponding blob from the reviewed PR head. A two-parent candidate must also bind its second parent to the reviewed PR head.
6. Confirm the workflow persisted `publication-candidate-<SHA>` before proof and checked out that candidate SHA. The persisted nine-field record is unchanged; parent topology is rederived from the immutable candidate commit instead of being trusted from the tag. Publication embeds the admission record in the annotated immutable release tag, so repair remains possible after the workflow artifact expires. Later movement of `main` does not change the candidate.
7. Wait for metadata validation, four-platform proof, deterministic packaging, and generated-drift rejection.
8. Approve the protected `release` environment. The workflow creates `vX.Y.Z` explicitly at the candidate SHA, verifies the remote tag target, then creates the GitHub Release with `--verify-tag --target <candidate-sha>`.
9. Confirm the Release contains the deterministic archive and `*.checksums.json`. For a public repository, confirm the archive attestation.

Do not hand-edit versions or `CHANGELOG.md`. Do not create the tag first. Do not publish to npm.

The one-parent path is intended for squash merges. GitHub does not expose a reliable field that proves which merge button produced a commit, so a lineage-equivalent single-commit rebase can satisfy the same checks. This is not a rebase-only support promise: readiness still requires squash merging because release PRs can contain multiple commits and ordinary PRs must remain squashable. Arbitrary or multi-commit rebases cannot pass admission because the candidate's first parent would not equal the trusted pre-merge and frozen PR base. Manual repair repeats these topology checks from GitHub and checks both the persisted identity and the fresh PR author against `RELEASE_PLEASE_AUTOMATION_LOGIN`; neither topology nor identity is self-authorized by the persisted record.

### Manually maintain or repair release state

Manual dispatch accepts two operation values. `maintenance` is the default; it only updates the standing release PR and never publishes. `repair` requires `release_tag` set to the exact existing `vX.Y.Z` tag. This repairs an incomplete publication; it does not create a new release.
Expand Down
12 changes: 8 additions & 4 deletions docs/adr/0003-reviewed-versioned-releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,25 @@ The release workflow classifies each invocation into one state:
- **Publication:** a push containing exactly one eligible merged release PR admits and publishes one candidate commit.
- **Repair:** manual dispatch repairs an incomplete publication from one exact existing immutable tag.

Publication admits only a release PR based on `main`, opened by the configured Release Please automation identity, merged as a two-parent commit equal to `github.sha`, and containing exactly the allowed version projection. The workflow persists a candidate record before proof. Every later checkout, validation, package, tag, Release target, and checksum binding uses that candidate SHA even if `main` advances.
Publication admits only a release PR based on `main`, opened by the configured Release Please automation identity, merged to a candidate equal to `github.sha`, and containing exactly the allowed version projection. Normal PRs and Release Please PRs may use squash merge. Admission accepts only a verified one-parent or two-parent candidate: in both cases the first parent must equal the trusted pre-merge base and the merged PR's frozen base, and every changed candidate blob must equal the corresponding blob reported for the reviewed PR head; for a two-parent candidate, the second parent must also equal the reviewed PR head. A multi-commit rebase fails that first-parent binding and is rejected.

The one-parent path is motivated by squash merge. GitHub exposes no reliable field that proves which merge button produced a commit, so a provenance-equivalent single-commit rebase is admitted when it satisfies the same immutable-SHA, base, merged-PR association, identity, and projection invariants. This is deliberately a topology check, not a general rebase or merge-policy framework.

The workflow persists the same nine-field candidate record before proof: `repository`, `baseBranch`, `pullRequest`, `automationIdentity`, `mergeCommit`, `version`, `tag`, `expectedTagState`, and `projectionDigest`. Topology is not added to that tag-carried record; it is rederived from the immutable candidate commit. Every later checkout, validation, package, tag, Release target, and checksum binding uses that candidate SHA even if `main` advances.

After four-platform and deterministic-distribution proof, the workflow creates `vX.Y.Z` explicitly at the candidate SHA, verifies the remote tag resolves to that SHA, and creates the GitHub Release with tag verification and an explicit target. Release Please has no publication role.

Packaging emits a deterministic `tar.gz` and `*.checksums.json`. The JSON binds `repository`, `sourceCommit`, `tag`, `plugin`, `version`, `archive`, `archiveBytes`, and `archiveSha256`, plus an evidence note. This is integrity evidence for the archive bytes. It is not independent publisher or builder authenticity. Public repositories may add GitHub artifact attestation; user-owned private repositories retain checksum evidence without that unsupported attestation path.

## Repair contract

Manual dispatch accepts `operation=maintenance` or `operation=repair`. Maintenance is the default and only updates the standing release PR. Repair requires `operation=repair` plus `release_tag` naming an existing `vX.Y.Z` tag. Repair begins from the immutable tag, repeats the complete proof, and validates any existing GitHub Release target. It compares each asset before writing: matching assets remain untouched, missing assets are added, and mismatches fail closed. A mismatched asset may be replaced only when `replace_mismatched_assets=true` is approved through the protected `release` environment. Repair never moves the tag and never represents a new release.
Manual dispatch accepts `operation=maintenance` or `operation=repair`. Maintenance is the default and only updates the standing release PR. Repair requires `operation=repair` plus `release_tag` naming an existing `vX.Y.Z` tag. Repair begins from the immutable tag, rederives candidate topology and fresh PR authorship from GitHub, and checks identity against repository configuration rather than trusting the tag-carried record. It repeats the complete proof and validates any existing GitHub Release target. It compares each asset before writing: matching assets remain untouched, missing assets are added, and mismatches fail closed. A mismatched asset may be replaced only when `replace_mismatched_assets=true` is approved through the protected `release` environment. Repair never moves the tag and never represents a new release.

## Human-owned safeguards

A human configures an active `v*` tag ruleset that restricts deletion and updates with no bypass actors. A human also configures required `main` checks, merge-commit availability without linear-history enforcement, Actions permissions, required reviewers on the `release` environment, and the `hosted-canary-qualification` environment with scoped `CANARY_GH_TOKEN`, `CANARY_SSH_PRIVATE_KEY`, and `CANARY_SSH_KNOWN_HOSTS` secrets. The token owns GitHub API calls; SSH owns Git transport, including immutable candidates that contain workflow files. Release automation receives narrow job permissions and never repository-administration authority.
A human configures an active `v*` tag ruleset that restricts deletion and updates with no bypass actors. A human also configures an active, no-bypass `main` ruleset that requires pull requests and blocks force pushes. That rule keeps the push event's pre-merge base outside an actor-controlled force-push path. Squash merging remains required for ordinary and release pull requests; two-parent merge commits are optional. The human also configures required `main` checks, Actions permissions, required reviewers on the `release` environment, and the `hosted-canary-qualification` environment with scoped `CANARY_GH_TOKEN`, `CANARY_SSH_PRIVATE_KEY`, and `CANARY_SSH_KNOWN_HOSTS` secrets. The token owns GitHub API calls; SSH owns Git transport, including immutable candidates that contain workflow files. Release automation receives narrow job permissions and never repository-administration authority.

`bun run readiness` reads GitHub and local workflow state without mutation. It fails closed unless the default branch is `main`, merge commits are enabled, classic protection and effective rulesets permit non-linear history, all release-path checks protect `main`, Actions is enabled, the immutable tag ruleset is active, the hosted-canary environment and required secret names exist, and no workflow grants repository administration. Secret values are never read. Automation is enabled only while these safeguards remain ready.
`bun run readiness` reads GitHub and local workflow state without mutation. It fails closed unless the default branch is `main`, squash merging is enabled, an active no-bypass branch ruleset requires pull requests and blocks force pushes on `main`, all release-path checks protect `main`, Actions is enabled, the immutable tag ruleset is active, the hosted-canary environment and required secret names exist, and no workflow grants repository administration. Secret values are never read. Automation is enabled only while these safeguards remain ready.

Installable payload changes require a releasable Conventional Commit PR title: `feat`, `fix`, `perf`, or a breaking `!` title. Documentation-, test-, and CI-only changes are exempt. The pure Release Please version projection is exempt because it changes release identity without changing installable behavior.

Expand Down
38 changes: 37 additions & 1 deletion scripts/release-projection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,38 @@ test("runtime hook files are outside the release projection", () => {
).toThrow("unsupported path")
})

test("projection binds every changed candidate blob to the reviewed pull-request head", () => {
const file = { filename: "plugin.config.json", status: "modified", sha: "reviewed-blob" }
const versions = {
before: '{"version":"0.1.0","name":"x"}',
after: '{"version":"0.2.0","name":"x"}',
afterSha: "reviewed-blob",
}

expect(validateReleaseProjection([file], () => versions).changedFiles).toEqual([
"plugin.config.json",
])
expect(() =>
validateReleaseProjection([{ ...file, sha: "different-blob" }], () => versions),
).toThrow("reviewed head blob")
})

test("projection binds the complete candidate changed-file set to the reviewed pull request", () => {
const file = { filename: "plugin.config.json", status: "modified", sha: "reviewed-blob" }
const versions = {
before: '{"version":"0.1.0","name":"x"}',
after: '{"version":"0.2.0","name":"x"}',
afterSha: "reviewed-blob",
}

expect(validateReleaseProjection([file], () => versions, [file.filename]).changedFiles).toEqual([
file.filename,
])
expect(() =>
validateReleaseProjection([file], () => versions, [file.filename, "plugin/runtime/extra.js"]),
).toThrow("candidate changed-file set")
})

test("changelog projection prepends exactly one current-version section", () => {
const manifest = {
before: '{".":"0.1.0"}',
Expand Down Expand Up @@ -108,8 +140,12 @@ test("projection CLI executes the same policy against Git refs", () => {
expect(run(["git", "add", "plugin.config.json"]).exitCode).toBe(0)
expect(run(["git", "commit", "--quiet", "-m", "version"]).exitCode).toBe(0)
const head = run(["git", "rev-parse", "HEAD"]).stdout.toString().trim()
const headBlob = run(["git", "rev-parse", `${head}:plugin.config.json`]).stdout.toString().trim()
const projection = join(repository, "projection.json")
writeFileSync(projection, '[{"filename":"plugin.config.json","status":"modified","sha":"1"}]\n')
writeFileSync(
projection,
`${JSON.stringify([{ filename: "plugin.config.json", status: "modified", sha: headBlob }])}\n`,
)

result = run([
process.execPath,
Expand Down
Loading