feat(bootstrap): let a consumer pin the reusables to a toolkit branch, to try unreleased changes before a cut #1534

Open
opened 2026-10-03 14:52:09 +02:00 by claude · 0 comments
Member

A feature for later. Pick it up when someone needs to try an unreleased toolkit change on a real adopter before a cut. Until then there are two routes:

  • pin @main after the change merges (off-contract, but it works);
  • run rt prep --dry-run locally, with an rt built from the branch, against a scratch clone of the adopter.

Measured

A wrapper pinned uses: .../reusable-release.yml@<branch> gets through the first two steps and stops at the third, bootstrap rt.

1. resolve-ref.

  • REF starts as the baked marker (reusable-release.yml:268).
  • A branch ref is neither main nor 40-hex, so it can reach only the #1222 wrapper-ref fallback (:344-347). That fallback fires only when the baked value is empty or main.
  • A branch whose tip carries a baked tag resolves to that tag instead. Two ways to get such a tip: branching from a cut tag, or branching from main in the window between a prep commit and post-cut's reset (AGENTS.md section 2, Build-bake).
  • Its bootstrap rt then fetches the released binary, and the branch's Go code silently does not run.
  • All 9 baked reusables carry the same arms.

2. checkout release-toolkit. actions/checkout at the branch (:357-362) works.

3. bootstrap rt. scripts/bootstrap-rt.sh has two arms:

  • v[0-9]*) FETCHes the asset (:32-44), minisign-authenticated and checksum-verified through fetch-rt.sh;
  • main) BUILDs from source (:45-68).

Any other ref stops at :79. That is deliberate and pinned by a test: tests/bootstrap-rt.bats:93-100 requires RT_REF=feature/861 to be refused.

Local probe. Git Bash, HOME redirected to a scratch dir, toolkit exported from origin/main 8086700:

origin/main bootstrap-rt.sh, RT_REF=claude/1529-commit-notes
  ::error::release: unsupported toolkit ref 'claude/1529-commit-notes'; only version tags (v...) and main have bootstrap arms
  rc=1
patched sketch: the main) arm becomes the *) arm, with the #1514 hex refusal kept at its top (+6/-13, 19 changed lines)
  bootstrap rt: BUILD from source @ claude/1529-commit-notes (toolkit-self floating pin; ADR-0008 §4a build-exception)
  rc=0; GITHUB_PATH gains $HOME/.local/bin; `rt --version` prints `rt dev`

The sketch gets two things wrong that a real change must not:

  • its log line still calls the build the toolkit-self exception;
  • rt dev records nothing about which commit was built.

A one-off trial needs nothing on main.

  • The composite and the script are loaded from .release-toolkit at the pinned ref (reusable-release.yml:422, composite/bootstrap-rt/action.yml:64-67). A throwaway branch made of the commit under test plus the bootstrap patch is therefore enough.
  • Pushing such a branch fires none of the toolkit's push workflows. Their push triggers are branches: [main] (build-c4, check-self-bootstrap, gitea-twin-check, prepared-uncut-check, release, tests), branches: [main, v2/next] (go-ci) or tags: ['v*'] (goreleaser, build-ref-check).

Why it matters, and what it costs

Security: unsigned, unreviewed code runs with the adopter's secrets.

  • The BUILD arm skips fetch-rt.sh's minisign and checksum verification.
  • The rt it produces runs in reusable-release.yml with the secrets that job reads through secrets: inherit: RELEASE_TOOLKIT_TOKEN, RELEASE_TOOLKIT_GIT_TOKEN, RELEASE_TOOLKIT_API_TOKEN and GITHUB_TOKEN.
  • Trust becomes "anyone who can push that branch". For a PR branch, that is code no reviewer has approved, running with the adopter's release credentials.
  • @main already builds unsigned (ADR-0008 section 4a calls this "convention-guarded, not structural"), but main is protected and reviewed and a branch is not. go.sum still pins dependencies.

An ADR amendment.

  • docs/adr/0008-go-port-architecture.md:193-212 grants the build exception to main only. It rejects build-per-run for adopters because of the Go-on-every-runner dependency.
  • Supporting branch pins amends that.
  • ADR-0008 is a historical analysis, superseded as a decision surface by ADR-0009, so the amendment may need to be a new ADR that cites section 4a. The decision AC records which.

