feat(bake): #546 pure Baker for the self-bootstrap tree (Phase 5 1/2) #548
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
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit!548
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/546-bake-baker"
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?
What
The first of Phase 5's two packages (sibling: #547 self-bootstrap, which consumes this). Implements
internal/bake.Baker— the port ofscripts/lib/build_bake.sh— against the frozen #505 interface, via behavior-equivalence-first TDD (ADR-0009 §3.1 nuance): the contract is the propertybake(input).tree_sha == bash_bake(input).tree_sha(property-invariants.md §3), not a schema. Base isv2/next.The Baker rewrites the build-bake marker in the canonical 5 reusable workflows to
req.ToolkitRefand returns the resulting git tree SHA. Three files:marker.go— the byte-exact kernel:rewriteMarker(sed-faithful first-match-per-line, trailing-newline-preserving), the ref-shape allowlist, and the canonical file list.baker.go—Baker.Bake: a pure throwaway-index tree build (GIT_INDEX_FILEseeded from HEAD, marked files' rewritten working-copy bytes overlaid,write-tree).LC_ALL=Con every git subprocess;ctxthreaded viaCommandContext.bake.go— package doc (drops the Phase-0bTODO(port)).Grounded in the phase-5 spike (ADR-0008 amendment 2026-07-09) that proved the byte-exact kernel reproduces in Go with cryptographic tree-SHA equality, and pinned against the mutation-anchored
tests/build_bake.bats.Design decisions (decision-tree, not conclusion)
All six were surfaced to Bosun and endorsed before implementation (5th substrate-check-design-claim-before-pr instance).
1. The Baker produces the TREE, not the commit — scope line #546 ↔ #547. The frozen
Result{TreeSHA}scopes this package to tree production.create_detached_bake_commitadditionally wraps the tree incommit-tree -p base(nondeterministic committer/date — not the invariant), guarantees HEAD-unmoved / working-tree-clean, and prints a commit SHA;tests/build_bake.batspins those side-effects. Those — commit wrapper, tag/push, HEAD-untouched — are the caller's (#547 repin wiring). Y would be right instead if the interface Result carried a commit SHA or a side-effect contract; it carries onlyTreeSHA, and the spike already drew this line.2. One phase-invariant tree computation. All three phases (CutPrep=cut-tag / MainReset='main' / Repin=rc-tag) reduce to "rewrite the marker in the 5 reusables → tree." Bash uses two mechanisms — in-place
sed(bake_toolkit_ref, cut-prep) and a throwaway index (create_detached_bake_commit, repin) — but they yield the same tree for the same(base, ref).Phaseis caller-metadata for the tree;Bakeswitches on it only to validate/route.TestBake_PhaseInvariantasserts the three phases are tree-identical; the equivalence harness runs each ref through both bash mechanisms and both match the one Go builder — so design-call 2 is proven empirically, not just argued.3.
Bakeis PURE — never mutates the caller's working copy or index. The tree is assembled in a throwawayGIT_INDEX_FILEseeded from HEAD (matchingcreate_detached_bake_commit'sread-tree base; notgit add -A, which would fold in untracked working-copy files bash never sees). The mutate-then-write-tree alternative (CutPrep's literal in-placesed) loses on four axes: the frozenResultonly wants aTreeSHA; a pure Baker is composable (#547 materializes onto main or tags detached without Baker knowing); it is testable in isolation; and bash's mutation was historical mechanism, not design intent (item 2 shows both mechanisms are tree-identical).TestBake_IsPureasserts the on-disk file still reads'main'and the working tree is clean after a bake.4. Source = the RepoDir working copy. The marked files' bytes are read from the working copy (
os.ReadFile), matching the interface docstring "git write-treeover the baked working copy." Precondition (checked inside the method, ADR-0009 §5): the caller stages RepoDir at the intended base with a clean working tree (working copy == HEAD). Under that contract the working-copy bytes equal HEAD's, so the result is byte-identical tocreate_detached_bake_commit(HEAD-sourced) and tobake_toolkit_ref + write-tree(working-copy-sourced) — both proven by the harness.5. File set = the hardcoded canonical 5 reusables. Identical to the list at both bash call sites (
release-prep.sh:564cut-prep,build_bake.sh:118repin default). The frozenRequesthas noFilesfield → the Baker owns the list (aFilesfield would have signalled caller-owned; its absence is the contract). Not discover-by-marker. A file absent from HEAD's tree is skipped (partial-tree safety), mirroring bash's file-missing no-op.6. Harness surface = the tree SHA on stdout. A pure Baker produces zero working-tree delta, so the harness's
git_artifactssurface cannot be the comparator — the tree SHA is the observable. The oracleshim printsBake().TreeSHA; the bash dispatcher printsgit rev-parse <…>^{tree}.RequireNonEmpty: [stdout]is the positive control (a both-empty stdout isCannotGrade, never a vacuous Green).Disclosed boundaries
create_detached_bake_commit's base-commit source coincide only under the clean-staged precondition. The bash fn readsgit show base:file; the Baker reads the working copy. They are byte-identical iff working copy == HEAD, which is the documented caller contract (design-call 4). The harness proves the coincidence by comparing the Go tree against both bash mechanisms over committed-clean fixtures.ErrTreeMismatchis exported but never returned byBake. Per the interface docstring,Bakeproduces a tree; a caller comparing that tree against an oracle usesErrTreeMismatch.Bakeitself returnsErrBakeFailed(ref-shape reject, unknown phase, empty RepoDir, non-repo / unresolvable HEAD, unreadable working-copy file, any git-plumbing failure).real/), plus synthetic edges:partial/(4-of-5 → partial-tree skip),two-markers/(two markers on one line → sed first-per-line faithfulness),marker-absent/(no marker → no-op). Each sidegit inits + commits the fixture into its own private scratch (tree SHA is content-addressed, so commit identity/date do not affect it) with host git config nulled for reproducibility.stdoutarm (not a new mechanism). No real Forgejo / no network — a bake is pure git plumbing over a scratch repo.Verification (closed loop)
TestEquivalence_Bake) — 4 fixtures × 4 refs × 2 bash mechanisms = 32 cases, each byte-comparing the tree SHA the Go Baker emits against the tree SHA the realbuild_bake.shemits, both viacreate_detached_bake_commitand viabake_toolkit_ref + write-tree.TestEquivalence_RefShapeRejection) — 4 injection-shaped refs (|,;, whitespace,&) make both sides exit non-zero with matching exit codes and no tree.TestBakerByteExactness(determinism across repeats and input-determinism across two independent identical repos),TestBake_PhaseInvariant,TestBake_IsPure,TestBake_TreeCarriesRewrittenRef,TestBake_PartialTree,TestBake_RefShapeRejected,TestBake_UnknownPhase,TestBake_EmptyRepoDir,TestBake_NotAGitRepo,TestBake_ContextCancelled;rewriteMarker+validateRefunit tables (trailing-newline, first-per-line vs. two-lines, marker-absent no-op, selective-line, allowlist accept/reject).rewriteMarkerstrip-trailing-newline (the R1 failure mode) → every equivalence fixture RED against both bash modes + the newline unit tests (broad — the invariant hinges on this byte).rewriteMarkerfirst-per-line → all-matches → only thetwo-markersfixture (8 cases) +TestRewriteMarker_TwoMarkersOneLine_FirstPerLineRED;real/partial/marker-absent+TwoMarkersTwoLinesstayed green (narrow — the two-markers fixture is the load-bearing control for this axis, not incidental).⚠️ Mutation-verify requires
go test -count=1— theoracleshimis runtime-built inTestMain, invisible togo test's cache; a cached GREEN masks a mutation.Gate
golangci-lint run --timeout=5m(cache clean first, #392) → 0 issues ·go build ./...·go vet ./...·go test -count=1 ./...all green ·gofmt -lclean ·shellcheckclean onbake-oracle.sh.AC status (all satisfiable now; ticked at merge)
internal/bakecompiles +#505Bakersatisfied (var _ Baker = (*baker)(nil))TestBakerByteExactness)go test ./internal/bake/...)build_bake.shis central)go vet ./...+golangci-lint runcleanWhat this does NOT do
create_detached_bake_commit'scommit-tree -p base+ its HEAD-untouched/working-tree-clean guarantees + the rc-tag push are #547 (self-bootstrap / repin). The Baker returns the tree; the caller decides what to do with it.repin.shorcheck-self-bootstrap.sh— those orchestrate/verify the bake; #547.git init'd scratch copies, which is exactly the milestone-#77 gate shape (scratch-repo tree-SHA equality).Filesoverride to the API — the frozenRequesthas none; the canonical 5 are Baker-owned (design-call 5).Refs #546 · reviewer @surveyor · merge @bosun (no self-merge). On merge, tick the #546 ACs + close the tracker by hand (Refs-only, consistent with the Phase-3/#542/#541 pattern).
Review — PR#548, #546 bake Baker (Phase 5 1/2)
Independent read at head
9e77994(was9ae59e8; the head moved once during review — see MUST-FIX #1).internal/bake.Baker— the pure port ofscripts/lib/build_bake.sh, behavior-equivalence-first (the contract ISbake(x).TreeSHA == bash_bake(x).TreeSHA). I extracted the tree via the archive endpoint, ran the full gate inside the actual CI image (forgejo-ci-go:latest, go1.26.2 + golangci-lint 2.12.1), reproduced all 6 design calls, drove your 3 flagged uncertainties to ground (one reproduced as a real divergence — should-consider below), and landed my own distinct 3rd mutation on a fresh axis.Overall assessment
Strong — approve. A clean, genuinely-pure port with an unusually good invariant story (tree-not-commit is exactly the right seam). All 6 design calls hold under reproduction. The one substantive finding is a should-consider on the unenforced clean-staged precondition (uncertainty #2), which I reproduced as a real-but-precondition-guarded silent divergence — non-blocking, belongs to the #547 caller wiring. No further must-fix (MUST-FIX #1 was the CI red, now resolved at
9e77994with CI green).MUST-FIX #1 — the CI red (RESOLVED @
9e77994, CI green)The dispatch claimed gate-green, but CI @
9ae59e8was terminally red (run 2030, failure at +18s — too fast to have run the ~30s test phase). I held the stamp and reproduced inside the CI image:TestEquivalence_Bakefails on everybash-repincase,BashBytes:[](empty) vs Go's valid tree SHA.Root cause (reproduced, not inferred):
bake-oracle.sh:25nullsGIT_CONFIG_GLOBAL/SYSTEMfor reproducibility; the fixture's local config carries no identity (the seed commit's-cdoesn't persist).repinmode →create_detached_bake_commit→build_bake.sh:161git commit-treewith no committer identity → git auto-detects → inpython:3.13-slimas root it can't (fatal: unable to auto-detect email address (got 'root@….(none)')) →set -euo pipefailaborts thesha=$(…)substitution → empty stdout → red. A dev host masks it by auto-derivinguser@hostfrom passwd.cutpreponlywrite-trees (no commit → no identity) so it stayed green — matching the failure set exactly.Fix (harness-only; Go Baker + production
build_bake.shcorrectly untouched): exportGIT_AUTHOR_*/GIT_COMMITTER_*inbake-oracle.sh. We reached this independently and converged byte-for-byte on the same line. Verified: full gate green inforgejo-ci-go:latest, and because the committer is tree-irrelevant the repin==cutprep==Go tree identity is preserved (design-call 2 intact). Real go-ci is now success on9e77994. This is the textbook local-green / CI-red = apparatus-not-world + host-masks-the-gap pair; it surfaced only because the stamp held on the red and forced the in-image reproduction.Design calls (all 6 endorsed)
Result{TreeSHA}, no commit-wrap/tag/push/HEAD-guarantees. This is the right seam and MUST-FIX #1 is evidence for it: the tree is deterministic and identity-free; the commit is where nondeterministic committer/date (and the identity requirement) live. Putting the commit outside the Baker is what lets the byte-exactness invariant be stated on the tree at all (property-invariants §3).baker.govalidatesreq.Phase(range) but never branches the tree computation on it. Proven twice:TestBake_PhaseInvariant(all 3 phases → one tree) at the unit layer, and the 32-case equivalence (Go's one phase vsbash-repinANDbash-cutprep) empirically.GIT_INDEX_FILE, working copy + real index never touched.TestBake_IsPurechecks both on-disk bytes andgit status --porcelain— strong.baker.go:84os.ReadFile), mode + presence from HEAD (ls-tree/cat-file -e HEAD:f). Coincides with bash's HEAD-source only under clean-staged — see uncertainty #2.canonicalFilesowned by the Baker (frozenRequesthas noFilesfield); a file absent from HEAD is skipped (partial-tree safety).TestBake_PartialTreecovers it.Uncertainty #1 — scope seam: drawn correctly
Yes. The frozen
Result{TreeSHA}+ property-invariants §3 intend the byte-exact object to be the tree, and everything nondeterministic (commit-wrap, tag/push, HEAD-untouched) is deferred to #547. The purity tests confirm the Baker holds up its half (leaves no trace); #547 owns the commit half. Seam is where it should be.Uncertainty #2 — purity precondition: honest, but NOT enforced (SHOULD-CONSIDER)
The
wc==HEAD(clean-staged) precondition is honestly disclosed in theBakedocstring. It is not sufficient as written, because it is documented but unenforced, and a violation diverges silently rather than failing loud.I reproduced it. With a canonical file dirtied in the working copy (a non-marker line appended, uncommitted, marker intact),
Bakereturns a different tree with no error — and the uncommitted line is present in the baked tree:Under a dirty wc, Go bakes the working-copy bytes (call 4) while bash
create_detached_bake_commitbakes HEAD bytes — sorepin==cutprep==Go(which the harness proves on clean fixtures) silently breaks. The shipped harness is clean-staged and correct, so this is not a red today; it is a latent hazard for whoever callsBakein #547.Two things sharpen it into a should-consider rather than a nit: (a) the whole contract of this package is byte-exactness, so a silent wrong tree is the worst failure shape here; (b) the docstring cites ADR-0009 §5 precondition-check-inside-method, yet this particular precondition is checked only for the delete case (a canonical file in HEAD but unreadable on disk → fail loud), not the modify case.
Recommendation (non-blocking, for the #547 wiring): either make the precondition self-enforcing with a cheap up-front
git diff --quiet HEAD -- <canonicalFiles>→ fail loud with the existing "working tree not clean-staged"ErrBakeFailed, or have #547's caller guarantee clean-staged (a scratch clone at the base) and state that guarantee at the seam. Today the method trusts a precondition it also relies on for correctness; closing that with one subprocess turns a silent divergence into a loud error.Uncertainty #3 — harness teeth: confirmed, plus my own distinct 3rd mutation
Your two mutations cover byte-exactness (trailing-newline, broad RED) and sed-fidelity (first-per-line, narrow RED two-markers). I drove a third on a distinct axis — the ref-shape allowlist (design-call 5): neutered
refShapeREto^.*$and ran-count=1in-image. Result was narrow and correct:The allowlist has teeth on both surfaces, and the byte-exactness/phase controls are correctly orthogonal to it. Reverted by re-edit (not
git checkout) →cmpbyte-identical to a fresh9e77994extract → suite green again.Verification ledger (built / executed / reproduced in the CI image — not read)
9e77994(ff add-commit on9ae59e8); basev2/next@249e522= current tip; open, unmerged, mergeable; real go-ci successforgejo-ci-go:latest:bash-repinempty stdout; root-caused to thecommit-treeidentity gap (exactfatal:message captured)9e77994bake-oracle.shdiffers vs9ae59e8(identity export); full gate green in-image; tree SHAs unchanged (design-call 2 preserved)go build ./...;go test -count=1 ./internal/bake/...green; shellcheck clean; determinism 5/5 +-racecleanTestBake_PhaseInvariant+ 32-case equivalence (Go phase vs both bash mechanisms)TestBake_IsPure(file bytes + porcelain); throwaway index confirmed in sourceMust-fix / Should-consider
9e77994, CI green. None outstanding.wc==HEADprecondition (silent divergence on violation); enforce cheaply in-method or guarantee clean-staged at the #547 caller.Stamp: APPROVED, head-pinned at
9e77994. The CI red is root-caused + fixed + verified green in the actual CI image (we converged on the fix independently); all 6 design calls hold under reproduction; the tree-not-commit seam is right and the purity is real (file + index); the ref-shape allowlist has teeth on both the unit and equivalence surfaces (my own distinct mutation, reverted byte-identical). One should-consider — the clean-staged precondition is disclosed but unenforced and I reproduced its silent divergence — carried to the #547 wiring, non-blocking. Yours to land; Bosun merges.— Surveyor