refactor: bake toolkit_ref into _release.yml at build time (eliminate the consumer-side duplication) #148

Closed
opened 2026-06-27 01:27:54 +02:00 by quartermaster · 1 comment

The substrate-honest gap

Today's consumer release.yml specifies the toolkit ref twice:

jobs:
  release:
    uses: frankenbit/release-toolkit/.forgejo/workflows/_release.yml@v0.11.0
    with:
      toolkit_ref:   v0.11.0     # MUST match the @ref above
      runs_on:       go

The "MUST match the @ref above" comment is the smoking gun: the consistency between uses: @ref and toolkit_ref: ref is enforced by convention, not by construction. A consumer who mismatches them (intentionally or by typo) gets a silently-inconsistent workflow run (the reusable file is loaded from one ref, but the toolkit scripts are checked out at another).

Why I ruled out the context-var path first

Initial hypothesis: maybe the reusable can self-discover its own ref via a context variable (github.workflow_ref / github.action_ref / etc.), eliminating the duplication without any build-time machinery.

Empirical probe on 2026-06-27 (branch i/probe-reusable-context, captured in tracker #1 comment) showed: github.workflow_ref inside a reusable points at the CALLER's workflow + ref, NOT the reusable's. For an external consumer's cut, the reusable would see cellblock/release.yml@main — not frankenbit/release-toolkit/_release.yml@v0.11.0. The toolkit ref isn't in the context vars at all.

This matches GitHub Actions documented behavior for reusable workflows; github.action_ref populates for step-level actions but not for workflow_call reusables. Substrate-feature gap, not substrate-honest-debt.

The proposed fix (operator engagement 2026-06-27)

Bake the ref directly into _release.yml at build time. The cut's release-prep.sh rewrites the marker-anchored value in _release.yml BEFORE creating the prep commit; the tag for that cut version then points at a commit whose _release.yml has the matching ref baked in.

Mechanism

  1. _release.yml gains a hardcoded ref: (no inputs.toolkit_ref lookup), anchored by a marker comment:

    - name: checkout release-toolkit
      uses: actions/checkout@v4
      with:
        repository: frankenbit/release-toolkit
        ref: 'v0.11.0'  # release-toolkit-build-ref (auto-updated by release-prep.sh)
        path: .release-toolkit
    
  2. release-prep.sh gains a step that rewrites the marker-anchored value to match the next-version BEFORE the prep commit:

    sed -i "s|ref: '[^']*'  # release-toolkit-build-ref|ref: '$NEXT_VERSION'  # release-toolkit-build-ref|" .forgejo/workflows/_release.yml
    
  3. Same edits to _manifest-check.yml.

  4. Consumer's wrapper drops toolkit_ref entirely:

    jobs:
      release:
        uses: ...@v0.11.0
        with:
          runs_on: go
    

Why this is better than the input-based design

Property Input-based (today) Build-baked (proposed)
Consistency between uses: @ref and toolkit checkout Convention-enforced ("MUST match") Construction-enforced (tag points at commit with matching baked value)
Re-pin discipline lines per upgrade 2 (uses + toolkit_ref) 1 (uses only)
Substrate-honest-debt Real Resolved

Implementation surface

  • .forgejo/workflows/_release.yml + _manifest-check.yml: hardcoded ref + remove the toolkit_ref workflow_call input declaration
  • scripts/release-prep.sh: new sed step rewrites the marker-anchored ref to $NEXT_VERSION before the prep commit
  • Consumer wrappers (toolkit-self's release.yml + manifest-check.yml): drop the toolkit_ref: line
  • docs/integration.md: quick-start template updated
  • scripts/check-self-bootstrap.sh: switch from reading toolkit_ref: in release.yml to reading @<ref> from uses: line (single source of truth alignment)
  • tests/check-self-bootstrap.bats: update fixture for the new reading path
  • New bats coverage for the release-prep.sh rewrite step (script-level test seam mirroring existing patterns)
  • AGENTS.md §2: update the re-pin discipline to note it's now 1-line not 2-line

Estimated scope: ~6 files modified + ~30 bats lines + ~20 lines in release-prep.sh. Roughly one PR cycle.

Composition

  • External consumer impact: cellblock is the one external adopter. Their release.yml needs the toolkit_ref: line dropped on next upgrade — matches the next version bump anyway, so no extra cycles.
  • Pre-1.0 readiness sweep: zero-adopter status at the schema-shift moment means hard-drop the input is appropriate (no deprecation cycle needed).
  • Related discipline (#124): the in-cycle re-pin discipline + structural backstop. The backstop's compose-script comparison still works — it now reads the uses: @<ref> line as the single source of truth instead of cross-checking against toolkit_ref:.

What this PR does NOT do

  • Does NOT eliminate runs_on: per probe (B), the vars-based route has timing quirks; the runs_on workflow_call input stays for clarity.
  • Does NOT add a deprecation window for toolkit_ref: zero pre-refactor adopters need backwards-compat (cellblock will update on next bump anyway).
  • Does NOT change the cut mechanism: this is a build-time substrate refactor, not a runtime behavior change.

Refs

  • Operator engagement 2026-06-27: surfaced the "why duplicate?" question + proposed the build-bake mechanism
  • Probe artifact: tracker #1 comment 2026-06-27 01:11 — github.workflow_ref empirical resolution
  • Sister substrate: ADR-0007 path-α mechanism (the toolkit's own self-bootstrap loop that the cut path operates on)
  • Calibration banked: "detector-output that confirms what I want to believe gets the SAME independent-probe rigor as detector-output that contradicts it" (sister to Surveyor's recursive verify-at-source pin)
## The substrate-honest gap Today's consumer `release.yml` specifies the toolkit ref **twice**: ```yaml jobs: release: uses: frankenbit/release-toolkit/.forgejo/workflows/_release.yml@v0.11.0 with: toolkit_ref: v0.11.0 # MUST match the @ref above runs_on: go ``` The "MUST match the @ref above" comment is the smoking gun: the consistency between `uses: @ref` and `toolkit_ref: ref` is enforced by *convention*, not by *construction*. A consumer who mismatches them (intentionally or by typo) gets a silently-inconsistent workflow run (the reusable file is loaded from one ref, but the toolkit scripts are checked out at another). ## Why I ruled out the context-var path first Initial hypothesis: maybe the reusable can self-discover its own ref via a context variable (`github.workflow_ref` / `github.action_ref` / etc.), eliminating the duplication without any build-time machinery. Empirical probe on 2026-06-27 (branch `i/probe-reusable-context`, captured in tracker #1 comment) showed: `github.workflow_ref` inside a reusable points at the **CALLER's** workflow + ref, NOT the reusable's. For an external consumer's cut, the reusable would see `cellblock/release.yml@main` — not `frankenbit/release-toolkit/_release.yml@v0.11.0`. The toolkit ref isn't in the context vars at all. This matches GitHub Actions documented behavior for reusable workflows; `github.action_ref` populates for step-level actions but not for workflow_call reusables. **Substrate-feature gap, not substrate-honest-debt.** ## The proposed fix (operator engagement 2026-06-27) Bake the ref directly into `_release.yml` at build time. The cut's `release-prep.sh` rewrites the marker-anchored value in `_release.yml` BEFORE creating the prep commit; the tag for that cut version then points at a commit whose `_release.yml` has the matching ref baked in. ### Mechanism 1. `_release.yml` gains a hardcoded `ref:` (no `inputs.toolkit_ref` lookup), anchored by a marker comment: ```yaml - name: checkout release-toolkit uses: actions/checkout@v4 with: repository: frankenbit/release-toolkit ref: 'v0.11.0' # release-toolkit-build-ref (auto-updated by release-prep.sh) path: .release-toolkit ``` 2. `release-prep.sh` gains a step that rewrites the marker-anchored value to match the next-version BEFORE the prep commit: ```bash sed -i "s|ref: '[^']*' # release-toolkit-build-ref|ref: '$NEXT_VERSION' # release-toolkit-build-ref|" .forgejo/workflows/_release.yml ``` 3. Same edits to `_manifest-check.yml`. 4. Consumer's wrapper drops `toolkit_ref` entirely: ```yaml jobs: release: uses: ...@v0.11.0 with: runs_on: go ``` ## Why this is better than the input-based design | Property | Input-based (today) | Build-baked (proposed) | |---|---|---| | Consistency between `uses: @ref` and toolkit checkout | Convention-enforced ("MUST match") | Construction-enforced (tag points at commit with matching baked value) | | Re-pin discipline lines per upgrade | 2 (uses + toolkit_ref) | 1 (uses only) | | Substrate-honest-debt | Real | Resolved | ## Implementation surface - `.forgejo/workflows/_release.yml` + `_manifest-check.yml`: hardcoded ref + remove the `toolkit_ref` workflow_call input declaration - `scripts/release-prep.sh`: new sed step rewrites the marker-anchored ref to `$NEXT_VERSION` before the prep commit - Consumer wrappers (toolkit-self's `release.yml` + `manifest-check.yml`): drop the `toolkit_ref:` line - `docs/integration.md`: quick-start template updated - `scripts/check-self-bootstrap.sh`: switch from reading `toolkit_ref:` in `release.yml` to reading `@<ref>` from `uses:` line (single source of truth alignment) - `tests/check-self-bootstrap.bats`: update fixture for the new reading path - New bats coverage for the release-prep.sh rewrite step (script-level test seam mirroring existing patterns) - AGENTS.md §2: update the re-pin discipline to note it's now 1-line not 2-line Estimated scope: ~6 files modified + ~30 bats lines + ~20 lines in release-prep.sh. Roughly one PR cycle. ## Composition - **External consumer impact**: cellblock is the one external adopter. Their `release.yml` needs the `toolkit_ref:` line dropped on next upgrade — matches the next version bump anyway, so no extra cycles. - **Pre-1.0 readiness sweep**: zero-adopter status at the schema-shift moment means hard-drop the input is appropriate (no deprecation cycle needed). - **Related discipline (#124)**: the in-cycle re-pin discipline + structural backstop. The backstop's compose-script comparison still works — it now reads the `uses: @<ref>` line as the single source of truth instead of cross-checking against `toolkit_ref:`. ## What this PR does NOT do - **Does NOT eliminate `runs_on`**: per probe (B), the vars-based route has timing quirks; the `runs_on` workflow_call input stays for clarity. - **Does NOT add a deprecation window for `toolkit_ref`**: zero pre-refactor adopters need backwards-compat (cellblock will update on next bump anyway). - **Does NOT change the cut mechanism**: this is a build-time substrate refactor, not a runtime behavior change. ## Refs - **Operator engagement 2026-06-27**: surfaced the "why duplicate?" question + proposed the build-bake mechanism - **Probe artifact**: tracker #1 comment 2026-06-27 01:11 — `github.workflow_ref` empirical resolution - **Sister substrate**: ADR-0007 path-α mechanism (the toolkit's own self-bootstrap loop that the cut path operates on) - **Calibration banked**: *"detector-output that confirms what I want to believe gets the SAME independent-probe rigor as detector-output that contradicts it"* (sister to Surveyor's recursive verify-at-source pin)
Author
Owner

Closing — Phase-1 (build-bake mechanism) shipped earlier; Phase-2 (drop toolkit_ref input + structural collapse of resolve-ref + detached-bake at re-pin via scripts/repin.sh) shipped via #172/#222 + the post-merge self-bootstrap loop validated via #224. The structural single-source-of-truth invariant the parent tracker described is now in main: every pinnable ref (cut tag, rc tag, main) carries its own baked value + resolve-ref reads BUILD_BAKED_TOOLKIT_REF unconditionally.

Sibling follow-ups still open: #209 (dogfood post_bump_hooks for own doc ref-pin updates), #196 (doc ref-pin drift — addressable by #209 or a manual sweep).

Closing — Phase-1 (build-bake mechanism) shipped earlier; Phase-2 (drop `toolkit_ref` input + structural collapse of resolve-ref + detached-bake at re-pin via `scripts/repin.sh`) shipped via #172/#222 + the post-merge self-bootstrap loop validated via #224. The structural single-source-of-truth invariant the parent tracker described is now in main: every pinnable ref (cut tag, rc tag, main) carries its own baked value + resolve-ref reads `BUILD_BAKED_TOOLKIT_REF` unconditionally. Sibling follow-ups still open: #209 (dogfood post_bump_hooks for own doc ref-pin updates), #196 (doc ref-pin drift — addressable by #209 or a manual sweep).
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#148
No description provided.