A deleted branch breaks the pin.

  • AGENTS.md section 15 step 6 deletes the head branch after merge.
  • Forgejo v15.0.7 resolves a called reusable's @ref with GetRefCommitID and errors with "failed to resolve reference" when the ref is gone (services/actions/reusable_workflows.go:141-144). This was read from the source, not measured on this instance.
  • The second checkout would fail on a missing ref in any case.
  • Every run of the consumer then fails until it is re-pinned.

A moving branch. Forgejo loads the reusable YAML when it expands the workflow and checks out the toolkit later, so a push in between can mix two commits. @main has the same exposure.

Compile time on every job.

  • 8 reusables install rt: ac-closure-check, changelog-body-check, changelog-fragment-check, manifest-check, prepared-uncut-check, recover-pending-cut, register-check and release.
  • The cache step runs only for refs starting with v (action.yml:46), so each of those jobs compiles.
  • Local measurement of go build ./cmd/rt at 8086700, module cache warm, on a 32-thread workstation:
    • about 10.7 s with an empty GOCACHE;
    • 23.2 s with -a -p 2;
    • 0.5 s with a warm cache.
  • The go runner has not been measured.
  • The build also needs Go on the adopter's runner (bootstrap-rt.sh:51). Storey's release wrapper runs on runs_on: go; other adopters may not.

Branch-name traps.

  • A branch matching v[0-9]* takes the FETCH arm and fails for lack of an asset.
  • A branch named with 7-40 hex characters is refused as a SHA (:76-78).
  • action.yml:46 runs the cache step for any ref that starts with v.

Real cuts. Merging an adopter's rolling PR while it is branch-pinned cuts a real release with unreleased toolkit code.

Relation to #1517

#1517 (SHA pins) lists the same mechanism as its option (B): BUILD rt from .release-toolkit at the pinned commit, with Go on adopter runners and an amendment to ADR-0008 section 4a.

A branch arm and a SHA build arm are one change to bootstrap-rt.sh: build any ref that is not a version tag. They differ in trust:

  • a SHA is immutable, so what was reviewed is what runs;
  • a branch moves under the pin, and the resolve-ref fallback above lets the baked value win for some branches.

Decide the two together. If #1517 picks (A) or (C), this issue needs its own build arm and its own amendment.

Acceptance criteria

  • A decision is recorded, as an ADR amendment or a new ADR. It says whether branch pins are supported, for whom, and what a branch pin guarantees about the rt that runs. It is made together with #1517's decision, or after it.
  • scripts/bootstrap-rt.sh builds a ref that is neither a version tag nor SHA-shaped from .release-toolkit. Its log line names the ref and the commit it built (git -C .release-toolkit rev-parse HEAD), and no longer calls the build the toolkit-self exception.
  • resolve-ref in all 9 baked reusables sends a branch pin to the branch even when the baked value is a tag, or the docs state that it does not. The .gitea twins are regenerated.
  • tests/bootstrap-rt.bats:93-100 is flipped, and a branch row is added to the #1514 composition table (:189-195). Both are mutation-verified.
  • composite/bootstrap-rt/action.yml:8-12, the pins section of docs/SECURITY.md and docs/integration.md say what a branch pin means:
    • the rt is unsigned and unreviewed;
    • the runner needs Go;
    • the pin breaks when the branch is deleted;
    • the adopter must not merge the rolling PR while branch-pinned.
  • The per-job build time is measured on the go runner and recorded here.
  • A changelog fragment.

Out of scope

  • Caching or signing branch builds.
  • An rc tag as a shortcut:
    • a v* tag fires goreleaser.yml and build-ref-check.yml (AGENTS.md section 14);
    • rt repin bakes from origin/main, not from a branch;
    • FETCH has never run for an rc (AGENTS.md section 2.2).
  • Pinning on GitHub or Gitea.

Anchor

Found while reviewing PR #1531 / issue #1529 on 2026-10-03. The operator asked whether Storey could be pinned to @claude/1529-commit-notes to try the PR before merge. It cannot: resolve-ref and the checkout pass, and bootstrap rt refuses. The review's research simulated that path against Storey's two wrappers, then ran a local rt prep --dry-run on a scratch Storey clone instead. Related: #1517, #1514, #456, #582, #336, #1222.

