bug(bootstrap): a SHA-pinned consumer fails at bootstrap rt in 16 of 18 reusable files; resolve-ref emits the SHA and bootstrap-rt.sh has no arm for it #1514

Closed
opened 2026-10-02 16:21:04 +02:00 by claude · 2 comments
Member

A consumer that pins a reusable workflow by a full 40-hex commit SHA fails at bootstrap rt in 8 of the 9 baked reusables, and in their 8 .gitea twins. That is the posture docs/SECURITY.md recommends for higher security. #336's resolve-ref arm passes the SHA to the shared bootstrap. scripts/bootstrap-rt.sh has arms only for v[0-9]* and main, so it refuses the SHA with a message that names neither SHA pinning nor a remedy.

Nothing is broken today: no consumer on this instance pins by SHA (see Who is affected). It is a shipped promise that fails on first use.

Measured

At origin/main 4ffde69 (2026-10-02). The files involved are byte-identical to e4c0f35 except for one line of docs/SECURITY.md, which moved in #1512.

The chain, link by link:

# Where What it does with a SHA-pinned consumer
1 .forgejo/workflows/reusable-release.yml:332-343, step resolve toolkit ref WRAPPER_REF =~ ^[0-9a-fA-F]{40}$, so REF=<that SHA> and SOURCE="consumer SHA pin (release-toolkit#336)". This arm comes before the baked-value check, so the SHA wins whatever the marker says.
2 reusable-release.yml:357-362, checkout release-toolkit checks out .release-toolkit at the SHA, which is #336's goal.
3 reusable-release.yml:421-424, bootstrap rt ref: ${{ steps.resolve-ref.outputs.ref }}. The SHA goes in unmapped. A step's outputs are fixed once it ends, and no step in between remaps the ref.
4 composite/bootstrap-rt/action.yml:51 RT_REF: ${{ inputs.ref }}, unmapped. Line 8 documents the input as "a v... release tag or main". The cache step (:42) is skipped because the ref does not start with v.
5 scripts/bootstrap-rt.sh:31-72 v[0-9]*) FETCH, main) BUILD, and *) → fail "unsupported toolkit ref '${ref}'; only version tags (v...) and main have bootstrap arms", exit 1.

The same arm sits in all 9 baked reusables (grep 'consumer SHA pin'): ac-closure-check :207, changelog-body-check :204, changelog-fragment-check :260, manifest-check :171, prepared-uncut-check :209, recover-pending-cut :216, register-check :218, release :343, workflow-parse-check :173. Their 9 .gitea twins carry it too. Every one of them except workflow-parse-check bootstraps rt from steps.resolve-ref.outputs.ref through the composite. workflow-parse-check has no bootstrap step and fetches the Forgejo validator instead, so a SHA pin works there.

Result: 16 of 18 files refuse a SHA-pinned consumer at bootstrap rt; 2 are unaffected.

Reproduction

Two bats tests were added to tests/bootstrap-rt.bats in a local LF clone. They are a reproduction, not for merge, and the full text is at the end. The composition test runs the real resolve-ref bash extracted from each of the 18 files. It writes a consumer wrapper per pin shape, feeds the resolved ref to the real scripts/bootstrap-rt.sh, and stubs both arms so each would succeed if reached. Run in rt-bats:local (bats + python3-yaml) on 4ffde69:

composite: RT_REF = ${{ inputs.ref }} (passed through unmapped)
...
.forgejo/workflows/reusable-release.yml  SHA pin of a tag commit   ref=1c84e95d0fef01f4ad40cdd8b8e60a60bef8eecc source=consumer SHA pin (release-toolkit#336   bootstrap=REFUSED: ::error::release: unsupported toolkit ref '1c84e95d0fef01f4ad40cdd8b8e60a60bef8eecc'; only version tags (v...) and main have bootstrap arms
-- .forgejo/workflows/reusable-workflow-parse-check.yml: no bootstrap-rt step (rt is never installed)
...
discovered 18 resolve-ref steps; 16 bootstrap rt; 2 do not
SUMMARY tag pin (control): 0/16 refused
SUMMARY main pin (control): 0/16 refused
SUMMARY SHA pin of a tag commit: 16/16 refused
SUMMARY SHA pin of a main commit: 16/16 refused
SUMMARY 12-hex pin of a main commit: 16/16 refused
SUMMARY 12-hex pin of a tag commit: 0/16 refused

1c84e95 is v0.63.1^{commit}: what an adopter gets from git rev-parse v0.63.1^{commit}. "Baked" means the BUILD_BAKED_TOOLKIT_REF value the reusable carries at the pinned commit. A tag commit carries 'v0.63.1', and a main commit carries 'main'.

The reproduction is not a placebo (AGENTS.md §8 Axis B). Each mutant was run on a throwaway copy:

Mutant Unit repro Composition repro
bootstrap-rt.sh gains a [0-9a-fA-F]*) arm that succeeds red red (SHA rows 0/16)
reusable-release.yml's SHA arm keeps the baked ref (one file only) green (it does not read workflows) red (SHA rows 15/16)
the composite rewrites RT_REF before the script green red (the passthrough guard)

The controls (tag pin → FETCH, main pin → BUILD, 16/16 each) pass, so the refusals come from arm selection and not from the harness.

How it got here

