Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
87 changes: 65 additions & 22 deletions .github/actions/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,17 @@ 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.

| 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 |

Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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:

Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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.

Expand Down
10 changes: 9 additions & 1 deletion .github/actions/release-candidate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
`<!-- release-candidate: vX.Y.Z -->`; keep it intact, because the settle
action uses it to find the merged candidate.
- The full flow, installation and recovery: [RELEASE.md](../RELEASE.md).
13 changes: 11 additions & 2 deletions .github/actions/release-publish/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
23 changes: 21 additions & 2 deletions .github/actions/release-route/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>` 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
Expand Down
24 changes: 18 additions & 6 deletions .github/actions/release-settle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 `<!-- release-settle: vX.Y.Z <sha> -->`
the release, followed by the `<!-- release-settle: vX.Y.Z <40-hex> -->`
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
Expand Down
Loading
Loading