**A feature for later.** Pick it up when someone needs to try an unreleased toolkit change on a real adopter before a cut. Until then there are two routes: - pin `@main` after the change merges (off-contract, but it works); - run `rt prep --dry-run` locally, with an `rt` built from the branch, against a scratch clone of the adopter. ## Measured A wrapper pinned `uses: .../reusable-release.yml@<branch>` gets through the first two steps and stops at the third, `bootstrap rt`. **1. resolve-ref.** - `REF` starts as the baked marker (`reusable-release.yml:268`). - A branch ref is neither `main` nor 40-hex, so it can reach only the #1222 wrapper-ref fallback (`:344-347`). That fallback fires **only when the baked value is empty or `main`**. - A branch whose tip carries a baked tag resolves to that tag instead. Two ways to get such a tip: branching from a cut tag, or branching from main in the window between a prep commit and post-cut's reset (AGENTS.md section 2, Build-bake). - Its `bootstrap rt` then fetches the released binary, and the branch's Go code silently does not run. - All 9 baked reusables carry the same arms. **2. checkout release-toolkit.** `actions/checkout` at the branch (`:357-362`) works. **3. bootstrap rt.** `scripts/bootstrap-rt.sh` has two arms: - `v[0-9]*)` FETCHes the asset (`:32-44`), minisign-authenticated and checksum-verified through `fetch-rt.sh`; - `main)` BUILDs from source (`:45-68`). Any other ref stops at `:79`. That is deliberate and pinned by a test: `tests/bootstrap-rt.bats:93-100` requires `RT_REF=feature/861` to be refused. **Local probe.** Git Bash, `HOME` redirected to a scratch dir, toolkit exported from origin/main `8086700`: ``` origin/main bootstrap-rt.sh, RT_REF=claude/1529-commit-notes ::error::release: unsupported toolkit ref 'claude/1529-commit-notes'; only version tags (v...) and main have bootstrap arms rc=1 patched sketch: the main) arm becomes the *) arm, with the #1514 hex refusal kept at its top (+6/-13, 19 changed lines) bootstrap rt: BUILD from source @ claude/1529-commit-notes (toolkit-self floating pin; ADR-0008 §4a build-exception) rc=0; GITHUB_PATH gains $HOME/.local/bin; `rt --version` prints `rt dev` ``` The sketch gets two things wrong that a real change must not: - its log line still calls the build the toolkit-self exception; - `rt dev` records nothing about which commit was built. **A one-off trial needs nothing on main.** - The composite and the script are loaded from `.release-toolkit` at the pinned ref (`reusable-release.yml:422`, `composite/bootstrap-rt/action.yml:64-67`). A throwaway branch made of the commit under test plus the bootstrap patch is therefore enough. - Pushing such a branch fires none of the toolkit's push workflows. Their push triggers are `branches: [main]` (build-c4, check-self-bootstrap, gitea-twin-check, prepared-uncut-check, release, tests), `branches: [main, v2/next]` (go-ci) or `tags: ['v*']` (goreleaser, build-ref-check). ## Why it matters, and what it costs **Security: unsigned, unreviewed code runs with the adopter's secrets.** - The BUILD arm skips `fetch-rt.sh`'s minisign and checksum verification. - The `rt` it produces runs in `reusable-release.yml` with the secrets that job reads through `secrets: inherit`: `RELEASE_TOOLKIT_TOKEN`, `RELEASE_TOOLKIT_GIT_TOKEN`, `RELEASE_TOOLKIT_API_TOKEN` and `GITHUB_TOKEN`. - Trust becomes "anyone who can push that branch". For a PR branch, that is code no reviewer has approved, running with the adopter's release credentials. - `@main` already builds unsigned (ADR-0008 section 4a calls this "convention-guarded, not structural"), but main is protected and reviewed and a branch is not. `go.sum` still pins dependencies. **An ADR amendment.** - `docs/adr/0008-go-port-architecture.md:193-212` grants the build exception to `main` only. It rejects build-per-run for adopters because of the Go-on-every-runner dependency. - Supporting branch pins amends that. - ADR-0008 is a historical analysis, superseded as a decision surface by ADR-0009, so the amendment may need to be a new ADR that cites section 4a. The decision AC records which. **A deleted branch breaks the pin.** - AGENTS.md section 15 step 6 deletes the head branch after merge. - Forgejo v15.0.7 resolves a called reusable's `@ref` with `GetRefCommitID` and errors with "failed to resolve reference" when the ref is gone (`services/actions/reusable_workflows.go:141-144`). This was read from the source, not measured on this instance. - The second checkout would fail on a missing ref in any case. - Every run of the consumer then fails until it is re-pinned. **A moving branch.** Forgejo loads the reusable YAML when it expands the workflow and checks out the toolkit later, so a push in between can mix two commits. `@main` has the same exposure. **Compile time on every job.** - 8 reusables install `rt`: ac-closure-check, changelog-body-check, changelog-fragment-check, manifest-check, prepared-uncut-check, recover-pending-cut, register-check and release. - The cache step runs only for refs starting with `v` (`action.yml:46`), so each of those jobs compiles. - Local measurement of `go build ./cmd/rt` at `8086700`, module cache warm, on a 32-thread workstation: - about 10.7 s with an empty `GOCACHE`; - 23.2 s with `-a -p 2`; - 0.5 s with a warm cache. - The `go` runner has not been measured. - The build also needs Go on the adopter's runner (`bootstrap-rt.sh:51`). Storey's release wrapper runs on `runs_on: go`; other adopters may not. **Branch-name traps.** - A branch matching `v[0-9]*` takes the FETCH arm and fails for lack of an asset. - A branch named with 7-40 hex characters is refused as a SHA (`:76-78`). - `action.yml:46` runs the cache step for any ref that starts with `v`. **Real cuts.** Merging an adopter's rolling PR while it is branch-pinned cuts a real release with unreleased toolkit code. ## Relation to #1517 #1517 (SHA pins) lists the same mechanism as its option (B): BUILD `rt` from `.release-toolkit` at the pinned commit, with Go on adopter runners and an amendment to ADR-0008 section 4a. A branch arm and a SHA build arm are one change to `bootstrap-rt.sh`: build any ref that is not a version tag. They differ in trust: - a SHA is immutable, so what was reviewed is what runs; - a branch moves under the pin, and the resolve-ref fallback above lets the baked value win for some branches. Decide the two together. If #1517 picks (A) or (C), this issue needs its own build arm and its own amendment. ## Acceptance criteria - [ ] A decision is recorded, as an ADR amendment or a new ADR. It says whether branch pins are supported, for whom, and what a branch pin guarantees about the `rt` that runs. It is made together with #1517's decision, or after it. - [ ] `scripts/bootstrap-rt.sh` builds a ref that is neither a version tag nor SHA-shaped from `.release-toolkit`. Its log line names the ref and the commit it built (`git -C .release-toolkit rev-parse HEAD`), and no longer calls the build the toolkit-self exception. - [ ] resolve-ref in all 9 baked reusables sends a branch pin to the branch even when the baked value is a tag, or the docs state that it does not. The `.gitea` twins are regenerated. - [ ] `tests/bootstrap-rt.bats:93-100` is flipped, and a branch row is added to the #1514 composition table (`:189-195`). Both are mutation-verified. - [ ] `composite/bootstrap-rt/action.yml:8-12`, the pins section of `docs/SECURITY.md` and `docs/integration.md` say what a branch pin means: - the `rt` is unsigned and unreviewed; - the runner needs Go; - the pin breaks when the branch is deleted; - the adopter must not merge the rolling PR while branch-pinned. - [ ] The per-job build time is measured on the `go` runner and recorded here. - [ ] A changelog fragment. ## Out of scope - Caching or signing branch builds. - An rc tag as a shortcut: - a `v*` tag fires `goreleaser.yml` and `build-ref-check.yml` (AGENTS.md section 14); - `rt repin` bakes from `origin/main`, not from a branch; - FETCH has never run for an rc (AGENTS.md section 2.2). - Pinning on GitHub or Gitea. ## Anchor Found while reviewing PR #1531 / issue #1529 on 2026-10-03. The operator asked whether Storey could be pinned to `@claude/1529-commit-notes` to try the PR before merge. It cannot: resolve-ref and the checkout pass, and `bootstrap rt` refuses. The review's research simulated that path against Storey's two wrappers, then ran a local `rt prep --dry-run` on a scratch Storey clone instead. Related: #1517, #1514, #456, #582, #336, #1222.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
frankenbit/release-toolkit#1534
No description provided.