Skip to content

feat(release)!: run the release pipeline as mega-gutenberg - #55

Merged
Troublor merged 15 commits into
mainfrom
ci/gutenberg-release-identity
Oct 27, 2026
Merged

Troublor merged 15 commits into
mainfrom
ci/gutenberg-release-identity

Conversation

@Troublor

@Troublor Troublor commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Runs the shared release pipeline as the mega-gutenberg GitHub App instead of mega-maxwell. Maxwell is read-only and is excluded from the release pipeline entirely (plan decision D12), including the private-dependency tokens that release jobs read.

Release settling is push-driven and the old settle/explicit-publish split is gone. Merging the settle PR is the release decision, and the PR says so in an [!IMPORTANT] alert at the top.

Breaking changes for consumers

  • settle_mode and settlers inputs are removed.
  • The explicit-commit publish path is removed.
  • cut and publish now require commit and pr_number outputs from release-route; callers that do not pass them will fail.
  • Identity defaults changed to mega-gutenberg; callers relying on the old Maxwell defaults need the mega-gutenberg app installed and its publish/release environment credentials configured.

What is in this PR

  • release-tools: the routing decision, plus hardening so no branch name, tag or null field can crash it.
  • release-route and release-settle composite actions, the latter rewritten as a long-lived bot-maintained settle PR.
  • release-candidate and release-publish on the gutenberg identity, publish requiring the merged settle PR.
  • app-token-lint and its action.
  • workflow-templates/ with the copy helper.
  • docs/RELEASING.md, the operator runbook.

Design rules this PR now enforces

  1. Merging the settle PR settles the version — visibly. The PR opens with a GitHub alert: merging tags the merge commit and publishes the Release; closing it settles nothing; a new commit on the release branch moves the settlement to that tip. The title reads chore(release): settle vX.Y.Z — merging publishes the release.
  2. Every step tolerates faults. Retried after success, retried after half-failure, or two runs racing: cut exits 0 when the release branch is already at the merge commit and names both SHAs when it is not; publish with tag and Release present is a successful no-op returning the existing release URL; branch, tag and Release creation races fall back to the winner; stale ref deletes treat 404/422 as done; the settle refresh resets its checkout every pass.
  3. No branch is ever force-pushed. The release branch and the settle branch never were. The candidate flow no longer routes through create-pull-request's --force-with-lease: it closes the open candidate PR, deletes the head ref, plain-pushes a fresh branch and opens a new PR. test_no_force_push.py fails the build if any action ever gains a force push.

Defects found by review, fixed here rather than later

  • An older version could be published after a newer one and marked GitHub's "Latest". Publish now refuses a version that is not newer than every other vX.Y.Z tag, and the Release is marked latest only when it truly is.
  • A non-X.Y.Z v* tag (v1.2.0-rc1) was chosen as "latest" and made routing fail permanently. One shared latest-tag helper now ignores those tags everywhere.
  • app_token_lint accepted an app token with no permission-* inputs, which inherits contents write. It now rejects them.
  • Refresh could open a settle PR for an already-published version. It re-checks right before every remote write.
  • An unreadable version file was reported as "candidate has not landed", silently skipping every push. It fails loudly now.
  • copy_templates.py rendered unquoted values, so a # in a substituted value truncated it.
  • The release-route harness was not hermetic on a real runner: GitHub ignores env: overrides for its own default GITHUB_* variables, so the job silently used the real repository. The harness now passes the repository explicitly on every product invocation.

Test plan

  • CI on this PR: 13/13 checks, including release-route, release-settle, release-candidate, release-publish and the templates lint.
  • Locally: both unittest suites, action_docs.py --check 0, actionlint at the one pre-existing SC2001, app_token_lint clean over the whole tree.
  • Every review fix ships with a test that failed before it and passes after.
  • Identity: every commit is authored and committed by Troublor <14846504+Troublor@users.noreply.github.com> with no co-author trailer.

release-candidate cuts on push to the default branch through release-route;
release-settle refreshes the long-lived settle PR on push to release-v* and
publishes when it merges. Every token is a mega-gutenberg token minted in the
release environment. release-publish.yml is gone, on-release.yml comments
name release-publish (gutenberg) as the Release creator, and app-token-lint.yml
adds the per-repo lint.

