From 13f36b2c19be0a3aac9ea2d9062c3ac2585803e9 Mon Sep 17 00:00:00 2001 From: Troublor <14846504+Troublor@users.noreply.github.com> Date: Fri, 30 Oct 2026 03:07:32 +0800 Subject: [PATCH] docs: align release guides with shipped settlement flow --- .github/actions/RELEASE.md | 87 ++++++++++++++----- .github/actions/release-candidate/README.md | 10 ++- .github/actions/release-publish/README.md | 13 ++- .github/actions/release-route/README.md | 23 ++++- .github/actions/release-settle/README.md | 24 ++++-- .github/actions/release-tools/README.md | 15 +++- docs/RELEASING.md | 95 ++++++++++++++------- 7 files changed, 201 insertions(+), 66 deletions(-) diff --git a/.github/actions/RELEASE.md b/.github/actions/RELEASE.md index b9e7700..9de1d40 100644 --- a/.github/actions/RELEASE.md +++ b/.github/actions/RELEASE.md @@ -28,8 +28,9 @@ Contents: ## What it does, and the choices behind it -A release is four phases, each a workflow in the consuming repository calling -one composite action here at `@main`. The workflows carry no decision logic: +A release is four phases across three workflows in the consuming repository: +candidate, settle (including its publish job), and publish targets. They call +the composite actions here at `@main`. The shared actions hold the decisions: the read-only `release-route` action classifies every push and dispatch and answers `cut`, `refresh`, `publish` or `skip`, with a one-line reason in the job log. @@ -37,7 +38,7 @@ job log. | Phase | Trigger | Result | |---|---|---| | **Candidate** | a maintainer dispatches `release-candidate.yml` with a version | a PR on the default branch that bumps the version and drafts the changelog entry; merging it creates `release-vX.Y.Z` at the merge commit | -| **Settle** | a push to `release-vX.Y.Z`: the branch being cut, and every later push to it | `route` answers `refresh`, and `release-settle` opens or updates one reviewed `chore/release-settle-vX.Y.Z` PR onto the release branch, carrying the changelog entry for the commits since the previous tag | +| **Settle** | a push to `release-vX.Y.Z`: the branch being cut, and every later push to it | `route` answers `refresh`; `release-settle` writes nothing for a clean candidate merge, otherwise opens or updates one reviewed `chore/release-settle-vX.Y.Z` PR carrying the changelog entry | | **Publish** | the settle PR merging, or a dispatch of `release-settle.yml` with a version whose branch needs no settle PR | `release-publish` creates the annotated tag `vX.Y.Z` at the settled commit and publishes the GitHub Release with the changelog section as notes | | **Publish targets** | the GitHub Release being published | whatever the repository ships: crates to crates.io, binaries to the artifact registry, downloads on the Release page, composed in `on-release.yml` from the publish-target actions | @@ -57,9 +58,11 @@ The choices that shape it: ruleset forbids updating or deleting them, and its only bypass actors are the repository admin role and the app, so nothing else can create one. - **The settle PR is the release decision.** A push to the release branch - never publishes directly; it opens or refreshes the settle PR, and merging - that PR publishes. When the branch is exactly the candidate merge, with no - fixes on top and no settle PR open, a maintainer can instead dispatch + never publishes directly unless it is the fresh settle PR merging; other + pushes refresh settlement. A clean candidate merge creates no settle PR. + When the branch is exactly the candidate merge, with no + fixes on top and no settle PR or tag, and its changelog section matches the + regenerated notes (or changelog handling is disabled), a maintainer dispatches `release-settle.yml` with the version: the clean state routes straight to `publish`, and the run waits for a required reviewer of the `release` environment. That approval stands in for the PR. @@ -72,9 +75,14 @@ The choices that shape it: published and blocked skip with the reason. A tag that exists at the candidate merge without its Release (a run that died between the two steps) is `resume`: publishing runs again and creates the missing Release. + Dispatch routing assumes a matching changelog; publish verifies the actual + section and refuses a mismatch before tagging. A refresh action also compares + the section, so a stale section creates a settle PR even at the candidate + merge. A tag at a settle-PR merge is blocked by versioned dispatch; recover + that publication by re-running its original push-triggered run. - **The changelog has one owner per phase, and no dates.** The candidate PR - drafts the entry under an undated `## vX.Y.Z` heading from the merged PR - titles and syncs the previous release's final entry from its tag. The + drafts the entry under an undated `## vX.Y.Z` heading from non-merge + commit subjects and syncs the previous release's final entry from its tag. The settle PR re-drafts the same undated entry over the commits that actually ship. The Release body is that section read from the tagged tree, verbatim; nothing stamps a date anywhere. The default branch catches up in @@ -97,7 +105,7 @@ The choices that shape it: - **A lint holds those rules.** `app-token-lint` runs on every workflow change (the `app-token-lint.yml` template). Rule L2' allows a Gutenberg mint only in `release-candidate.yml`, `release-settle.yml`, - `on-release.yml` and `release-publish-*.yml`; rule L6 requires every app + `on-release.yml` (exact filenames, not prefixes); rule L6 requires every app token in a release workflow to list its `permission-*` inputs explicitly, because a token minted without them gets every permission of the installation, contents write included. @@ -125,10 +133,10 @@ Commands assume `gh` authenticated as a maintainer and the default branch gh workflow run release-candidate.yml --ref main -f version=1.2.3 ``` -Within a minute the app opens a candidate PR from the branch +The app opens a candidate PR from the branch `chore/release-candidate-1.2.3`: the version file bumped, whatever `bump_command` maintains (a lockfile, path-dependency versions), the -changelog entry `## v1.2.3` drafted from the merged PR titles since the last +changelog entry `## v1.2.3` drafted from non-merge commit subjects since the last `v*` tag (grouped by Conventional Commit type, with PR links), and the previous release's entry synced from its tag. Review it like any PR; fix wording in it if the generated entry needs work. @@ -153,8 +161,14 @@ runs `release-settle.yml`. Once the candidate has landed on the branch, `route` answers `refresh` and the settle job opens (or refreshes) the PR from `chore/release-settle-v1.2.3` onto the release branch, with the entry for the commits since the previous tag under the undated `## v1.2.3` -heading. Review it like any PR. Nothing is published yet, and every new push -to the branch refreshes the same PR. +heading when the state is `needs-settle-pr`. At the candidate merge with no +open settle PR or tag and a section matching regenerated notes (or changelog +handling disabled), the state is `clean`: the action skips all write steps +and creates no settle PR. Its summary says the release branch is unchanged +at the candidate merge and nothing was written. Push routing cannot see +changelog state, so its `refresh` answer can legitimately end in this clean +no-op. Use the versioned dispatch in step 4 to publish that branch. Otherwise, +review the settle PR like any PR; each new push refreshes it. **4. Publish**, one of two ways: @@ -172,10 +186,15 @@ to the branch refreshes the same PR. ``` When the branch tip is exactly the candidate merge, with no open settle PR - and no tag, the settlement state is `clean` and routes straight to - `publish`. A branch with commits beyond the candidate merge, or an open + and no tag, dispatch routing assumes a clean changelog and routes straight to + `publish` with `settle_source=dispatch`. Publish compares the committed + section with regenerated notes and refuses a mismatch; dispatch with a + blank `version` to refresh that section through a settle PR first. + A branch with commits beyond the candidate merge, or an open settle PR, routes to `refresh` instead; a version already published, or one whose candidate is not an ancestor of the tip, skips with the reason. + A tag not at the candidate merge also skips, even when a settle PR created + it; re-run the original push-triggered run to recover that publication. A blank `version` refreshes every eligible release branch and never publishes. @@ -332,6 +351,25 @@ this repository): python3 .github/scripts/app_token_lint.py --repo REPO /path/to/REPO/.github/workflows ``` +Required PR checks must also run against release branches. A consumer +`pull_request.branches: [main]` filter must include `"release-v*"` too +(substitute the actual default branch); audit job conditions, path filters, +test workflows and any `compat:` label check. Otherwise a candidate targeting +a release branch, and the follow-up settle PR, cannot satisfy those required +checks. Carry the corrected CI workflows onto existing release branches. +For `base_commit` candidates, include them in `release_workflows`, for example: + +```yaml +release_workflows: | + .github/workflows/release-settle.yml + .github/workflows/on-release.yml + .github/workflows/lint.yml +``` + +The default carry list includes only the two release workflows, not lint or +tests. Fix the CI filters on the default branch before proposing the candidate +so its PR carries the corrected files. + **6. The changelog.** Create `CHANGELOG.md` with a preamble and nothing else; the first candidate PR adds the first entry. A previous, hand-made release history is not needed: the notes for the first pipeline release are @@ -371,7 +409,9 @@ naming those repositories, never the release token. Examples: path dependencies pinned to the workspace version, then the lockfile. **Labels** (`pr_labels`, candidate and settle): for repositories whose label -gates apply to the app's PRs. +gates apply to the app's PRs. The templates leave this empty: where a consumer +requires a `compat:` label, the reviewer chooses it for each candidate or +settle PR. The shared actions do not enforce a compatibility-label gate. **Branch prefix** (`release_branch_prefix`, default `release-v`): change it everywhere at once, including the `branches:` filter in `release-settle.yml` @@ -487,19 +527,22 @@ workflow-wide group would let such a run evict a queued release run), the candidate refresh runs one per PR, the settle refresh and publish one per release branch, and `on-release` one per tag. -**The tag already exists** when settling: that version is released, and the -refresh refuses to touch it. Pick the next version and start from the -candidate. +**The tag already exists** when settling: the refresh refuses to touch it. +If its GitHub Release exists too, pick the next version and start from the +candidate. If not, recover the interrupted publication as below. **A publish died between the tag and the Release.** The half-published state (tag at the candidate merge, no Release) is `resume`: re-run the failed run, -or dispatch `release-settle.yml` with the version. Either routes to -`publish`, skips the tag step and creates the missing Release. +or dispatch `release-settle.yml` with the version. For a tag created by +merging a settle PR, re-run the original push-triggered run at that merge: +a versioned dispatch refuses a tag not at the candidate merge. The appropriate +path skips the tag step and creates the missing Release. **A publish target failed after the tag was created**: fix the cause on the default branch. If the fix is in the shared action, dispatch `on-release.yml` again on the tag (`--ref vX.Y.Z -f dry_run=false`); all -targets are idempotent, so the ones that succeeded are no-ops. If the fix is +registry and crate targets skip identical or already-published versions, +while Release assets are uploaded again with `--clobber`. If the fix is in the workflow file itself, it cannot reach that tag's tree; the next release carries it. diff --git a/.github/actions/release-candidate/README.md b/.github/actions/release-candidate/README.md index 1cc964c..31f6222 100644 --- a/.github/actions/release-candidate/README.md +++ b/.github/actions/release-candidate/README.md @@ -108,13 +108,14 @@ both from `workflow-templates/release-candidate.yml`: version_pattern: toml changelog_file: CHANGELOG.md bump_command: cargo update --workspace # propose only; needs the toolchain installed first + dependency_token: ${{ github.token }} # use a separate read-only App token for private dependencies ``` ## Notes - `propose` bumps the version file, runs `bump_command` with `OLD_VERSION` and `NEW_VERSION` set, drafts the `## vX.Y.Z` changelog entry from the - merged PR titles since the last `v*` tag, syncs the previous release's + non-merge commit subjects since the last `v*` tag, syncs the previous release's entry from its tag, and opens the candidate PR from a new `chore/release-candidate-X.Y.Z` branch. It never rewrites a branch: a candidate PR still open for that version is closed with a comment saying a @@ -142,4 +143,11 @@ both from `workflow-templates/release-candidate.yml`: any other commit it refuses and names both SHAs. - `token` must be an App installation token: a `GITHUB_TOKEN` push would not trigger the consumer's CI on the candidate PR. +- With `base_commit`, the candidate targets `release-vX.Y.Z`, so the consumer's + required lint, test and label checks must include that base branch, not just + the default branch. Add corrected CI workflows to `release_workflows` to + carry them onto the old tree; the default list carries only the settle and + publish-target workflows. The candidate body carries + ``; keep it intact, because the settle + action uses it to find the merged candidate. - The full flow, installation and recovery: [RELEASE.md](../RELEASE.md). diff --git a/.github/actions/release-publish/README.md b/.github/actions/release-publish/README.md index 51306f5..4ab4a46 100644 --- a/.github/actions/release-publish/README.md +++ b/.github/actions/release-publish/README.md @@ -107,6 +107,13 @@ repeats the entire state check inside the tag step immediately before tagging, so branch drift during an environment approval is refused for a fresh publish. Both refusal messages state the fresh-publish equality rule and the existing-tag ancestry rule. +These are composite-action inputs, not workflow dispatch inputs: +`release-settle.yml` accepts only `version`, and its route supplies the commit, +candidate PR number and `settle_source=dispatch` to the publish job. Dispatch +routing assumes a matching changelog; this action compares the committed +section with regenerated notes and refuses stale notes before tagging. +Refresh them through a blank-version settle dispatch and merge the resulting +settle PR instead. ## Notes @@ -131,5 +138,7 @@ ancestry rule. push; tags that are not `vX.Y.Z` are ignored. - The tag push comes from the app token so `push: tags` and `release` workflows fire; the app must bypass the `v*` tag ruleset. -- This workflow must exist on the release branch (it does when the branch - was cut from a default branch that has it). +- The caller, `release-settle.yml`, must exist on the release branch for its + push-triggered publish job to run (it does when the branch was cut from a + default branch that has it). Publication is that workflow's `publish` job, + not a separate consumer workflow. diff --git a/.github/actions/release-route/README.md b/.github/actions/release-route/README.md index f1348d5..649a1e7 100644 --- a/.github/actions/release-route/README.md +++ b/.github/actions/release-route/README.md @@ -84,13 +84,32 @@ branch or that release branch, `candidate_is_ancestor` from a Git ancestry check against its tip, `open_settle_pr` from the open settle PR lookup, and `published_tag_sha` from the peeled release tag (or the lightweight tag). All four keys are always present: missing SHAs and ancestry without a candidate -are empty strings, and no open settle PR is `false`. Dispatches without a +are empty strings, and no open settle PR is `false`. A selected branch without a +candidate merge fails with `missing settlement facts: candidate_merge_sha`; +it does not route to a successful skip. Dispatches without a version leave these facts empty or false. Each dispatch branch also includes `release_exists`, queried with `gh release view v` when its tag exists and otherwise `false`. Together with the tag facts, this lets an already published version route -to `skip` instead of `publish`. +to `skip` instead of `publish`. The tag must point at the candidate merge: +a tag at a settle-PR merge is `blocked` by versioned dispatch, whether or not +its Release exists. Recover an interrupted settle-PR publication by re-running +its original push-triggered run, not by versioned dispatch. + +For an ordinary push after the candidate lands, routing returns `refresh` +without comparing changelog state. The settle action then compares the +section and may find `clean`, write nothing and create no PR. Publish that +branch with a versioned dispatch. Dispatch routing also does not compare +the section: it assumes a match, and `release-publish` refuses stale notes +before tagging. Use a blank-version dispatch to refresh stale notes first. +The versioned dispatch route does not check version ordering; the publish +guard still refuses versions not newer than every other `vX.Y.Z` tag. + +The workflow accepts only `version` on dispatch. A blank version refreshes all +eligible branches and never publishes. The `version`, `release_branch` and +`commit` outputs also describe a selected versioned dispatch; a resume uses +the existing tag's peeled commit rather than a branch tip that has advanced. A push to a `release_branch_prefix` branch whose suffix is not `X.Y.Z` (for example `release-vnext`) routes to `skip` with a reason naming the branch; a diff --git a/.github/actions/release-settle/README.md b/.github/actions/release-settle/README.md index e9234b4..2675c45 100644 --- a/.github/actions/release-settle/README.md +++ b/.github/actions/release-settle/README.md @@ -75,8 +75,9 @@ environment; the publish job that tags and creates the Release declares - uses: megaeth-labs/.github/.github/actions/release-settle@main with: token: ${{ steps.app-token.outputs.token }} - version: ${{ inputs.version }} - commit: ${{ inputs.commit }} # full SHA of the release-branch tip + version: ${{ matrix.target.version }} + commit: ${{ matrix.target.commit }} # full SHA of the release-branch tip + release_branch: ${{ matrix.target.release_branch }} version_file: Cargo.toml version_pattern: toml changelog_file: CHANGELOG.md @@ -85,19 +86,30 @@ environment; the publish job that tags and creates the Release declares The action itself never tags and never creates a Release: it only maintains `chore/release-settle-vX.Y.Z`. Publishing is `release-publish`, called by the template's publish job on a clean dispatch or on the settle PR's merge. +The workflow's sole dispatch input is `version`; a blank value refreshes +every eligible release branch. `commit` is an action input supplied by the +route's refresh-target matrix, not a workflow dispatch input. ## Notes - Before generating notes or ensuring the PR, `settle-state` compares the release-branch tip with the merged candidate PR, checks ancestry, and reads - the open settle PR, tag and GitHub Release facts. If the branch is unchanged - at the candidate merge with no open settle PR or tag, the action succeeds - with step outputs `settled=false` and `state=clean`. The run summary says + the open settle PR, tag and GitHub Release facts, and compares the committed + changelog section with regenerated notes. If the branch is unchanged + at the candidate merge with no open settle PR or tag and a matching section + (or changelog handling is disabled), the action succeeds + with internal step outputs `settled=false` and `state=clean`; these are not + public action outputs. The public `pr_url` and `notes_file` are empty. + The run summary says nothing was written: git identity configuration, notes generation and the entire ensure-PR step are skipped, so no settle branch, commit, PR creation, update or comment is made. Only `needs-settle-pr` reaches the existing PR flow, including its deliberate `--allow-empty` commit when a fresh branch records settlement. +- Push routing cannot see changelog state and returns `refresh` even when this + action will find `clean`; that is a successful no-op, not a missing PR. + Publish a clean branch by dispatching `release-settle.yml` with its version. + A blank-version dispatch only refreshes and never publishes. - Guards, in order: `commit` is a full SHA; the release branch exists; the tag does not; `commit` is the branch tip; the version file at `commit` says the version; the version is newer than the latest `v*` tag. Then a @@ -110,7 +122,7 @@ template's publish job on a clean dispatch or on the settle PR's merge. - `pr`: pushes `chore/release-settle-vX.Y.Z` and opens the settle PR, titled `chore(release): settle vX.Y.Z — merging publishes the release`. Its body opens with an `[!IMPORTANT]` alert saying that merging it settles - the release, followed by the `` + the release, followed by the `` marker that `release-route` and `release-publish` read. A new tip on the release branch merges into the same PR and moves the marker; a re-run with nothing new changes nothing. Only a PR that is closed, merged or not diff --git a/.github/actions/release-tools/README.md b/.github/actions/release-tools/README.md index 7322548..da8c76d 100644 --- a/.github/actions/release-tools/README.md +++ b/.github/actions/release-tools/README.md @@ -10,11 +10,15 @@ only. The actions call them as `python3 $GITHUB_ACTION_PATH/../release-tools/\t"` lines → the Markdown section: heading `## vX.Y.Z (DATE)` (no date when `DATE` is empty), commits grouped by Conventional Commit type, PR links from `(#N)` suffixes, `chore(release):` commits dropped | | `changelog-insert PATH VERSION SECTION_MD` | insert or replace the version's section; prints `inserted` / `replaced`; keeps exactly one blank line between blocks so formatters leave the file alone | -| `changelog-extract PATH VERSION` | the section body on stdout; exit 1 if absent | +| `changelog-extract PATH VERSION [--include-heading]` | the section body on stdout, or the section including its heading with the flag; exit 1 if absent | | `changelog-copy SRC DST VERSION` | copy the version's section (heading included) from one changelog into another; prints `inserted` / `replaced` / `unchanged` / `absent` | Version patterns: `plain` is the whole file, trimmed; `toml` is the first @@ -22,6 +26,15 @@ line of the form `version = "…"` at column 0 (the `[package]` or `[workspace.package]` version, never a dependency's); `json` is the first `"version": "…"`. +`settle-state` returns `clean` only at the candidate merge with no open settle +PR or tag and a matching changelog section (`--section-matches` defaults to +true). A non-ancestor candidate is blocked; an existing tag at a different +commit is blocked before checking whether its Release exists. A tag at the +candidate merge is `published` with a Release and `resume` without one. +`route` does not supply a changelog comparison for a versioned dispatch; +the publish guard checks it. Ordinary release-branch pushes route to +`refresh`, and the settle action may then find `clean` and write nothing. + ## `crates_tools.py` Helpers for `release-publish-rust-crates`: parse the crate list, check every diff --git a/docs/RELEASING.md b/docs/RELEASING.md index c39314c..a467e16 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -10,12 +10,12 @@ per-action READMEs linked below. | Identity | Does | Never does | |---|---|---| | `mega-gutenberg[bot]` (the release app) | Every write of the release: the candidate PR, the `release-vX.Y.Z` branch, the settle PR, the tag, the GitHub Release | Nothing is approved by it; it is not a reviewer | -| `mega-maxwell[bot]` (the CI app) | Only ever reads, as far as a release is concerned: at most a read-only token for private dependencies | Create a branch, PR, tag or Release of a release; appear as a git identity or PR-author filter in a release workflow | -| A human reviewer | Reviews and merges the candidate PR and the settle PR; approves the `release` environment when a dispatch publishes; adds the `compat:` label | - | +| `mega-maxwell[bot]` (the CI app) | CI outside the release workflows | Mint any token in a release workflow, even read-only; appear as a git identity or PR-author filter there. Private dependency reads use a separate read-only Gutenberg token. | +| A human reviewer | Reviews and merges the candidate PR and, when needed, the settle PR; approves the `release` environment for either publish path; adds the `compat:` label where the repository requires it | - | The [app-token lint](#app-token-lint) refuses a gutenberg token anywhere outside the allowlisted release workflow files (`release-candidate.yml`, -`release-settle.yml`, `on-release.yml`, `release-publish-*.yml`, rule L2') on +`release-settle.yml`, `on-release.yml`, rule L2') on every workflow change. The publish job runs in the `release` environment; the jobs that propose, cut and settle run without one, and the candidate's `refresh` job mints its token without one too. @@ -23,8 +23,8 @@ and the candidate's `refresh` job mints its token without one too. ## The four flows Each flow starts from a push (or a merge) the previous one made; there is no -polling. The only manual triggers are the candidate dispatch of flow 1 and the -versioned settle dispatch of flow 3. The read-only `route` step +polling. The core workflows accept a candidate dispatch in flow 1 and a settle +dispatch in flows 2 and 3. The read-only `route` step ([`release-route`](../.github/actions/release-route/README.md)) decides what a push or a dispatch means: `cut`, `refresh`, `publish` or `skip`, with a one-sentence reason in the job log. @@ -32,9 +32,10 @@ one-sentence reason in the job log. 1. **Candidate: push to the default branch, then the cut.** An operator starts the Release Candidate workflow with the version (and optionally `base_commit`). Gutenberg opens a `chore/release-candidate-X.Y.Z` PR that - bumps the version file and drafts the changelog entry under an undated - `## vX.Y.Z` heading; the notes generator is never given a date. A reviewer - adds the `compat:` label (see [the reviewer step](#the-reviewer-step-the-compat-label)) + bumps the version file and drafts the changelog entry from non-merge commit + subjects under an undated `## vX.Y.Z` heading; the notes generator is never + given a date. A reviewer adds the `compat:` label where required + (see [the reviewer step](#the-reviewer-step-the-compat-label)) and merges it. The merge is a push to the default branch; `route` finds the merged, gutenberg-authored candidate PR and answers `cut`, and [`release-candidate`](../.github/actions/release-candidate/README.md) creates @@ -52,9 +53,17 @@ one-sentence reason in the job log. 2. **Settle: push to `release-vX.Y.Z`, then the settle PR.** The creation of the branch (and every later push to it) starts the Release Settle workflow. - Once the candidate has landed on the branch, `route` answers `refresh` and - [`release-settle`](../.github/actions/release-settle/README.md) opens or - updates one long-lived `chore/release-settle-vX.Y.Z` PR onto the release + Once the candidate has landed on the branch, `route` answers `refresh`. + [`release-settle`](../.github/actions/release-settle/README.md) then checks + the changelog too: if the tip equals the candidate merge, no settle PR or + tag exists, and the section matches regenerated notes (or changelog handling + is disabled), the state is `clean`. It writes nothing and creates no settle + PR; publish it with the versioned dispatch in flow 3. Push routing cannot + see changelog state, so `refresh` in the route log and `clean` in the action + are consistent, not a failed PR creation. + + Otherwise, `needs-settle-pr` opens or updates one long-lived + `chore/release-settle-vX.Y.Z` PR onto the release branch, with the changelog entry for the commits since the previous tag. The heading stays undated here too. New commits on the release branch refresh the same PR. A dispatch with a blank `version` refreshes every eligible @@ -63,7 +72,8 @@ one-sentence reason in the job log. 3. **Publish: merge the settle PR, or dispatch a clean branch.** Two paths, one publish step. - *Merge the settle PR:* a reviewer adds the `compat:` label and merges it. + *Merge the settle PR:* a reviewer adds the `compat:` label where required + and merges it. The merge is a push to the release branch; `route` re-checks that the PR is gutenberg's, that it settled exactly the branch tip it was merged onto, and answers `publish`. This is the path a branch takes when it carries fixes on @@ -76,9 +86,15 @@ one-sentence reason in the job log. commit is an ancestor of the branch tip, whether a settle PR is open, and whether the tag and the GitHub Release exist. A clean state (the tip equals the candidate merge, no settle PR is open, the version is unpublished) - routes to `publish`. Commits beyond the candidate merge or an open settle PR + routes to `publish` with `settle_source=dispatch`. Dispatch routing does not + compare the changelog section; publish checks it against regenerated notes + and refuses a mismatch before tagging. Refresh first with a blank-version + dispatch if that happens. Commits beyond the candidate merge or an open settle PR route to `refresh` instead. A version whose tag and Release both exist is - skipped, and so is one whose candidate is not an ancestor of the tip. A tag + skipped when its tag is at the candidate merge, and so is one whose candidate + is not an ancestor of the tip. A tag elsewhere is blocked by this dispatch + path, including one created from a settle PR; re-run that publication's + original push-triggered run to recover it. A tag that exists at the candidate merge without its Release (a run that died between the two steps) routes to `publish` again and resumes. @@ -92,8 +108,8 @@ one-sentence reason in the job log. no date is injected. A settle PR that went stale before it merged publishes nothing; `route` answers `refresh` and a fresh PR follows. -4. **Assets: the tag push (the Release) fires On Release.** Publishing the - Release runs the repository's `on-release.yml`, which builds, verifies the +4. **Assets: publishing the Release fires On Release.** The `release: published` + event runs the repository's `on-release.yml`, which builds, verifies the version ([`release-verify-version`](../.github/actions/release-verify-version/README.md)), uploads ([`release-upload-artifact`](../.github/actions/release-upload-artifact/README.md)), attaches [release assets](../.github/actions/release-assets/README.md), and @@ -167,19 +183,33 @@ copy before pushing (from a checkout of this repository): python3 .github/scripts/app_token_lint.py --repo my-repo /path/to/my-repo/.github/workflows ``` -Each template ships with a `.properties.json` sidecar that feeds the "New -workflow" picker; keep the pair together when copying. The templates carry the -mega-reth values as the worked example: `Cargo.toml` with `version_pattern: -toml`, `cargo update --workspace` as the bump command, and the private -dependency repos as the read-only dependency token's `repositories`. +Each template ships with a `.properties.json` sidecar for the organization's +"New workflow" picker; consumers copy the YAML into `.github/workflows/`, +not the sidecar. The core templates use `VERSION` with `version_pattern: plain`; +the bump command is a commented example, not an enabled Cargo setup. Use +`.github/scripts/copy_templates.py` to render the core workflows with the +repository's version file, pattern, bump command and private dependency repos. + +Required PR checks must run for PRs targeting `release-v*`, not just the +default branch. For example, a consumer `lint.yml` with +`pull_request.branches: [main]` needs `[main, "release-v*"]` (use the actual +default branch). Check test and label workflows too, including job conditions +and path filters. A candidate aimed at a release branch otherwise has no +required checks, and the follow-up settle PR has the same problem. For +`base_commit` candidates, add the corrected lint/test workflows to +`release_workflows` so they are carried from the default branch in the candidate +PR; the default list carries only `release-settle.yml` and `on-release.yml`. +An existing release branch needs those CI changes carried onto it too. ## The reviewer step: the `compat:` label -Every PR into the default branch or a `release-*` branch must carry a label -starting with `compat:` (mega-reth's required check "Compatibility Label Check", -in `pr-title.yml`). The patch/minor/major call is a per-release judgement, so +Where the consumer requires it, every PR into the default branch or a +`release-*` branch must carry a label starting with `compat:` (for example, +mega-reth's "Compatibility Label Check" in `pr-title.yml`). This is a consumer +check, not a gate implemented by the shared release actions. +The patch/minor/major call is a per-release judgement, so the pipeline leaves `pr_labels` empty on the candidate and settle PRs. The -reviewer adds the label to both PRs while reviewing. `pr-title.yml` also +reviewer adds the label to both PRs while reviewing. Mega-reth's `pr-title.yml` also listens for `labeled`, so adding the label re-runs the check and turns it green; no push or manual re-run is needed. @@ -200,10 +230,10 @@ run; `::warning::` and the route `skip` reason do not. | `refusing to publish settle PR #N: vX.Y.Z is not newer than the latest published tag vA.B.C (rule: a version is published only when it is newer than every vX.Y.Z tag)` | An older line (a hotfix branch) was settled after a newer release was published. It is not tagged, so the newer Release stays latest. Cut the fix as a version above vA.B.C instead. | | `vX.Y.Z is already published` | The tag and the Release both exist. | | `merged settle PR #N was stale (settled …, tip was …); a fresh settle PR follows` | The branch moved after the settle PR was written. Review and merge the fresh PR. | -| `vX.Y.Z is tagged but has no Release: re-run the release-settle run for the settle PR's merge commit … to finish publishing` (also a `::warning::`) | `publish` died between tag and Release. Re-run the failed run for that merge commit, or dispatch Release Settle with the version: the half-published state routes to `publish` and creates the missing Release. | +| `vX.Y.Z is tagged but has no Release: re-run the release-settle run for the settle PR's merge commit … to finish publishing` (also a `::warning::`) | `publish` died between tag and Release. For a settle-PR publication, re-run the original push-triggered run at that merge commit. A versioned dispatch resumes only a tag at the candidate merge, not a settle-PR merge. | | `vX.Y.Z already has a published tag and Release` | A versioned dispatch named a finished release. Nothing to do. | | `candidate merge … is not an ancestor of tip …` | A versioned dispatch, but the branch history no longer contains the candidate merge (rewritten, or the wrong branch). Skipped; restore the branch or start the release again. | -| `vX.Y.Z points to …, not candidate merge …` | The tag exists at a commit that is not the candidate PR's merge. Skipped; investigate the tag before doing anything else. | +| `vX.Y.Z points to …, not candidate merge …` | Versioned dispatch blocks a tag not at the candidate merge. A settle-PR merge tag is legitimate: re-run its original push-triggered run if its Release is missing. Investigate any other unexpected tag target. | | `release branch release-vX.Y.Z does not exist` | A versioned dispatch named a version that was never cut. | | `no route for kind=…, event=…, ref=…` | The event is not one the pipeline handles. | @@ -218,12 +248,12 @@ run; `::warning::` and the route `skip` reason do not. | `candidate PR #N targets '…', not the default branch '…'` | Only a candidate merged into the default branch is cut. | | `branch already exists: vX.Y.Z was already cut from its merged candidate, so there is nothing left to propose; settle it from ` | The release was proposed before. Settle the existing branch, or delete it and propose again. | | `release candidates start from the default branch (…); this run is on …` | Dispatch the candidate workflow from the default branch. | -| `base_commit … is not reachable from the default branch (…); a release must ship code that landed on trunk` | Choose a commit that is on the default branch. | +| `base_commit … is not reachable from the default branch (…); a release must ship code that landed on trunk` | Choose a commit reachable from the default branch, or on the latest release line between its merge-base with trunk and the latest exact `vX.Y.Z` tag. Other side branches are refused. | | `$FILE at $COMMIT says X, expected Y` | The version file does not carry the release version at that commit. | | `could not merge … into …; close the settle PR so the next run recreates it` | A refresh conflicts with the settle PR branch. Close the settle PR; the next settle run recreates it. | | `… is not the tip of release-vX.Y.Z (tip is …). Settle the tip, or move the branch first.` | The branch moved while the settle ran. Re-run on the current tip. | | `release branch drifted: settle PR settled X but the branch tip at merge was Y. Re-run release-settle on the current tip.` | The settle PR was merged after the branch moved. Re-run, review the fresh PR. | -| `settle PR body has no '' marker; refusing to publish an unverifiable settlement` | The PR body was edited. Close it and let settle recreate it. | +| `settle PR body has no '' marker; refusing to publish an unverifiable settlement` | The settlement cannot be verified. The PR is already merged on this path: refresh the current release tip with a blank-version settle dispatch and review the replacement PR; do not invent a marker. | | `settle PR #N was opened by '…', not by 'mega-gutenberg[bot]' …; refusing to publish from it` | A hand-made settle PR never publishes. | | `tag vX.Y.Z already exists (at …), not at …; a release is published once` | Publish refuses to retag. Investigate the existing tag. | | `Refusing to publish an older version::settle PR #N: vX.Y.Z is not newer than the latest published tag vA.B.C …` | Publish re-checks the route's order rule, in the guard and again right before the tag push. Nothing was tagged. | @@ -231,10 +261,11 @@ run; `::warning::` and the route `skip` reason do not. | `release-vX.Y.Z moved to … after this refresh of … started …` | A refresh lost the race to a settle-PR merge or a newer push. It wrote nothing; the run for the new tip refreshes the settle PR. | | `::notice title=Already tagged::vX.Y.Z is already tagged, so this refresh … does not open or update a settle PR for it` | The release was published while this refresh ran. Nothing to do. | | `settle_source must be pr or dispatch, got '…'` | The publish job's wiring is broken; fix the workflow. | +| `changelog section for vX.Y.Z at … disagrees with regenerated notes; let the settle refresh land first, then publish.` | Dispatch routing does not compare notes. Dispatch with a blank `version` to refresh; review and merge the resulting settle PR rather than retrying the unchanged versioned dispatch. | | `candidate PR #N is not merged` / `candidate PR #N merge_commit_sha is '…', not the requested commit …` | A dispatch publish, but the candidate PR route resolved is not the merged candidate for this version. | | `release_branch is required for dispatch settlement` | The publish job did not pass the route's `release_branch` output. | | `candidate PR #N head ref is '…', expected chore/release-candidate-X.Y.Z` / `candidate PR #N base is '…', expected '…' or '…'` / `candidate PR #N author is '…', expected '…'` | A dispatch publish re-verifies the candidate PR through the API and refuses a PR that is not gutenberg's candidate into the default or release branch. | -| `release branch '…' tip is '…', expected …` | The branch moved between the dispatch and the publish. Dispatch again for the new tip. | +| `release branch '…' tip is '…'; a fresh publish without a tag requires exact tip equality …` / `… resuming an existing tag requires … to be an ancestor of the tip …` | The branch moved or no longer contains the tagged candidate. A new versioned dispatch refreshes a moved, untagged branch through a settle PR; an existing candidate tag can resume only while it remains an ancestor. | | `::warning::… lacks workflows that has: …` | The release branch is missing a workflow that the default branch has (usually `on-release.yml`). A release-event workflow runs from the tagged commit's tree, so add it to the branch through its own PR before merging the settle PR, or the Release publishes with nothing attached. | ### App-token lint errors @@ -244,11 +275,11 @@ Each is reported as `::error file=::/: : `. | Code | Message | Fix | |---|---|---| | L1 | `Maxwell token requests permission-contents: write; Maxwell may only write pull-requests and issues` | Drop the permission, or mint a gutenberg token in one of the allowlisted release workflow files. A `permission-…` that is an expression is refused too. | -| L2' | `Gutenberg token may be minted only in release-candidate.yml, release-settle.yml, on-release.yml or release-publish-*.yml; file is …` | Move the token step into one of the allowlisted release workflow files. | +| L2' | `Gutenberg token may be minted only in on-release.yml, release-candidate.yml, release-settle.yml; file is …` | Move the token step into one of the three exact allowlisted release workflow files; filename prefixes do not grant access. | | L3 | `no Maxwell token in a release workflow; mint a Gutenberg token instead` | Replace the Maxwell token. | | L3 | `read-only dependency token names the repository itself (…)` | Remove the repository from `repositories:`; the checkout uses the job token. | | L4 | `mega-maxwell[bot] is not a release identity` | Use `mega-gutenberg[bot]` as git identity and PR-author filter. | -| L5 | ``environment `release` is only for release-candidate.yml, release-settle.yml and on-release.yml`` | Move the job, or use a different environment. | +| L5 | ``environment `release` is only for on-release.yml, release-candidate.yml, release-settle.yml`` | Move the job, or use a different environment. | | L6 | `Maxwell token requests no permission-* inputs, so it gets every permission of the installation (contents write included); list the permissions it needs` (also for Gutenberg, and for any app token in a release job) | Add the `permission-:` inputs the job needs. | | parse | `malformed YAML …`, `not a workflow: the document is not a mapping`, `no such file or directory`, `no workflow files (*.yml, *.yaml) found` | Fix the input; a file that cannot be read never lints clean. |