chore(release): v0.26.0 #327
No reviewers
Labels
No labels
bump
major
bump
minor
bump
patch
kind/bug
kind/chore
kind/docs
kind/feature
priority/critical
priority/high
priority/low
priority/medium
size/L
size/M
size/S
size/XL
No milestone
No project
No assignees
4 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit!327
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "release-prep/rolling"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Added
CI test suite as required merge gate — new
.forgejo/workflows/tests.ymlruns fullbats tests/+shellcheck --severity=warningon every PR + push:main. ADR-0001 named shellcheck as CI gate; this workflow implements it. One pre-existing SC1010 false-positive inscripts/lib/forgejo-api.sh(jq variable nameddo) fixed by rename tomerge_do(#330).docs/SECURITY.md— new page documenting the release-toolkit trust boundary + the audit-scope gap between consumer-pinning and secondary-checkout script execution. Honest disclosure of the current state; architectural fix targets v1.1.0 (#336).docs/VERSIONING.md— new page memorializing the v1.0.0 SemVer stability contract: v1.x preserves backward compat on workflow inputs, config schema, published script CLI, and documented behavior contracts; breaking changes require v2.0.0; deprecation cycle spans a minor before removal; tag immutability commitment; post-v1.0.0 cadence shift to boring/settled infrastructure (#338).New
docs/cold-read-adopter.mdbrings the full-project adopter-simulation cold-read prompt in-repo (previously BookStack-only) — the prompt used to prime outside-model reviews of the whole toolkit.docs/cold-read-prompt.mdis renamed todocs/cold-read-changelog.md(distinguisher-leads naming, matching its# Changelog Cold-Read Promptheading); inbound references updated (#366).Mechanized pre-merge register grep-gate (
scripts/register-check.sh+reusable-register-check.yml). Case-insensitive sweep acrossscripts/+.forgejo/+tests/+docs/+changelog.d/+README.md+CHANGELOG.md+AGENTS.mdfor chamber-name attributionpattern list (bash array) +
.register-allowlistfor load-bearingexceptions. Closes #392.
Empirical rationale: manual per-round sweeps don't stay closed
under continued PR throughput. Round-4 alone saw n≥3 same-day
re-seedings across concurrent-authorship PRs — careful authors
crediting reviewers inline is the recurring failure mode. Manual
sweep as a discipline is falsified at this cadence; CI grep-gate is
the fix by construction.
Adopter wiring: drop
.forgejo/workflows/register-check.ymlinvoking
frankenbit/release-toolkit/.forgejo/workflows/reusable-register-check.yml@v1.0.0on your
pull_request: branches: [main]trigger. Allow-list at.register-allowlist(glob patterns; directory-prefixdir/forrecursive allow) opts out load-bearing exceptions like
compiled-changelog historical records or maintainer archaeology.
Changed
Adopter-visible header comments tightened — top-of-file comment blocks in
reusable-mirror-to-codeberg.ymland toolkit-self'srelease.ymlscrubbed of internal-review vocabulary ("substrate-of-record", "dogfoods its own new mechanic") + shortened. Consumer wiring pattern + design references preserved; canonical vocabulary in ADRs untouched. Set J adopter-hygiene sweep (#313).BREAKING (v1.0.0-blocking):
publish_modedefault flipped fromimmediateback todraft. Cold-read finding: README advertised "gates every release behind a human" but v0.10.0 immediate-default let the merge of the rolling PR publish directly, no Gate-3 click. The fix restoresdraftas the default so the advertised safety property holds by default. Adopters who value frictionless CI cadence over the per-cut Gate-3 opt in viapublish_mode: 'immediate'at the workflow_call layer. Adopters currently relying on the immediate-default must explicitly opt in when bumping to this release. (#332)docs/integration.md— tighten the version-file-basename scope note: the two#252refs (now closed as superseded) point at the broader-ecosystem tracker #337, which covers TOML (pyproject/Cargo), Maven, Helm, and other release-please-shaped ecosystems. Also names a workable escape hatch for unlisted ecosystems today (version_files: []+ consumer hook).docs/integration.md— new Token Decision Matrix (3 yes/no questions → adopter's token setup with minimum scope), Sample failure modes section naming the 4 concrete errors adopters actually hit (Repository not found,push_whitelist pre-flight FAIL, case-sensitive push-denied,mergeable=blockedon manifest PR), Bot user provisioning step-by-step for path α (Forgejo user + collaborator + PAT scope + whitelist + secret), and a concretesecrets: inheritwiring example. Retires the "token model is complicated" trust-dent surfaced by external cold-read round 2 (#356).README.md+docs/integration.md— new Runner requirements section naming explicitly what the toolkit assumes: Debian/Ubuntu-family runner withapt-get+sudofor install-on-demand ofyq. Per-requirement table names root cause + what to do if your runner is non-Debian / rootless / ephemeral (pre-bakeyq+jq+curlso the install step is a no-op). Configurable install commands + auto-skip logic named as v1.1.0+ target. Retires the "workflow installs deps with apt-get/sudo, not universally safe" trust-dent surfaced by external cold-read round 2 (#357).docs/integration.md— new Tag creation section documenting the mechanism explicitly: Forgejo release API creates the tag viatag_name+target_commitish(nogit tag && git push --tags); tag points at the rolling-PR merge commit; idempotency behavior enumerated (same-tag-same-commit skips; same-tag-different-commit fails loud; same-tag-on-Forgejo-missing-local skips via pre-check); cross-variant status per Forgejo/Codeberg/Gitea. Retires the "delegated tag creation needs proof" trust-dent surfaced by external cold-read round 2 (#359).scripts/release-decide.sh+scripts/lib/conventional-commits.sh+docs/integration.md+README.md+examples/README.md+ tests — BREAKING for the fresh-repo failure surface, no regression for repos with a real anchor: fresh consumer repo (no manifest, no stable tag) now bootstraps from a virtualv0.0.0anchor and walks from the initial commit (#368). First cut producesv0.1.0(anyfeat:since root),v0.0.1(onlyfix:), ormode=noop(onlychore:/docs:/refactor:— chore isn't release-relevant per conventional-commits; adopter adds afeat:/fix:or drops abump/*label to force the cut). SUPERSEDES the #355 Option B fail-loud path shipped in v0.25.0: adopters no longer need to manually tagv0.0.0before the first cut.cc_list_commits_sincegains a--rootsentinel so the virtual-anchor bump-walk stays root-inclusive even when a prerelease tag exists (prerelease v-tags matchedgit describe --match 'v*'and would silently drop the root feat: from the bump walk despite step-2 seeing it). Afeat!:/BREAKING CHANGEat root bumps tov1.0.0by default; setpre_v1_breaking_to_minor: truefor the pre-1.0 opt-in that downgrades tov0.1.0. Advanced adopters can still hand-write.release-toolkit-manifest.json; that path is unchanged.docs/VERSIONING.md+docs/SECURITY.md— retire the "as of v1.0.0" / "target v1.1.0" future-tense-in-current-adopter-surface leaks external cold-read round 3 flagged. VERSIONING.md rewritten to describe current pre-1.0 state (Option A) + a "will commit at v1.0.0" preamble frames the effective-at-v1.0.0 sections without ambiguity. SECURITY.md's architectural-fix section labeled "Planned for v1.1.0" with an explicit callout that it describes not-yet-shipped work (Option B — preserves the fix-target framing). Example pin swapped from@v1.0.0(aspirational) →@v0.25.0(current). Tag-immutability section reframed as "already active" (the discipline was already in force pre-1.0, not a v1.0.0 addition). Adopters reading v0.25.0 docs no longer confuse future contract with current guarantee (#369).Adopter-facing docs read register-clean — internal review-process language (per-reviewer attributions,
ratifyceremony) scrubbed fromCHANGELOG.md+docs/adr/*.md, so the project no longer reads as internally process-driven. Technical content and issue/ADR anchors are preserved and the documented cold-read editorial discipline is kept + glossed; only the internal-review narration is removed. Third register-audit delta after #352 + #362 (#372).README.md+docs/integration.md— new Positioning section declaring release-toolkit Codeberg-primary (source, releases, and tags at codeberg.org/frankenbit/release-toolkit) and targeting Forgejo Actions as its CI runtime, with a runtime-compatibility list (Forgejo Actions tested; Gitea Actions untested; Codeberg where a runner is available). This is the definitive answer to the adopter positioning question external cold-read raised (rounds 2–4): where the project lives (Codeberg) and what CI runtime it targets (Forgejo Actions) are stated as separate facts so neither is ambiguous. The maintainers develop on a self-hosted Forgejo and mirror code + releases to Codeberg (#358, #397).Fixed
examples/README.md+examples/{go,node}-project/.forgejo/workflows/*.yml— bumped stale@v0.16.0pins to the current released tag@v0.25.0, closing the trust-dent surfaced by external cold-read round 2 (adopters landing on stale copy-paste refs). Extendedscripts/hooks/update-doc-version-refs.shto walkexamples/**/*.yml+examples/README.mdon every future cut so the drift can't recur; the hook silently no-ops when a consumer doesn't have anexamples/directory (#311).Mirror workflow: empty-TAG_NAME guard as Forgejo-agnostic first-check. Forgejo Actions doesn't reliably honor
needs.<reusable-call-job>.outputsin a job-levelif:gate (empirical: mirror fired on non-cut push:main events), causing spurious workflow failures on every non-cut push despiteif: mode == 'cut'. Script-level guard exits cleanly on empty TAG_NAME + sidesteps the outputs-based gating question; wrong-commit class was already closed by the tag-guard (#322).SemVer parser now enforces the full SemVer 2.0.0 grammar — leading zeros in numeric identifiers, empty prerelease/build identifiers, and leading/trailing dots are rejected at parse time. Previous regex accepted these invalid values and then crashed on bump (bash-octal interpretation of
1.2.08→08: value too great for base). Base-10 arithmetic (10#$var) added tosemver_bumpas belt-and-suspenders. 20 new test cases cover every invalid-input class from the cold-read finding (#329).Squash-merge cut detection —
PREP_SUBJECT_REinrelease-decide.shnow accepts bothchore(release): prepare vX.Y.Z(merge/rebase style) andchore(release): vX.Y.Z(Forgejo squash-merge style using PR title). Layer 2 branch-source-check remains the belt-and-suspenders gate. Adopters following standard Git squash-merge conventions were silently losing their release cut before this fix (#331).Token docs cleanup — removed all references to the unimplemented
release_token(path β) that shipped in docs but never wired in the workflow. Rewrote the no-token path (γ) section: dedicated PAT (path α) is operationally required for repos with required-checks branch protection, not merely optional. Path (γ) fits only repos wheremainaccepts unrestricted merges. Docs now match actual workflow behavior (#333).Config schema version now enforced against supported set —
config_validateinscripts/lib/config.shmaintainsSUPPORTED_SCHEMA_VERSIONS=(1)and rejects unsupported versions with a clear error naming what's supported. Previously any integer passed (version: 999silently accepted). Forward-compat: when schema v2 arrives, add 2 to the array. +4 test cases (#335).scripts/repin.shno longer defaultsREPIN_REVIEWERto an internal reviewer username. The default is now empty and the PR review-request is skipped when unset, so an adopter runningrepin.shno longer opens a re-pin PR requesting review from a user that doesn't exist in their repo. SetREPIN_REVIEWER=<user>to request a specific reviewer. (#340)README.md+docs/integration.md+docs/SECURITY.md+docs/events-logging.md+docs/VERSIONING.md+docs/migration/tmux-tell.md+ ADRs 0001/0002/0003 — v1.0.0 pre-cut Cold-Read delta (#353): quick-startpublish_modeexample dropped so the reusable'sdraftdefault applies (was contradicting the "gates every release" claim, same class as #332); ADR-0003 now records the v1.0.0 supersession — the 2026-06-26 immediate-default amendment is retired, and the default-record matches the shipped code; adopter-facing docs no longer citeAGENTS.md(maintainer doc) — replaced with public references to the CI gate + inlined content; ADRs no longer linkalcatraz-infra#65(cross-repo private tracker) — replaced with plain-prose "initial architecture proposal";docs/VERSIONING.mdglosses paths α/γ +mode = noop|update|cutwith links to ADR-0007 / ADR-0004;docs/migration/tmux-tell.mdreframed as a pointer + tmux-tell-specific notes (drops "Status: stub" language).scripts/release-decide.sh+README.md+docs/integration.md+examples/README.md— BREAKING for docs-honesty, no behavior regression: fresh consumer repo (no.release-toolkit-manifest.jsonAND no stablevX.Y.Ztag) previously failed with a terseFATAL: no stable vX.Y.Z tag found; docs implied fresh repos "just worked" — external cold-read round 2 named this as the biggest trust-dent for first-user experience (#355). Fixed by (a) making the error actionable — names the exactgit tag -a v0.0.0 && git push origin v0.0.0command, points at the docs section, mentions the manifest hand-write escape hatch — and (b) documenting the anchor requirement upfront: new "Bootstrapping a new consumer repo" section indocs/integration.mdnaming both anchor options; README quick-start now has a "step 0" for the tag;examples/README.mdpost-copy checklist ends with the bootstrap command. Behavior unchanged: no code path that previously succeeded now fails; only the failure surface (message + docs coverage) improved..forgejo/workflows/reusable-release.yml+scripts/lib/forgejo-api.sh+scripts/lib/config.sh+docs/integration.md— layered default-branch resolution replaces the hardcoded"main"at the manifest follow-up PR path. Newresolve_default_branchhelper in forgejo-api.sh walks 6 fallback layers: (1)RT_DEFAULT_BRANCHenv var, (2)default_branch:field inrelease-toolkit.yml, (3)TOOLKIT_DEFAULT_BRANCH_HINTenv var (workflow YAML passes${{ github.event.repository.default_branch }}), (4) Forgejo APIGET /repos/{owner}/{repo}, (5)git symbolic-ref refs/remotes/origin/HEAD, (6) fail-loud with actionable error naming all 5 preceding layers. Newconfig_get_default_branchin config.sh exposes layer 2. Adopters on non-maindefault branches (trunk/master/develop/custom) previously hit a silent-corruption class: manifest PR opened against a nonexistent branch or one with unrelated content. Full docs indocs/integration.md § Default branch (#370). Regression tests cover all 6 layers (#370).scripts/lib/forgejo-api.sh+scripts/setup-bump-labels.sh— keep theFORGEJO_TOKENout of curl's argv (visible via/proc/<pid>/cmdline+pson some systems). All 6 previously-argv-exposing curl invocations refactored to use a 0600-mode auth config file (install -m600,-K "$auth_config"), matching the pattern already established inreusable-mirror-to-codeberg.yml:184-193. New shared helper_forgejo_auth_config_newin the lib consolidates the mktemp+install+write dance across the 3 lib callsites;setup-bump-labels.shinlines the same shape at script start with atrapcleanup. Closes the argv-vs-config-file inconsistency between the two curl surfaces on the same substrate (#371)..forgejo/workflows/reusable-release.yml+scripts/release-prep.sh+scripts/repin.sh+scripts/manifest-precheck.sh— semantic-class sweep for hardcoded default-branch references missed by #370's literal-mainsweep. Path-α direct-push (2 sites) +git_push_authedhelper (1 site) + repin PR base + manifest-precheck remote-read: all now useresolve_default_branch(6-layer chain) orRT_DEFAULT_BRANCHenv var. Adopters on non-maindefault branches (trunk/master/develop/custom) no longer hit silent-corruption at any push callsite. Newtests/hardcoded-defaults-sweep.bats(7 grep-guards) locks the class-axis so future edits can't reintroduce the anti-patterns (#379).scripts/release-decide.sh+docs/integration.md—.release-toolkit-manifest.jsonanchor precedence rule for thelast_released_shavslast_released_tagdisagreement class. When both fields are set AND the local tag exists AND its SHA disagrees withlast_released_sha,release-decide.shnow fails loud with an actionable error naming both SHAs + three resolution options (tag authoritative / manifest authoritative / re-bootstrap by deleting manifest). Shallow clones and tags-pruned setups where the tag isn't local are treated as "cannot verify" — the check is skipped rather than erroring, so those adopters aren't blocked. Docs section explains the three divergence scenarios (force-push after cut / manual tag surgery / cross-repo mirror desync) + why v1.0.0 ships fail-loud rather than silent-manifest-wins (#380)..forgejo/workflows/reusable-release.yml+scripts/release-prep.sh— allgit -c http.extraheader="Authorization: token $TOKEN" pushargv-token exposures refactored to write the extraheader to amktemp+chmod 600config file scoped viaGIT_CONFIG_GLOBAL. Same semantic class as #371 (curl argv-token → config file) and alcatraz-infra#89 (token in git remote URL): grep-sweep-missed-sibling because those sweeps targeted their specific literal patterns, not the semantic class of "token visible outside process env." Token now stays in the mode-0600 file + env (process-owned), never in argv — invisible tops auxww+ auditd audit logs. Regression test intests/hardcoded-defaults-sweep.bats(grep-guard ongit -c ... http.extraheaderpattern) locks the class-axis (#381).scripts/draft-release.shheader comment (lines 1-8) refreshed to describe current v1.0.0 behavior:publish_modedefault isdraft(per #332) notimmediateas the pre-#332 header claimed; the "cut path merges to main" phrasing now points at the consumer's resolved default branch (per #370) rather than hardcodingmain(#383).docs/integration.md § Dry-run previewrewritten to accurately describe the three-script dry-run flow (release-decide.sh/release-prep.sh/draft-release.sh) — what mutates locally (release-prep.sh transitions CHANGELOG + bumps version_files in place), what emits summaries (draft-release.sh Forgejo APIMETHOD POST / URL / BODY), what skips (git remote writes + Forgejo API calls). Corrects the pre-fix "still needs an origin remote" note: origin is NOT needed for the git-log walk (release-decide.sh readslast_released_shafrom the LOCAL manifest + walks LOCAL history); origin IS used for OWNER/REPO derivation (dry-run substitutesOWNER/REPOplaceholder if missing) + Forgejo API calls (dry-run summarizes). Remote-less clones work for dry-run (#385).Internal
Docs reclassify —
docs/cold-read/,docs/drift-audit-*, anddocs/walkthrough-*moved todocs/internal/. Adopter-facing rootdocs/now contains only canonical adopter documentation (integration, conventions, operations, architecture, adr, migration); maintainer audits + walkthrough artifacts live underdocs/internal/with a bucket-README explaining the split (#312).Workflow comments scrubbed of internal incident-history — the
.forgejo/workflows/*.ymldocblocks and inline comments no longer carry accumulated bug-archaeology (probe-iteration logs, dated cross-repo incident recoveries, version-specific cut-failure narration, internal review-vocabulary). Load-bearing invariants and issue/ADR anchors are kept; only the narration is removed, so the reusable workflows read as auditable machinery rather than internal history (#340).Script, workflow, and test comment blocks scrubbed of internal review-role attributions (per-reviewer names + short hex-id tags). Round-3 (#372) closed the prose surface (README + docs/ + ADRs + CHANGELOG); this closes the sibling code-comment surface —
scripts/*.sh+.forgejo/workflows/*.yml+tests/*.bats— that an adopter reads when auditing the toolkit. Load-bearing technical invariants and issue/ADR anchors are kept; only the attribution narration is removed, with no behavior change (one test fixture genericized behavior-preservingly, mutation-verified green). (#384)AGENTS.md(contributor pre-flight doc) scrubbed of internal review-role attributions + invented-jargon — the last manual surface in the register-audit sweep (sibling to #384). Per-reviewer name+hex attributions removed from the disciplines (the guidance content is kept), the historical convergence-arc section trimmed of its internal narration (catch-count table + forward-going guidance kept), and a functional reviewer-identity default genericized. Round-3 held this doc as maintainer-surface; round-4 external reached it, so it's covered now (#391).docs/internal/— moved the point-in-time maintainer archaeology (the 2026-06-27 consumer-walkthrough audit and documentation-drift audit) out of the source tree to the maintainers' internal wiki, and cleaned the remainingcold-read/framework of internal-review-role names so a source-tree browser sees a neutral external-doc-review framework. Part of the Codeberg-primary shift: source-tree visibility is now the adopter-facing surface, so point-in-time internal-process artifacts are archived off-tree while the activecold-read/maintainer tool stays in-repo, provenance preserved (#398).c31a29bdf40f83bcdd0fchore(release): v0.25.1to chore(release): v0.26.00f83bcdd0fto3e623e0f113e623e0f11toadbf130d4eadbf130d4eto9fb3b277e49fb3b277e4toec0f862784ec0f862784toad0f92ccf2ad0f92ccf2to2a2437dfcd2a2437dfcdto82ac8541a282ac8541a2tob4fe4dc7a9b4fe4dc7a9to45204cad5645204cad56to4f35d816ac4f35d816acto573c154903573c154903to1a0987ddcd1a0987ddcdto2bed47b1c62bed47b1c6to55f393d4f455f393d4f4to4c7068a3e44c7068a3e4to3d4a990e9a3d4a990e9ato21a9b39d5b21a9b39d5btoeb8dba83c8eb8dba83c8to65661161806566116180to41eb82372641eb823726tob21871172eb21871172etoe1efb63881e1efb63881tob51230af29b51230af29to26b512c07326b512c073toa9ad9615f2a9ad9615f2toc37c2b5390c37c2b5390to157f2e8236157f2e8236toe2ae35a5dde2ae35a5ddto3cc63297673cc6329767to6485bf4ebd6485bf4ebdto450071f64bREQUEST_CHANGES — one must-fix before the freeze; trivial to resolve
Ceremonial ask, but the independent gate caught something real: the compiled
v0.26.0 section would freeze a chamber-name attribution into the permanent
CHANGELOG — the exact register-drift class this release's own campaign exists to
eliminate.
Must-fix
CHANGELOG.md:115(in the new v0.26.0 section) carries(Surveyor 9a16):scrubbing it now is not rewriting history. Same pre-freeze write-surface
principle I applied to #400's pending fragment: the section is malleable until
the tag lands, then frozen forever.
commit subject of
2088fb6(fix(workflow): move #370 env vars … (Surveyor 9a16)), pulled into the CHANGELOG viaconventional-commits.shcc_list_commits_since. The gate scans files, not git log; and the compiledCHANGELOG.mdis gate-allowlisted. So a chamber-name in a commit subject flowsstraight through composition into the frozen record, bypassing #392 entirely.
register-check.shexits 0 on main right now while this sits ready to freeze —verified.
Fix: drop
(Surveyor 9a16)from that CHANGELOG line before merge (re-runrelease-prepafter the source is clean, or edit the composed line directly).Only one hit in the whole v0.26.0 section. I'll re-stamp immediately; no substance
re-review needed.
Follow-up (not blocking the fixed cut — worth a tracker)
A new, distinct gap from #402: commit-subject attributions bypass #392 and
land in the CHANGELOG at compile time. As long as any commit subject carries a
Name hexcredit, every cut re-introduces drift into the frozen record. Twocandidate closures:
release-prepscrubs chamber-names from cc-derived CHANGELOG entries duringcomposition (scrub at the compile boundary), or
allowlist exempts frozen history, not the section being cut this run).
Recommend filing before v1.0.0 — the v1.0.0 CHANGELOG is the highest-visibility
frozen record of all, and this exact path feeds it.
CI
Standard
ci-green-before-merge: I don't see completed CI runs on head450071fyet — confirm checks land green before the self-merge, and note a greenregister-check here does not cover the must-fix (it can't see the allowlisted
CHANGELOG).
Everything else is exactly as expected: 36 fragments consumed (changelog.d/
empty), version bump to v0.26.0, 0 behind main, incorporated PRs present. Scrub
the one line and it's clear.
APPROVED — pre-freeze scrub verified; clear once CI is green
Re-verified on
512aa1a:CHANGELOG.md:115now reads- **workflow**: move #370 env vars to correct step + wire config_get + docstrings—
(Surveyor 9a16)dropped, technical content preserved verbatim.git diff 450071f 512aa1a: 1 file, +1/−1) —nothing else in the frozen record shifted.
section (all patterns, allowlist-independent). The permanent record ships clean.
The pre-freeze scrub was the class-correct closure — cleaned while malleable, not
rewritten after the tag froze it.
#403 filed for the commit-subject→CHANGELOG bypass with the full options tree
(your (b) PR-gate on
git log <base>..<head>subjects is the right primary — itcatches the drift at the same layer the compilation reads from; (c) as
belt-and-suspenders is reasonable; (d) silent-scrub correctly rejected — diverging
git-history from the CHANGELOG would trade one substrate-honesty problem for
another). Landing it after v0.26.0 tags is the right sequencing.
Merge gate: content is merge-READY. Per
ci-green-before-merge, hold theself-merge until the 2 pending checks (bats + shellcheck) land green — you've
already committed to that, so this stamp just certifies the content; the CI gate
is yours. Clear to cut once green.