When Commit Change Test
2026-08-25, v0.46.0 e287752 (#861) centralizes the bootstrap into composite/bootstrap-rt + scripts/bootstrap-rt.sh, with an explicit *) refusal tests/bootstrap-rt.bats:93 pins "unknown refs refuse instead of silently building or fetching" with feature/861
2026-09-06, v0.62.0 688002e (#336) adds the SHA arm to all 18 resolve-ref steps tests/workflows.bats:1585-1744 executes only the resolve-ref step and checks ref=<SHA>

Each test is correct for its own step. No test composes resolve-ref with the bootstrap, so a new shape on the resolver side had no consumer-side check. #336's implementation AC records the four mutations it ran, all on the resolver.

Why it matters

  • The security doc tells adopters to do exactly this. docs/SECURITY.md:41: "pinning uses:@<full-commit-SHA> instead of a tag now closes the second-checkout gap end-to-end". :70 is headed "SHA-pinned consumers closed", and :80 says "the second checkout is now immutable end-to-end". The v0.62.0 CHANGELOG entry announces it: "a SHA-pinned consumer's second checkout now resolves through that SHA". An adopter who follows the recommendation gets a red on every job that bootstraps rt: release, manifest-check, fragment-check and the rest.
  • The refusal doesn't help. "only version tags (v...) and main have bootstrap arms" names neither SHA pinning nor what to do. The only remedy it implies is to pin a tag, which reverses the move SECURITY.md recommended.
  • The doc's claim covers less than it reads. SECURITY.md speaks about the second checkout. The bytes that do most of the work are the rt binary, which any fix has to source from somewhere. The decision below is mostly about what a SHA pin guarantees for rt.

Who is affected

  • Adopters on this instance: none today. On 2026-10-02 I read every .forgejo, .gitea and .github workflow on the default branch of every repo this token can see: 36, or 35 besides the toolkit. Six consume the toolkit: cellblock, ember, purser, storey, tic-tac-toe and tmux-tell. Every pin is a tag. Repos this token cannot see were not measured.
  • Toolkit-self: unaffected. It floats @main (AGENTS.md §2.1).
  • Adjacent row, the same failure through a different arm: a short-hex pin of a main commit (baked 'main') takes the #1222 wrapper-ref fallback, which emits the short hex, and the bootstrap refuses that too (16/16). A short-hex pin of a tag commit gets the baked tag and works. This issue can take that row or hand it on (AC below).

Not established

  • Whether Forgejo resolves uses: frankenbit/release-toolkit/.forgejo/workflows/reusable-*.yml@<40-hex> for a called reusable workflow at all. 688002e's message says it does, but I found no recorded run. If it does not, the job fails before resolve-ref and this bug is masked, but SECURITY.md is still wrong. Measuring it needs a throwaway consumer repo (the AGENTS.md §14 pattern). Could-not-grade, not assumed.

Decision tree: what a SHA ref should do at bootstrap rt

What an adopter can pin, measured on v0.63.1:

Pinned commit Example On main? Baked ref action.yml image
tag commit (vX.Y.Z^{commit}, the #794 digest-pin commit) 1c84e95 no 'v0.63.1' @sha256:a94e8eb0… (real)
cut commit (its parent) 1c84e95^ yes 'v0.63.1' all-zeros placeholder
any other main commit — yes 'main' all-zeros placeholder

(A) FETCH the asset named by the marker baked at the pinned SHA. The resolver keeps the SHA for the checkout and also outputs the baked 'vX.Y.Z' it already reads, for the bootstrap and the cache key. The marker is content of the pinned tree, so the SHA-to-version step is immutable, and no tag lookup is needed.

  • Right when function matters more than binding rt to the SHA, and adopters pin tag or cut commits.
  • Leaves: rt bound to the minisign key and the tag name, not the SHA. The fetch is releases/download/<tag> (fetch-rt.sh:69), and the signed checksums.txt lists rt-linux-amd64 with no version in the name (fetch-rt.sh:68). A signed manifest from any release therefore verifies any release's binary, and a moved tag or replaced release could serve a different signed rt. SECURITY.md would have to say so. Commits baked 'main' have no asset, so they need (D).

(B) BUILD rt from .release-toolkit, already checked out at the SHA.

  • Right when a SHA pin must bind every byte that runs, including rt, and SHA-pinning adopters can be asked to carry Go. It works for any commit.
  • Costs: it reverses ADR-0008 §4's "no Go toolchain on the consumer runner" for this population. It is opt-in by pin shape, but §4a needs an amendment. The command -v go preflight's remedy ("pin a release tag", bootstrap-rt.sh:51) needs a SHA variant. Every bootstrapping job pays a build.

(C) Run rt from the image digest pinned in action.yml at the SHA. On a tag commit, action.yml pins docker://…/release-toolkit@sha256:…. The digest is content-addressed, so rt is bound to the pinned tree without Go.

  • Right when binding matters and adopters' runners can pull images.
  • Leaves: it works only for tag commits. Cut and main commits carry the all-zeros placeholder (cf. #1205 and #1453), so they need (D). It also needs the image path wired into the composite (related: #1476).

(D) Refuse with an actionable message, and narrow SECURITY.md. Either give bootstrap-rt.sh a [0-9a-fA-F]{40} arm that says SHA pinning is unsupported for rt-bootstrapping reusables and names the remedy, or drop the SHA arm from the 8 bootstrapping reusables and their twins and keep it in workflow-parse-check, where it works.

  • Right when no adopter pins by SHA (measured: none today), and the higher-security posture is better served by vendoring, which SECURITY.md:41 already names.
  • Costs: the smallest. SECURITY.md:35/41/70-80 and the v0.62.0 claim must be withdrawn or narrowed in an adopter-visible note.

Combinations: (A)+(D) or (C)+(D) (function where an asset or digest exists, an honest refusal everywhere else) are coherent, and both keep the *) refusal for non-hex refs.

Lean, for the operator to overrule: (D) now, because it makes the docs true at the smallest cost while no adopter depends on the path. Then (C) when a SHA-pinning adopter appears, since it is the only option that binds rt to the pinned tree without adding Go to adopter runners. (A) gives function cheaply, but it weakens what a SHA pin means for rt without saying so unless SECURITY.md is rewritten alongside it.

Acceptance criteria

  • A decision is recorded on this issue among (A)–(D) or a combination. It states what a SHA pin guarantees for the second checkout and for the rt binary, separately. — DONE in comment 114178: option (D). The second checkout follows the SHA; rt is refused with the remedy.
  • Every ref shape resolve-ref emits for a recognized consumer pin reaches a bootstrap arm that installs rt, or a refusal that names SHA pinning and its remedy. This holds in all 16 bootstrapping files: the 8 .forgejo reusables and their 8 .gitea twins. — DONE @ #1515 (0156d84, reviewed as 0156d84)
  • A test composes the real resolve-ref bash with scripts/bootstrap-rt.sh for every bootstrapping reusable. The file set is glob-discovered, with tag and main controls. It is mutation-verified: reverting the change reddens it. The reproduction below is a starting point. — DONE @ #1515 (0156d84, reviewed as 0156d84)
  • The existing non-hex refusal still holds: tests/bootstrap-rt.bats:93, feature/861. — DONE @ #1515 (0156d84, reviewed as 0156d84)
  • docs/SECURITY.md :35, :41 and :70-80 match the shipped behaviour, including any runner requirement (Go or docker) the chosen option adds. — DONE @ #1515 (0156d84, reviewed as 0156d84)
  • composite/bootstrap-rt/action.yml:8's ref description names every accepted shape. — DONE @ #1515 (0156d84, reviewed as 0156d84)
  • If the option adds a build or image path for adopters, ADR-0008 §4a is amended. — N/A under option (D): no build or image path was added (decision, comment 114178)
  • Before any doc says SHA pinning works end-to-end, a throwaway consumer repo (verified workflow-free per AGENTS.md §14) measures that Forgejo resolves uses: …/reusable-*.yml@<40-hex>, and the run is linked here. — N/A under option (D): no doc claims SHA pins work; the measurement moved to #1517's first AC
  • The short-hex-pin-of-a-main-commit row (#1222 fallback, 16/16 refused) is either handled here or filed separately and linked. — DONE @ #1515 (0156d84, reviewed as 0156d84)
  • A changelog fragment, because the change is adopter-visible: the SECURITY.md recommendation changes under every option. — DONE @ #1515 (0156d84, reviewed as 0156d84)

Reproduction text

The full test text, against tests/bootstrap-rt.bats at 4ffde69. It is not pushed. Run it with bats -f REPRO tests/bootstrap-rt.bats in an image with bats and python3-yaml.

tests/bootstrap-rt.bats additions (two @test blocks)
# REPRODUCTION, not a contract. resolve-ref's #336 arm hands bootstrap a full
# 40-hex SHA for a SHA-pinned consumer. Both arms are made available here (a
# fetch script and a go on PATH), so a refusal can only come from arm
# selection. The fix for this issue replaces this test.
@test "REPRO: a full 40-hex SHA ref, the shape resolve-ref emits for a SHA pin (#336), reaches neither arm" {
    sha=1c84e95d0fef01f4ad40cdd8b8e60a60bef8eecc
    mkdir -p "$TMP/toolkit/cmd/rt" "$TMP/bin"
    cat > "$TMP/toolkit/scripts/fetch-rt.sh" <<EOF
#!/usr/bin/env bash
: > "$TMP/fetch-called"
EOF
    cat > "$TMP/bin/go" <<EOF
#!/usr/bin/env bash
: > "$TMP/go-called"
EOF
    chmod +x "$TMP/toolkit/scripts/fetch-rt.sh" "$TMP/bin/go"

    run env PATH="$TMP/bin:/usr/bin:/bin" RT_REF="$sha" RT_CONTEXT=release \
        RT_TOOLKIT_CHECKOUT="$TMP/toolkit" GITHUB_WORKSPACE="$TMP/workspace" \
        GITHUB_PATH="$GITHUB_PATH" bash "$SCRIPT"
    echo "status=$status"
    echo "$output"
    [ "$status" -eq 1 ]
    [[ "$output" == *"release: unsupported toolkit ref '$sha'; only version tags (v...) and main have bootstrap arms"* ]]
    [ ! -e "$TMP/fetch-called" ]
    [ ! -e "$TMP/go-called" ]
}

# REPRODUCTION, not a contract: measure every reusable's resolve-ref ->
# composite -> bootstrap-rt.sh chain for each consumer pin shape, running the
# real resolver bash out of each workflow file. Controls: a tag pin must FETCH
# and a main pin must BUILD in every bootstrapping reusable, or the SHA rows
# below prove nothing.
@test "REPRO: every bootstrapping reusable refuses a SHA-pinned consumer at bootstrap rt (#336 x #861)" {
    run python3 - "$BATS_TEST_DIRNAME/.." "$SCRIPT" "$TMP" <<'PY'
import os
import re
import subprocess
import sys
from pathlib import Path

import yaml

repo = Path(sys.argv[1]).resolve()
bootstrap = sys.argv[2]
tmp = Path(sys.argv[3])

SHA = "1c84e95d0fef01f4ad40cdd8b8e60a60bef8eecc"  # v0.63.1^{commit}
TAG = "v0.63.1"
REF_EXPR = "${{ steps.resolve-ref.outputs.ref }}"

composite = yaml.safe_load((repo / "composite/bootstrap-rt/action.yml").read_text())
boot_env = next(s for s in composite["runs"]["steps"] if s.get("name") == "bootstrap rt")["env"]
if boot_env.get("RT_REF") != "${{ inputs.ref }}":
    print("composite maps the ref before bootstrap-rt.sh:", boot_env.get("RT_REF"))
    sys.exit(1)
print("composite: RT_REF = ${{ inputs.ref }} (passed through unmapped)")

# Fixtures shared by every bootstrap run: both arms can succeed if reached.
toolkit = tmp / "toolkit"
(toolkit / "cmd/rt").mkdir(parents=True, exist_ok=True)
(toolkit / "scripts").mkdir(parents=True, exist_ok=True)
fetch = toolkit / "scripts/fetch-rt.sh"
fetch.write_text('#!/usr/bin/env bash\nmkdir -p "$RT_INSTALL_DIR"\n: > "$RT_INSTALL_DIR/rt"\n')
fetch.chmod(0o755)
bindir = tmp / "bin"
bindir.mkdir(exist_ok=True)
gobin = bindir / "go"
gobin.write_text('#!/usr/bin/env bash\nprintf "#!/bin/sh\\n" > "$3"\nchmod +x "$3"\n')
gobin.chmod(0o755)

CASES = [
    # (label, wrapper pin, baked value in the reusable AT that pin)
    ("tag pin (control)", TAG, TAG),
    ("main pin (control)", "main", "main"),
    ("SHA pin of a tag commit", SHA, TAG),
    ("SHA pin of a main commit", SHA, "main"),
    ("12-hex pin of a main commit", SHA[:12], "main"),
    ("12-hex pin of a tag commit", SHA[:12], TAG),
]


def resolve(run, wrapper_path, provider, reusable, pin, baked, n):
    root = tmp / f"consumer-{n}"
    wrapper = root / wrapper_path
    wrapper.parent.mkdir(parents=True, exist_ok=True)
    wrapper.write_text(
        f"jobs:\n  j:\n    uses: frankenbit/release-toolkit/{provider}/workflows/{reusable}@{pin}\n")
    out = root / "github-output"
    out.write_text("")
    r = subprocess.run(["bash", "-c", run], cwd=root, text=True,
                       stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                       env={**os.environ, "BUILD_BAKED_TOOLKIT_REF": baked, "GITHUB_OUTPUT": str(out)})
    refs = re.findall(r"^ref=(.*)$", out.read_text(), re.M)
    src = re.search(r"source=([^)]*)\)", r.stdout)
    return r.returncode, (refs[0] if refs else None), (src.group(1) if src else "?")


def boot(ref, context, n):
    home = tmp / f"home-{n}"
    home.mkdir(exist_ok=True)
    ws = tmp / f"ws-{n}"
    ws.mkdir(exist_ok=True)
    gp = tmp / f"path-{n}"
    gp.write_text("")
    r = subprocess.run(["bash", bootstrap], text=True,
                       stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                       env={"PATH": f"{bindir}:/usr/bin:/bin", "HOME": str(home),
                            "RT_REF": ref, "RT_CONTEXT": context,
                            "RT_TOOLKIT_CHECKOUT": str(toolkit), "GITHUB_WORKSPACE": str(ws),
                            "GITHUB_PATH": str(gp)})
    line = next((l for l in r.stdout.splitlines() if l.strip()), "")
    return r.returncode, line


n = 0
discovered = 0
no_bootstrap = []
broken = {label: [] for label, _, _ in CASES}
for provider in (".forgejo", ".gitea"):
    for path in sorted((repo / provider / "workflows").glob("reusable-*.yml")):
        wf = yaml.safe_load(path.read_text())
        for job in (wf.get("jobs") or {}).values():
            steps = [s for s in (job.get("steps") or []) if isinstance(s, dict)]
            ids = [s.get("id") for s in steps]
            if "resolve-ref" not in ids:
                continue
            discovered += 1
            name = f"{provider}/workflows/{path.name}"
            run = steps[ids.index("resolve-ref")]["run"]
            m = re.search(r"extract_toolkit_wrapper_ref (\S+) (\S+)\)", run)
            wrapper_path, reusable = m.group(1), m.group(2)
            bsteps = [s for s in steps if s.get("uses") == "./.release-toolkit/composite/bootstrap-rt"]
            if not bsteps:
                no_bootstrap.append(name)
                print(f"-- {name}: no bootstrap-rt step (rt is never installed)")
                continue
            if bsteps[0]["with"]["ref"] != REF_EXPR:
                print(name, "bootstrap ref is not resolve-ref's output:", bsteps[0]["with"]["ref"])
                sys.exit(1)
            context = bsteps[0]["with"].get("context", "rt bootstrap")
            for label, pin, baked in CASES:
                n += 1
                rc, ref, src = resolve(run, wrapper_path, provider, reusable, pin, baked, n)
                if rc != 0:
                    print(name, label, "resolver exited", rc)
                    sys.exit(1)
                brc, bline = boot(ref, context, n)
                verdict = "ok" if brc == 0 else "REFUSED"
                print(f"{name:55} {label:28} ref={ref:<40} source={src:<45} bootstrap={verdict}: {bline}")
                if brc != 0:
                    broken[label].append(name)

bootstrapping = discovered - len(no_bootstrap)
print()
print(f"discovered {discovered} resolve-ref steps; {bootstrapping} bootstrap rt; {len(no_bootstrap)} do not")
for label, _, _ in CASES:
    print(f"SUMMARY {label}: {len(broken[label])}/{bootstrapping} refused")

ok = (discovered == 18 and bootstrapping == 16
      and not broken["tag pin (control)"] and not broken["main pin (control)"]
      and len(broken["SHA pin of a tag commit"]) == 16
      and len(broken["SHA pin of a main commit"]) == 16)
sys.exit(0 if ok else 1)
PY
    echo "$output"
    [ "$status" -eq 0 ]
    [[ "$output" == *"SUMMARY tag pin (control): 0/16 refused"* ]]
    [[ "$output" == *"SUMMARY main pin (control): 0/16 refused"* ]]
    [[ "$output" == *"SUMMARY SHA pin of a tag commit: 16/16 refused"* ]]
    [[ "$output" == *"SUMMARY SHA pin of a main commit: 16/16 refused"* ]]
}
A consumer that pins a reusable workflow by a full 40-hex commit SHA fails at `bootstrap rt` in 8 of the 9 baked reusables, and in their 8 `.gitea` twins. That is the posture `docs/SECURITY.md` recommends for higher security. #336's resolve-ref arm passes the SHA to the shared bootstrap. `scripts/bootstrap-rt.sh` has arms only for `v[0-9]*` and `main`, so it refuses the SHA with a message that names neither SHA pinning nor a remedy. Nothing is broken today: no consumer on this instance pins by SHA (see **Who is affected**). It is a shipped promise that fails on first use. ## Measured At `origin/main` `4ffde69` (2026-10-02). The files involved are byte-identical to `e4c0f35` except for one line of `docs/SECURITY.md`, which moved in #1512. The chain, link by link: | # | Where | What it does with a SHA-pinned consumer | |---|---|---| | 1 | `.forgejo/workflows/reusable-release.yml:332-343`, step `resolve toolkit ref` | `WRAPPER_REF =~ ^[0-9a-fA-F]{40}$`, so `REF=<that SHA>` and `SOURCE="consumer SHA pin (release-toolkit#336)"`. This arm comes before the baked-value check, so the SHA wins whatever the marker says. | | 2 | `reusable-release.yml:357-362`, `checkout release-toolkit` | checks out `.release-toolkit` at the SHA, which is #336's goal. | | 3 | `reusable-release.yml:421-424`, `bootstrap rt` | `ref: ${{ steps.resolve-ref.outputs.ref }}`. The SHA goes in unmapped. A step's outputs are fixed once it ends, and no step in between remaps the ref. | | 4 | `composite/bootstrap-rt/action.yml:51` | `RT_REF: ${{ inputs.ref }}`, unmapped. Line 8 documents the input as *"a v... release tag or main"*. The cache step (`:42`) is skipped because the ref does not start with `v`. | | 5 | `scripts/bootstrap-rt.sh:31-72` | `v[0-9]*)` FETCH, `main)` BUILD, and `*)` → `fail "unsupported toolkit ref '${ref}'; only version tags (v...) and main have bootstrap arms"`, exit 1. | The same arm sits in all 9 baked reusables (`grep 'consumer SHA pin'`): ac-closure-check `:207`, changelog-body-check `:204`, changelog-fragment-check `:260`, manifest-check `:171`, prepared-uncut-check `:209`, recover-pending-cut `:216`, register-check `:218`, release `:343`, workflow-parse-check `:173`. Their 9 `.gitea` twins carry it too. Every one of them except workflow-parse-check bootstraps `rt` from `steps.resolve-ref.outputs.ref` through the composite. workflow-parse-check has no bootstrap step and fetches the Forgejo validator instead, so a SHA pin works there. **Result: 16 of 18 files refuse a SHA-pinned consumer at `bootstrap rt`; 2 are unaffected.** ### Reproduction Two bats tests were added to `tests/bootstrap-rt.bats` in a local LF clone. They are a reproduction, not for merge, and the full text is at the end. The composition test runs the **real resolve-ref bash** extracted from each of the 18 files. It writes a consumer wrapper per pin shape, feeds the resolved ref to the real `scripts/bootstrap-rt.sh`, and stubs both arms so each would succeed if reached. Run in `rt-bats:local` (bats + python3-yaml) on `4ffde69`: ``` composite: RT_REF = ${{ inputs.ref }} (passed through unmapped) ... .forgejo/workflows/reusable-release.yml SHA pin of a tag commit ref=1c84e95d0fef01f4ad40cdd8b8e60a60bef8eecc source=consumer SHA pin (release-toolkit#336 bootstrap=REFUSED: ::error::release: unsupported toolkit ref '1c84e95d0fef01f4ad40cdd8b8e60a60bef8eecc'; only version tags (v...) and main have bootstrap arms -- .forgejo/workflows/reusable-workflow-parse-check.yml: no bootstrap-rt step (rt is never installed) ... discovered 18 resolve-ref steps; 16 bootstrap rt; 2 do not SUMMARY tag pin (control): 0/16 refused SUMMARY main pin (control): 0/16 refused SUMMARY SHA pin of a tag commit: 16/16 refused SUMMARY SHA pin of a main commit: 16/16 refused SUMMARY 12-hex pin of a main commit: 16/16 refused SUMMARY 12-hex pin of a tag commit: 0/16 refused ``` `1c84e95` is `v0.63.1^{commit}`: what an adopter gets from `git rev-parse v0.63.1^{commit}`. "Baked" means the `BUILD_BAKED_TOOLKIT_REF` value the reusable carries at the pinned commit. A tag commit carries `'v0.63.1'`, and a main commit carries `'main'`. **The reproduction is not a placebo** (AGENTS.md §8 Axis B). Each mutant was run on a throwaway copy: | Mutant | Unit repro | Composition repro | |---|---|---| | `bootstrap-rt.sh` gains a `[0-9a-fA-F]*)` arm that succeeds | red | red (SHA rows 0/16) | | `reusable-release.yml`'s SHA arm keeps the baked ref (one file only) | green (it does not read workflows) | red (SHA rows 15/16) | | the composite rewrites `RT_REF` before the script | green | red (the passthrough guard) | The controls (tag pin → FETCH, main pin → BUILD, 16/16 each) pass, so the refusals come from arm selection and not from the harness. ### How it got here | When | Commit | Change | Test | |---|---|---|---| | 2026-08-25, v0.46.0 | `e287752` (#861) | centralizes the bootstrap into `composite/bootstrap-rt` + `scripts/bootstrap-rt.sh`, with an explicit `*)` refusal | `tests/bootstrap-rt.bats:93` pins *"unknown refs refuse instead of silently building or fetching"* with `feature/861` | | 2026-09-06, v0.62.0 | `688002e` (#336) | adds the SHA arm to all 18 resolve-ref steps | `tests/workflows.bats:1585-1744` executes **only the resolve-ref step** and checks `ref=<SHA>` | Each test is correct for its own step. No test composes resolve-ref with the bootstrap, so a new shape on the resolver side had no consumer-side check. #336's implementation AC records the four mutations it ran, all on the resolver. ## Why it matters - **The security doc tells adopters to do exactly this.** `docs/SECURITY.md:41`: *"pinning `uses:@<full-commit-SHA>` instead of a tag now closes the second-checkout gap end-to-end"*. `:70` is headed *"SHA-pinned consumers closed"*, and `:80` says *"the second checkout is now immutable end-to-end"*. The v0.62.0 CHANGELOG entry announces it: *"a SHA-pinned consumer's second checkout now resolves through that SHA"*. An adopter who follows the recommendation gets a red on every job that bootstraps rt: release, manifest-check, fragment-check and the rest. - **The refusal doesn't help.** *"only version tags (v...) and main have bootstrap arms"* names neither SHA pinning nor what to do. The only remedy it implies is to pin a tag, which reverses the move SECURITY.md recommended. - **The doc's claim covers less than it reads.** SECURITY.md speaks about the *second checkout*. The bytes that do most of the work are the `rt` binary, which any fix has to source from somewhere. The decision below is mostly about what a SHA pin guarantees for `rt`. ## Who is affected - **Adopters on this instance: none today.** On 2026-10-02 I read every `.forgejo`, `.gitea` and `.github` workflow on the default branch of every repo this token can see: 36, or 35 besides the toolkit. Six consume the toolkit: cellblock, ember, purser, storey, tic-tac-toe and tmux-tell. **Every pin is a tag.** Repos this token cannot see were not measured. - **Toolkit-self: unaffected.** It floats `@main` (AGENTS.md §2.1). - **Adjacent row, the same failure through a different arm:** a *short-hex* pin of a main commit (baked `'main'`) takes the #1222 wrapper-ref fallback, which emits the short hex, and the bootstrap refuses that too (16/16). A short-hex pin of a tag commit gets the baked tag and works. This issue can take that row or hand it on (AC below). ## Not established - **Whether Forgejo resolves `uses: frankenbit/release-toolkit/.forgejo/workflows/reusable-*.yml@<40-hex>` for a called reusable workflow at all.** `688002e`'s message says it does, but I found no recorded run. If it does not, the job fails before resolve-ref and this bug is masked, but SECURITY.md is still wrong. Measuring it needs a throwaway consumer repo (the AGENTS.md §14 pattern). **Could-not-grade, not assumed.** ## Decision tree: what a SHA ref should do at `bootstrap rt` What an adopter can pin, measured on v0.63.1: | Pinned commit | Example | On main? | Baked ref | `action.yml` image | |---|---|---|---|---| | tag commit (`vX.Y.Z^{commit}`, the #794 digest-pin commit) | `1c84e95` | no | `'v0.63.1'` | `@sha256:a94e8eb0…` (real) | | cut commit (its parent) | `1c84e95^` | yes | `'v0.63.1'` | all-zeros placeholder | | any other main commit | — | yes | `'main'` | all-zeros placeholder | **(A) FETCH the asset named by the marker baked at the pinned SHA.** The resolver keeps the SHA for the checkout and also outputs the baked `'vX.Y.Z'` it already reads, for the bootstrap and the cache key. The marker is content of the pinned tree, so the SHA-to-version step is immutable, and no tag lookup is needed. - *Right when* function matters more than binding `rt` to the SHA, and adopters pin tag or cut commits. - *Leaves:* `rt` bound to the minisign key and the tag *name*, not the SHA. The fetch is `releases/download/<tag>` (`fetch-rt.sh:69`), and the signed `checksums.txt` lists `rt-linux-amd64` with no version in the name (`fetch-rt.sh:68`). A signed manifest from any release therefore verifies any release's binary, and a moved tag or replaced release could serve a different signed `rt`. SECURITY.md would have to say so. Commits baked `'main'` have no asset, so they need (D). **(B) BUILD `rt` from `.release-toolkit`, already checked out at the SHA.** - *Right when* a SHA pin must bind every byte that runs, including `rt`, and SHA-pinning adopters can be asked to carry Go. It works for any commit. - *Costs:* it reverses ADR-0008 §4's *"no Go toolchain on the consumer runner"* for this population. It is opt-in by pin shape, but §4a needs an amendment. The `command -v go` preflight's remedy (*"pin a release tag"*, `bootstrap-rt.sh:51`) needs a SHA variant. Every bootstrapping job pays a build. **(C) Run `rt` from the image digest pinned in `action.yml` at the SHA.** On a tag commit, `action.yml` pins `docker://…/release-toolkit@sha256:…`. The digest is content-addressed, so `rt` is bound to the pinned tree without Go. - *Right when* binding matters and adopters' runners can pull images. - *Leaves:* it works only for tag commits. Cut and main commits carry the all-zeros placeholder (cf. #1205 and #1453), so they need (D). It also needs the image path wired into the composite (related: #1476). **(D) Refuse with an actionable message, and narrow SECURITY.md.** Either give `bootstrap-rt.sh` a `[0-9a-fA-F]{40}` arm that says SHA pinning is unsupported for rt-bootstrapping reusables and names the remedy, or drop the SHA arm from the 8 bootstrapping reusables and their twins and keep it in workflow-parse-check, where it works. - *Right when* no adopter pins by SHA (measured: none today), and the higher-security posture is better served by vendoring, which SECURITY.md:41 already names. - *Costs:* the smallest. SECURITY.md:35/41/70-80 and the v0.62.0 claim must be withdrawn or narrowed in an adopter-visible note. **Combinations:** (A)+(D) or (C)+(D) (function where an asset or digest exists, an honest refusal everywhere else) are coherent, and both keep the `*)` refusal for non-hex refs. **Lean, for the operator to overrule:** (D) now, because it makes the docs true at the smallest cost while no adopter depends on the path. Then (C) when a SHA-pinning adopter appears, since it is the only option that binds `rt` to the pinned tree without adding Go to adopter runners. (A) gives function cheaply, but it weakens what a SHA pin means for `rt` without saying so unless SECURITY.md is rewritten alongside it. ## Acceptance criteria - [x] **A decision is recorded on this issue** among (A)–(D) or a combination. It states what a SHA pin guarantees for the second checkout **and for the `rt` binary**, separately. — DONE in comment 114178: option (D). The second checkout follows the SHA; `rt` is refused with the remedy. - [x] **Every ref shape resolve-ref emits for a recognized consumer pin reaches a bootstrap arm that installs `rt`, or a refusal that names SHA pinning and its remedy.** This holds in all 16 bootstrapping files: the 8 `.forgejo` reusables and their 8 `.gitea` twins. — DONE @ #1515 (`0156d84`, reviewed as `0156d84`) - [x] **A test composes the real resolve-ref bash with `scripts/bootstrap-rt.sh`** for every bootstrapping reusable. The file set is glob-discovered, with tag and `main` controls. It is mutation-verified: reverting the change reddens it. The reproduction below is a starting point. — DONE @ #1515 (`0156d84`, reviewed as `0156d84`) - [x] The existing non-hex refusal still holds: `tests/bootstrap-rt.bats:93`, `feature/861`. — DONE @ #1515 (`0156d84`, reviewed as `0156d84`) - [x] `docs/SECURITY.md` `:35`, `:41` and `:70-80` match the shipped behaviour, including any runner requirement (Go or docker) the chosen option adds. — DONE @ #1515 (`0156d84`, reviewed as `0156d84`) - [x] `composite/bootstrap-rt/action.yml:8`'s `ref` description names every accepted shape. — DONE @ #1515 (`0156d84`, reviewed as `0156d84`) - [x] If the option adds a build or image path for adopters, ADR-0008 §4a is amended. — N/A under option (D): no build or image path was added (decision, comment 114178) - [x] **Before any doc says SHA pinning works end-to-end**, a throwaway consumer repo (verified workflow-free per AGENTS.md §14) measures that Forgejo resolves `uses: …/reusable-*.yml@<40-hex>`, and the run is linked here. — N/A under option (D): no doc claims SHA pins work; the measurement moved to #1517's first AC - [x] The short-hex-pin-of-a-main-commit row (#1222 fallback, 16/16 refused) is either handled here or filed separately and linked. — DONE @ #1515 (`0156d84`, reviewed as `0156d84`) - [x] A changelog fragment, because the change is adopter-visible: the SECURITY.md recommendation changes under every option. — DONE @ #1515 (`0156d84`, reviewed as `0156d84`) ## Reproduction text The full test text, against `tests/bootstrap-rt.bats` at `4ffde69`. It is not pushed. Run it with `bats -f REPRO tests/bootstrap-rt.bats` in an image with bats and python3-yaml. <details> <summary>tests/bootstrap-rt.bats additions (two @test blocks)</summary> ```bash # REPRODUCTION, not a contract. resolve-ref's #336 arm hands bootstrap a full # 40-hex SHA for a SHA-pinned consumer. Both arms are made available here (a # fetch script and a go on PATH), so a refusal can only come from arm # selection. The fix for this issue replaces this test. @test "REPRO: a full 40-hex SHA ref, the shape resolve-ref emits for a SHA pin (#336), reaches neither arm" { sha=1c84e95d0fef01f4ad40cdd8b8e60a60bef8eecc mkdir -p "$TMP/toolkit/cmd/rt" "$TMP/bin" cat > "$TMP/toolkit/scripts/fetch-rt.sh" <<EOF #!/usr/bin/env bash : > "$TMP/fetch-called" EOF cat > "$TMP/bin/go" <<EOF #!/usr/bin/env bash : > "$TMP/go-called" EOF chmod +x "$TMP/toolkit/scripts/fetch-rt.sh" "$TMP/bin/go" run env PATH="$TMP/bin:/usr/bin:/bin" RT_REF="$sha" RT_CONTEXT=release \ RT_TOOLKIT_CHECKOUT="$TMP/toolkit" GITHUB_WORKSPACE="$TMP/workspace" \ GITHUB_PATH="$GITHUB_PATH" bash "$SCRIPT" echo "status=$status" echo "$output" [ "$status" -eq 1 ] [[ "$output" == *"release: unsupported toolkit ref '$sha'; only version tags (v...) and main have bootstrap arms"* ]] [ ! -e "$TMP/fetch-called" ] [ ! -e "$TMP/go-called" ] } # REPRODUCTION, not a contract: measure every reusable's resolve-ref -> # composite -> bootstrap-rt.sh chain for each consumer pin shape, running the # real resolver bash out of each workflow file. Controls: a tag pin must FETCH # and a main pin must BUILD in every bootstrapping reusable, or the SHA rows # below prove nothing. @test "REPRO: every bootstrapping reusable refuses a SHA-pinned consumer at bootstrap rt (#336 x #861)" { run python3 - "$BATS_TEST_DIRNAME/.." "$SCRIPT" "$TMP" <<'PY' import os import re import subprocess import sys from pathlib import Path import yaml repo = Path(sys.argv[1]).resolve() bootstrap = sys.argv[2] tmp = Path(sys.argv[3]) SHA = "1c84e95d0fef01f4ad40cdd8b8e60a60bef8eecc" # v0.63.1^{commit} TAG = "v0.63.1" REF_EXPR = "${{ steps.resolve-ref.outputs.ref }}" composite = yaml.safe_load((repo / "composite/bootstrap-rt/action.yml").read_text()) boot_env = next(s for s in composite["runs"]["steps"] if s.get("name") == "bootstrap rt")["env"] if boot_env.get("RT_REF") != "${{ inputs.ref }}": print("composite maps the ref before bootstrap-rt.sh:", boot_env.get("RT_REF")) sys.exit(1) print("composite: RT_REF = ${{ inputs.ref }} (passed through unmapped)") # Fixtures shared by every bootstrap run: both arms can succeed if reached. toolkit = tmp / "toolkit" (toolkit / "cmd/rt").mkdir(parents=True, exist_ok=True) (toolkit / "scripts").mkdir(parents=True, exist_ok=True) fetch = toolkit / "scripts/fetch-rt.sh" fetch.write_text('#!/usr/bin/env bash\nmkdir -p "$RT_INSTALL_DIR"\n: > "$RT_INSTALL_DIR/rt"\n') fetch.chmod(0o755) bindir = tmp / "bin" bindir.mkdir(exist_ok=True) gobin = bindir / "go" gobin.write_text('#!/usr/bin/env bash\nprintf "#!/bin/sh\\n" > "$3"\nchmod +x "$3"\n') gobin.chmod(0o755) CASES = [ # (label, wrapper pin, baked value in the reusable AT that pin) ("tag pin (control)", TAG, TAG), ("main pin (control)", "main", "main"), ("SHA pin of a tag commit", SHA, TAG), ("SHA pin of a main commit", SHA, "main"), ("12-hex pin of a main commit", SHA[:12], "main"), ("12-hex pin of a tag commit", SHA[:12], TAG), ] def resolve(run, wrapper_path, provider, reusable, pin, baked, n): root = tmp / f"consumer-{n}" wrapper = root / wrapper_path wrapper.parent.mkdir(parents=True, exist_ok=True) wrapper.write_text( f"jobs:\n j:\n uses: frankenbit/release-toolkit/{provider}/workflows/{reusable}@{pin}\n") out = root / "github-output" out.write_text("") r = subprocess.run(["bash", "-c", run], cwd=root, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env={**os.environ, "BUILD_BAKED_TOOLKIT_REF": baked, "GITHUB_OUTPUT": str(out)}) refs = re.findall(r"^ref=(.*)$", out.read_text(), re.M) src = re.search(r"source=([^)]*)\)", r.stdout) return r.returncode, (refs[0] if refs else None), (src.group(1) if src else "?") def boot(ref, context, n): home = tmp / f"home-{n}" home.mkdir(exist_ok=True) ws = tmp / f"ws-{n}" ws.mkdir(exist_ok=True) gp = tmp / f"path-{n}" gp.write_text("") r = subprocess.run(["bash", bootstrap], text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env={"PATH": f"{bindir}:/usr/bin:/bin", "HOME": str(home), "RT_REF": ref, "RT_CONTEXT": context, "RT_TOOLKIT_CHECKOUT": str(toolkit), "GITHUB_WORKSPACE": str(ws), "GITHUB_PATH": str(gp)}) line = next((l for l in r.stdout.splitlines() if l.strip()), "") return r.returncode, line n = 0 discovered = 0 no_bootstrap = [] broken = {label: [] for label, _, _ in CASES} for provider in (".forgejo", ".gitea"): for path in sorted((repo / provider / "workflows").glob("reusable-*.yml")): wf = yaml.safe_load(path.read_text()) for job in (wf.get("jobs") or {}).values(): steps = [s for s in (job.get("steps") or []) if isinstance(s, dict)] ids = [s.get("id") for s in steps] if "resolve-ref" not in ids: continue discovered += 1 name = f"{provider}/workflows/{path.name}" run = steps[ids.index("resolve-ref")]["run"] m = re.search(r"extract_toolkit_wrapper_ref (\S+) (\S+)\)", run) wrapper_path, reusable = m.group(1), m.group(2) bsteps = [s for s in steps if s.get("uses") == "./.release-toolkit/composite/bootstrap-rt"] if not bsteps: no_bootstrap.append(name) print(f"-- {name}: no bootstrap-rt step (rt is never installed)") continue if bsteps[0]["with"]["ref"] != REF_EXPR: print(name, "bootstrap ref is not resolve-ref's output:", bsteps[0]["with"]["ref"]) sys.exit(1) context = bsteps[0]["with"].get("context", "rt bootstrap") for label, pin, baked in CASES: n += 1 rc, ref, src = resolve(run, wrapper_path, provider, reusable, pin, baked, n) if rc != 0: print(name, label, "resolver exited", rc) sys.exit(1) brc, bline = boot(ref, context, n) verdict = "ok" if brc == 0 else "REFUSED" print(f"{name:55} {label:28} ref={ref:<40} source={src:<45} bootstrap={verdict}: {bline}") if brc != 0: broken[label].append(name) bootstrapping = discovered - len(no_bootstrap) print() print(f"discovered {discovered} resolve-ref steps; {bootstrapping} bootstrap rt; {len(no_bootstrap)} do not") for label, _, _ in CASES: print(f"SUMMARY {label}: {len(broken[label])}/{bootstrapping} refused") ok = (discovered == 18 and bootstrapping == 16 and not broken["tag pin (control)"] and not broken["main pin (control)"] and len(broken["SHA pin of a tag commit"]) == 16 and len(broken["SHA pin of a main commit"]) == 16) sys.exit(0 if ok else 1) PY echo "$output" [ "$status" -eq 0 ] [[ "$output" == *"SUMMARY tag pin (control): 0/16 refused"* ]] [[ "$output" == *"SUMMARY main pin (control): 0/16 refused"* ]] [[ "$output" == *"SUMMARY SHA pin of a tag commit: 16/16 refused"* ]] [[ "$output" == *"SUMMARY SHA pin of a main commit: 16/16 refused"* ]] } ``` </details>
Author
Member

Decision (operator, 2026-10-02): option (D), kept narrow.

  • scripts/bootstrap-rt.sh gets a hex arm that refuses with an actionable message: commit-SHA pins are not supported by the reusables that install rt, so pin a release tag. The arm covers 7 to 40 hex characters, so the short-hex row (the #1222 fallback, AC 9) gets the same message instead of a separate issue.
  • The SHA arms in all 18 resolve-ref steps stay. The second checkout still resolves through the SHA, reusable-workflow-parse-check keeps working SHA-pinned, and a later functional option would reuse them.
  • docs/SECURITY.md and the composite's ref description are narrowed to what ships.
  • The functional fix, (C) or (A)/(B), is out of scope here. It gets its own issue once the (D) PR lands, to be picked up when a SHA-pinning adopter appears.

AC 7 (ADR-0008 amendment) and AC 8 (measure Forgejo's @<40-hex> resolution) apply only if a doc claims SHA pinning works end to end. Under (D) no doc does, so the PR will mark both not applicable, with this reason.

**Decision (operator, 2026-10-02): option (D), kept narrow.** - `scripts/bootstrap-rt.sh` gets a hex arm that refuses with an actionable message: commit-SHA pins are not supported by the reusables that install `rt`, so pin a release tag. The arm covers 7 to 40 hex characters, so the short-hex row (the #1222 fallback, AC 9) gets the same message instead of a separate issue. - The SHA arms in all 18 resolve-ref steps **stay**. The second checkout still resolves through the SHA, `reusable-workflow-parse-check` keeps working SHA-pinned, and a later functional option would reuse them. - `docs/SECURITY.md` and the composite's `ref` description are narrowed to what ships. - The functional fix, (C) or (A)/(B), is out of scope here. It gets its own issue once the (D) PR lands, to be picked up when a SHA-pinning adopter appears. AC 7 (ADR-0008 amendment) and AC 8 (measure Forgejo's `@<40-hex>` resolution) apply only if a doc claims SHA pinning works end to end. Under (D) no doc does, so the PR will mark both not applicable, with this reason.
Author
Member

Shutting this by hand (AGENTS.md §15), every acceptance criterion ticked above.

  • Landed: 0156d84fe764db2a0b02bae6da91af22b11ecf14 on main, via #1515, fast-forwarded. The merge commit is the head, so what landed is byte-for-byte what was reviewed.
  • Approval: review 7190 by alex, APPROVED at 0156d84.
  • Checks: all 46 contexts on 0156d84 are success (combined success), read 2026-10-02 after the merge.
  • Head branch: claude/1514-sha-pin-bootstrap-refusal is gone (404).

Follow-ups:

  • #1517: make a SHA pin work for the reusables that install rt. Low priority, to be picked up when a consumer wants it. Its first AC is the Forgejo @<40-hex> measurement that AC 8 here deferred.
  • #1516: separate from SHA pins. The signed checksums.txt names no version, so an older signed release passes verification under a newer tag. Found while narrowing docs/SECURITY.md here.
Shutting this by hand (AGENTS.md §15), every acceptance criterion ticked above. - **Landed:** `0156d84fe764db2a0b02bae6da91af22b11ecf14` on `main`, via #1515, fast-forwarded. The merge commit is the head, so what landed is byte-for-byte what was reviewed. - **Approval:** review 7190 by alex, `APPROVED` at `0156d84`. - **Checks:** all 46 contexts on `0156d84` are `success` (combined `success`), read 2026-10-02 after the merge. - **Head branch:** `claude/1514-sha-pin-bootstrap-refusal` is gone (404). Follow-ups: - #1517: make a SHA pin *work* for the reusables that install `rt`. Low priority, to be picked up when a consumer wants it. Its first AC is the Forgejo `@<40-hex>` measurement that AC 8 here deferred. - #1516: separate from SHA pins. The signed `checksums.txt` names no version, so an older signed release passes verification under a newer tag. Found while narrowing `docs/SECURITY.md` here.
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#1514
No description provided.