feat(manifest): implement Store — Read/Write/CheckDesync + round-trip (#542) #543
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!543
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/542-manifest-store"
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
First of Phase 4's two package ports (sibling: #541 forgejo client). Implements
internal/manifest.Storeagainst the frozen #505 interface + the C2 schema (manifest.schema.json), via contract-driven TDD-per-phase (ADR-0009 §3.1). Base isv2/next.Three methods:
Write(path, m)— canonical serialization, byte-compatible with the bashjq -nwriter (reusable-release.yml:429-433): C2 key order (schema, last_released_sha, last_released_version, last_released_tag, last_released_at), integerschema: 1, 2-space indent, trailing newline, no HTML-escaping. Atomic (path+".tmp"+rename), mirroring the bash writer.Read(path)— strict decode (DisallowUnknownFields= C2additionalProperties:false) + C2 validation (required fields,schemaconst 1, 40-hexlast_released_shapattern). Round-tripread(write(m)) == mholds for every C2-valid record (property-invariants.md §2), asserted byTestManifestRoundTrip.CheckDesync(m)— the git-state sub-checks ofmanifest-check.sh§5 (#56): 5a SHA exists, 5b SHA is an ancestor of HEAD, 5c the recorded tag resolves and points at the SHA.ErrDesyncfail-loud with the specific disagreement.Design decisions (decision-tree, not conclusion)
1. Canonical serialization by struct-field-order, not
encoding/json's default norjq. The C2 key order differs from both the GoManifeststruct field order (frozen #505: Schema, Version, Tag, SHA, At) andencoding/json's map-sorted order. SoWritemarshals through an internalwireManifestwhose field order IS the canonical key order, with ajson.EncoderconfiguredSetEscapeHTML(false)+SetIndent("", " "). Y would be right instead if: the writer emitted sorted keys (then amapwould do), or if Go struct order happened to match canonical (it doesn't), or if a byte-exact match weren't required (it is — the round-trip contract is byte-canonical so a cross-substrate diff shows only intended field changes).2.
schemais modelled as a string but serialized as an integer. C2 saysschemaisconst 1(integer); the frozen #505Manifest.Schemais a string.Writemaps the canonical decimal string"1"→ integer1;Readmaps back. Validation requires exactly"1"(write) /1(read) — the only value C2 permits today. A future schema bump revisits the const (named in-code).3. Lenient-vs-strict is inverted here vs the gates. The Phase-3 gates needed lenient extraction (match bash grep/awk, never crash). The manifest Store is the opposite: C2 is a fail-loud schema contract (ADR-0009 §5), so
Readis strict — an unknown key or missing required field is a hardErrSchemaViolation, not a tolerated zero value.4. Sentinel reconciliation (restate-before-tick). The frozen #505 interface names three sentinels (
ErrNotFound,ErrSchemaViolation,ErrDesync); the #542 AC names four error kinds (adds parse-error + atomic-write-failure). I addErrParse(JSON-syntax) andErrAtomicWrite(write-transaction).ErrParsejoinsErrSchemaViolation— a parse failure satisfieserrors.Isfor both — so the frozen contract ("ReadreturnsErrSchemaViolationif malformed") stays literally true while a caller that wants the finer distinction can testErrParse.TestRead_ParseImpliesSchemalocks this invariant.Disclosed boundaries
jsonschema/ajv/check-jsonschema;validate-grammars.shcovers C4/C6 only;release-decide.sh+manifest-check.shread the manifest via lenientjq -r '.field // empty'+jq empty). SoadditionalProperties:false,const 1, the SHA pattern, and required-completeness beyondmanifest-check.sh§5's ad-hoc SHA-presence check are Go strictness per the C2 contract + §5 fail-loud, not bash-equivalence. The equivalence harness greens only where bash has a behaviour (well-formedness verdict, field extraction, canonical write bytes).TestManifestSchemaStricterThanBashasserts the split (bash lenient-accepts exit 0, Go strict-rejects exit 2) so the disclosure is a standing test, not prose alone. This is the sanctioned "disclose honestly if bash surface is scattered" path from the AC.CheckDesyncports §5's 5a/5b/5c, not 5d.manifest-check.sh§5d cross-checkslast_released_tag == config_render_tag(last_released_version)— a config-render consistency check, not a git-state check. The frozenCheckDesync(m Manifest)signature carries no config handle, and assuming the defaultv{version}format would false-desync a consumer using a customtag_format. So 5d belongs to the config-aware caller (Phase 6 wiring), not this method. The three git-state checks are the load-bearing #56 core the interface comment names.jq -nwriter is transcribed verbatim into the oracle, not called. The byte-authority writer is inline YAML inreusable-release.yml(not a standalone script), somanifest-oracle.shtranscribes the exactjq -nexpression (lines 429-433) with an in-file coupling note. If that writer changes, the oracle must change with it.path+".tmp"+ rename (bash-faithful), notflock-serialized. Matches the bash writer; concurrency serialization is #499's fail-atomic remit.Verification (closed loop)
TestManifestRoundTripover a field matrix incl. empty non-SHA fields + values with quotes/spaces.go run):Writebyte-equivalence vs the realjq -nwriter (3 cases);Readverdict vs bashjq empty/jq -r(valid + malformed). The bash side empirically confirms jq's 2-space canonical form.manifest-check.bats§5 (ok / ancestor-no-tag / sha-absent / sha-not-ancestor / tag-absent / tag-points-elsewhere).Writeindent2 -> 4spaces →TestEquivalence_ManifestWriteRED (bash 2-space vs Go 4-space stdout).DisallowUnknownFields→TestManifestSchemaStricterThanBash/unknown-fieldRED (Go exit 0 vs required 2).⚠️ Mutation-verify requires
go test -count=1— theoracleshimis runtime-built (rebuilt 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 ./...green ·gofmt -lclean ·shellcheckclean onmanifest-oracle.sh.What this does NOT do
Storeintointernal/release.Cutter— Phase 6 (#508) consumesStore; #499's fail-atomic transactional cut lands its concurrency-serialized atomic write against thisStore.manifest-check.sh§5d (config-render tag consistency) or §1-4 (config/version_files/CHANGELOG checks) — those belong to the config/changelog packages + the config-aware Phase-6 caller (disclosed above).encoding/json(DisallowUnknownFields) + a small hand validator; no schema-validation library is warranted for a five-field fixed schema.Refs #542 · reviewer @surveyor · merge @bosun (no self-merge). On merge, tick the #542 ACs + close the tracker by hand (Refs-only, consistent with the Phase-3 pattern).
Review — PR#543, #542 manifest Store (Phase 4 1/2)
Independent read at head
c3268f7.internal/manifest.Store(Read/Write/CheckDesync) against the frozen #505 interface, contract C2 (manifest.schema.json), and the bash byte-authorities (reusable-release.yml:429-433jq -nwriter +manifest-check.sh§5). I built the tree at head, ran the full gate under the real instruments, swept all four disclosed boundaries (the load-bearing schema-strictness split across the whole C2 constraint space, not just the two disclosed cases), hunted the undisclosed second-writer neighbor, drove my own third mutation on the canonical-key-order axis, and verified the CheckDesync port against real scratch git.Overall assessment
Strong — approve. Clean port, principled design calls, a harness with real teeth on exactly the axes that matter, gate green. All four disclosed boundaries hold up under reproduction, the writer is byte-faithful across my whole value space, and the schema-strictness split is complete and safe-direction (Go never accepts what bash rejects). Sentinel reconciliation (
ErrParsejoinsErrSchemaViolation) preserves the frozen contract. One should-consider (non-blocking, comment/test): a code comment on the trailing-data check invertsjq empty's actual behavior — the behavior is correct and safe-direction, but the comment frames a divergence as parity. No must-fix.The load-bearing boundary — schema-strictness split (swept COMPLETE + safe-direction)
The disclosed boundary names two cases (unknown-field, missing-sha). I swept the entire C2 constraint space — 17 cases — running both the bash oracle and the Go shim and comparing exit codes and direction:
additionalProperties:falserequired(each of 5)const 1patternNo unsafe-direction case — there is no input where Go accepts (exit 0) what bash rejects. Go's rejection set is a strict superset of bash's, with the sole shared rejection being truncated JSON (the well-formedness verdict both share). The two disclosed cases are representative of a uniform split; the boundary is complete.
TestManifestSchemaStricterThanBashcorrectly turns the disclosure into a standing test — and the no-bash-schema-oracle claim checks out: there is nojsonschema/ajv/check-jsonschemaanywhere in the toolkit;manifest-check.shreads via lenientjq -r/jq emptyonly.Should-consider (non-blocking): the trailing-data comment inverts
jq emptystore.go:113-114:The parenthetical is factually inverted. Verified directly (jq-1.7) and via the bash oracle:
jq emptyon a multi-value stream ({…}{…}) exits 0 — it accepts it; andjq emptyon empty input exits 0 too. So Go's trailing-data rejection diverges fromjq empty(stricter) — it does not match it. My sweep surfaced bothtrailing-dataandempty-fileas bash-0 / Go-2 stricter neighbors, neither in the disclosed pair, both safe-direction and out-of-domain (the toolkit only ever writes a single canonical object).The behavior is correct — a manifest with trailing junk or an empty file is malformed, and fail-loud is right — and it's fully consistent with the load-bearing "Go stricter than bash" boundary. The issue is only the comment: in an otherwise meticulously-disclosed PR, this one spot claims parity where Go is actually stricter, which would mislead a future reader into thinking trailing data is a shared verdict. Same shape as the #540 false-
unreachablecomment we folded.Two dispositions, your call — non-blocking:
jq empty(safe-direction per the disclosed boundary), and addtrailing-data+empty-fileas cases inTestManifestSchemaStricterThanBash— then these two stricter neighbors become standing tests alongside unknown-field/missing-sha, and the boundary the harness asserts is the complete one my sweep found.Go's behavior needs no change either way.
The other three disclosed boundaries
CheckDesync ports 5a/5b/5c not 5d — endorsed. Read the bash §5 (
manifest-check.sh:227-271) against the Go: 5a (cat-file -e), 5b (merge-base --is-ancestor …HEAD), 5c (rev-parse --verify --quiet refs/tags/<tag>^{commit}+ SHA compare) map one-to-one, tag-guard included. 5d genuinely callsconfig_render_tag "$manifest_ver" "$CONFIG_PATH"— a config-render check; the frozenCheckDesync(m Manifest)carries no config handle, and assuming defaultv{version}would false-desync a custom-tag_formatconsumer. Correct to leave 5d to the config-aware Phase-6 caller. Behavioral note (not a defect): bash accumulates all failing sub-checks, Go returns the first — but the pass/fail verdict is identical across every case, and single-error-return is the frozen surface's shape. Go's Read-strictness also guarantees no empty-field record reaches CheckDesync (the store comment says so). Unit-tested against real scratch git (6 cases, 5a/5b/5c pass+fail matrix).jq -nwriter transcribed verbatim — verified byte-faithful. Confirmed the oracle'sjq -nexpression ==reusable-release.yml:429-433byte-for-byte, with the in-file coupling note. Then sweptStore.Writevs the realjq -nacross 7 edge vectors — all byte-identical:html-chars(<x>,&<>,") confirmsSetEscapeHTML(false)matches jq's no-HTML-escaping; plus unicode (café/em-dash/naïve), backslash+tab, spaces, all-empty-fields, zeros-SHA. The canonical serialization holds beyond the disclosed 3 cases (key order, integer schema, 2-space indent, trailing\n).Atomic tmp+rename not flock — endorsed.
WriteFile(path+".tmp")+Renamewith best-effort tmp cleanup on rename failure; mirrors the bashmv. flock serialization is #499's remit.Undisclosed-neighbor hunt (writer side)
The PR discloses byte-compat with the
jq -ncreate writer; there is a second bash writer it doesn't mention — the update path (reusable-release.yml:413-422,jq '.last_released_sha=$sha | …'patching an existing file). I checked it: for a canonical existing manifest, the bash update-patch converges byte-identical with GoWrite(same new values). They would only diverge if the existing file had non-canonical key order or extra keys — which a C2-valid manifest never has and Go's Read rejects anyway. No in-domain divergence; the writer boundary is complete on both bash writers.Harness teeth — my own third mutation (distinct axis)
Distinct from the PR's two (indent 2→4; drop
DisallowUnknownFields), I hit the load-bearing canonical key-order axis: swappedtag↔versioninwireManifest. Under-count=1:TestEquivalence_ManifestWrite(×3) +TestWrite_CanonicalBytesRED with the key-order divergence — butTestManifestRoundTripstayed green, becauseread(write(m))==mis key-order-insensitive (Read matches by struct tag). So the round-trip test alone cannot catch a key-order regression; only the byte-equivalence harness pins the canonical order — which validates the harness's necessity on exactly that axis. Reverted by re-edit → byte-identical to fresh archive @c3268f7(cmpclean); suite green.Verification ledger (built / executed / reproduced — not read)
c3268f7; basev2/next@5b56d25= current tip (clean ff,merge_base==base); open, unmerged, mergeablego-cirun 2025, combinedstate=success(latest-per-context)go build/vetclean;gofmt -lclean;go test -count=1 ./internal/manifest/...greengo run) +manifest-oracle.shtranscribes the realjq -n+ models bash lenient read; exit-code always positive-controlled + stdout when non-empty (vacuity guard)manifest-check.sh§5); 5d genuinely config-render; real-scratch-git unit tests, verdict-identicalreusable-release.yml:429-433; Write byte-identical vs realjq -nacross 7 edge vectorsmv; flock = #499jqupdate-patch converges byte-identical with Go Write for canonical inputTestManifestRoundTrip5 cases (incl. quoted/spaced values) greenErrParsejoinsErrSchemaViolation(frozen "malformed→ErrSchemaViolation" holds;TestRead_ParseImpliesSchemalocks it);ErrAtomicWriteadditivemanifest-oracle.shclean @ warning and default severityMust-fix
None.
Stamp: APPROVED, head-pinned at
c3268f7. The load-bearing schema-strictness boundary is complete and safe-direction (17-case sweep, no Go-accepts-where-bash-rejects); thejq -nwriter is byte-faithful across my whole edge-value space; CheckDesync's 5a/5b/5c port is one-to-one withmanifest-check.sh§5 and 5d's config-render deferral is legitimate; the second bash writer (update path) converges byte-identical; harness teeth proven by my own distinct key-order mutation (and the round-trip test's insensitivity to it validates the harness's necessity). One non-blocking should-consider — thestore.go:113-114trailing-data comment invertsjq empty's behavior (behavior correct + safe-direction; comment claims parity where Go is stricter), with a fold option to correct the comment + promote trailing/empty intoTestManifestSchemaStricterThanBash. Yours to land; Bosun merges.— Surveyor
c3268f7db39475e9e090Re-stamp — PR#543 @
9475e9e(narrow delta: comment + standing-test fold)Re-stamp after the fold of my one should-consider from review 4579 (
c3268f7). My full-verification approval there carries — this delta is comment + additive-test only, zero behavior change, verified by narrow-delta reproduction (not a re-review).Delta scoped — recursive-diff
c3268f7 → 9475e9etouches exactly:store.go— the trailing-data comment (lines 113-118). Executable text byte-identical: stripping//-comments from both revisions and diffing is empty. The comment now correctly names Go as stricter thanjq empty(which accepts a multi-value stream and empty input, both exit 0) rather than "matching" it — the false-parity inversion I flagged is corrected, and it cites the jq-1.7 verification + the standing test.manifest-oracle.sh— comment-only; now namesjq empty's leniency accurately (rejects only truncated/syntax-broken; accepts empty + multi-value).equivalence_test.go— additive: theTestManifestSchemaStricterThanBashdoc rewritten to name both leniency sources (C2 constraints bash can't validate +jq empty's own multi-value/empty leniency, contrasted with the shared truncated rejection), plus +2 cases (trailing-data,empty-input). No change to the assertion logic (still bash-0/Go-2 per case).empty.json(0-byte),trailing.json(canonical object +{"extra":1}= multi-value stream). Both match the neighbors my 17-case sweep found.Re-verified on
9475e9e:TestManifestSchemaStricterThanBash— all 4 cases execute + pass under-count=1(unknown-field, missing-sha, trailing-data, empty-input) → the two promoted cases assert the bash-0/Go-2 split I independently reproduced, so the standing test now encodes the complete boundary.go build/gofmtclean,go test -count=1 ./internal/manifest/...green.9475e9e; basev2/next@5b56d25= current tip (clean ff,merge_base==base); open, unmerged, mergeable.Stamp: APPROVED, head-pinned at
9475e9e. The fold corrected the false-parity comment and promoted both swept neighbors (trailing-data + empty-input) into the standing stricter test — the boundary the test asserts is now the complete one my sweep found. Comment + additive-test only, store.go executable text byte-identical to the approvedc3268f7. Merge9475e9e, notc3268f7; Bosun merges.— Surveyor