copy_templates.py renders the release templates for one repository into a
fresh directory and fails on an unfilled placeholder or a with: key that is
not an input of the called action; its unit test runs in actions-test.yml.
A push to a prefix branch whose suffix is not X.Y.Z (e.g. release-vnext)
made the action's normalize call and release_tools route abort. The push
now routes to skip with a reason naming the branch, a manual dispatch
leaves such branches out of the targets, JSON null fields act like absent
ones, wrong types fail with one release_tools line instead of a
traceback, and a stale settle PR without a marker no longer prints None.
When route rejects its input the step fails with a Release route error
annotation.
@Troublor
Troublor marked this pull request as ready for review October 27, 2026 15:44
GitHub ignores workflow env: for its default GITHUB_* variables, so the
release-route job's GITHUB_REPOSITORY: megaeth-labs/fake never reached
the scripts on a runner; the extracted publish guard asked for
repos/megaeth-labs/.github/pulls/42 and the fake gh exited 99.

Pass the fake repository explicitly to route.sh and publish-guard.sh,
pin every fake gh call to it (the commits/pulls and repo view patterns
used to accept any repository), and check that repo-less gh calls would
resolve to the fixture's fake remote.
cut: a release branch already at the candidate merge commit is this cut,
done; exit 0 with a notice. At any other commit it still refuses, now
naming both SHAs. A ref create that loses a race re-reads the ref and
applies the same rule.

propose: the base_commit branch create and the stale candidate-branch
delete tolerate a retry or a concurrent run that got there first.

publish: a tag at the merge commit whose Release already exists is a
retry of a finished run; exit 0 with a notice and skip both writes. A tag
push or Release create that loses a race keeps the winner's when it is at
this commit. A tag elsewhere still refuses; the settle-PR re-verification
is unchanged.

The release-candidate and release-publish harness jobs assert each rule
against a bare remote and a fake gh that fails like GitHub's 422.
…efresh idempotent

The settle PR body now opens with an [!IMPORTANT] alert saying that merging
it settles vX.Y.Z (the merge commit is tagged, released and built), then the
version line, then the unchanged `<!-- release-settle: vX.Y.Z <sha> -->`
marker, then the notes. The title reads
`chore(release): settle vX.Y.Z — merging publishes the release` and is
re-applied on refresh.

Ensuring the PR is now safe to re-run and to race:
- a lost `gh pr create` ("already exists") or a rejected push re-reads the
  open PR and refreshes it instead of failing;
- deleting a stale settle branch tolerates 404/422, and a retry pass never
  deletes a branch a concurrent run just pushed;
- the up-to-date exit also requires the current title;
- the branch is pushed only once the merge and the changelog commit both
  succeed, each pass starts from the remote (checkout --force), and a
  failure names the stage reached.

test_release_settle.py drives the shipped step against a real local remote
with a fake gh. The release-settle harness also asserts that a refresh keeps
the same PR.
… tags, and lint permission-less tokens

- release-route and release-publish share one order rule (release_tools
  order_refusal / check-order): a settle PR is published only when its
  version is newer than every other vX.Y.Z tag. Publish checks it in the
  guard and again right before the tag push, and marks the Release latest
  only when it is newer than the current latest Release.
- The latest tag is picked by release_tools latest-tag everywhere: only
  vX.Y.Z tags count, others (v1.2.0-rc1) are named and ignored, so they
  can no longer fail routing.
- app-token-lint L6: a Maxwell or Gutenberg token, and any app token in a
  release job, must request explicit permission-* inputs.
- release-settle re-checks the tag and the branch tip before every remote
  write, so a late refresh never opens a settle PR for a published version.
- release-route fails, naming the file and pattern, when it cannot read the
  version file, instead of skipping as "not landed".
- copy_templates quotes substituted values that a plain YAML scalar would
  change (bump_command with " #"), and checks they read back exactly.
…ndidate instead

peter-evans/create-pull-request updated an open candidate PR's branch in
place, which force-pushed chore/release-candidate-*: the last force push in
the release pipeline. The candidate PR is now opened by an explicit step that
never rewrites a ref. A re-dispatch closes the open candidate PR with a
comment, deletes its branch, pushes the new commit as a new ref with a plain
git push and opens a fresh PR with gh, with the same title, body (including
the release-candidate marker), base, labels and app authorship.

Every state a retry or a concurrent run can leave (a PR already closed, a
ref already gone, a branch pushed with no PR, a branch or PR another run
made first) converges on one branch and one open PR. The harness runs the
step against a remote that denies non-fast-forward updates.

test_no_force_push.py fails if any shipped action or template force-pushes
(--force, --force-with-lease, -f, +refspec, --mirror, create-pull-request,
force=true ref updates); release-publish's git fetch --force of a tag
refspec is allowed explicitly. The docs now say nothing force-pushes any
branch, with no ruleset exemption needed.
@Troublor
Troublor merged commit c1b7003 into main Oct 27, 2026
13 checks passed
@Troublor
Troublor deleted the ci/gutenberg-release-identity branch October 27, 2026 16:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant