carlos / platform Public

release: golden fixtures for target, tag and labeled manifests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 84e505217bfcac04100ceba7a12c3beb33eadf44 parent 05f5158
1 file changed, +201 −0
  • internal/release/golden_test.go +201 −0
diff --git a/internal/release/golden_test.go b/internal/release/golden_test.go
index 4b4a0df76..b9ef53820 100644
--- a/internal/release/golden_test.go
+++ b/internal/release/golden_test.go
@@ -213,3 +213,204 @@ func TestAManifestWithFutureTopLevelKeysStillParses(t *testing.T) {
t.Fatalf("Capabilities = %+v, want the daemon block decoded", m.Capabilities)
}
}
+
+// A manifest from BEFORE the release versioning spec (2026-08-08): no
+// "label" or "notes" keys at all, same as every manifest already sitting in
+// a production bucket today. It has to keep parsing, and Label/Notes have to
+// come back empty rather than erroring — that is the wire back-compat lock
+// for the new fields, the same guarantee TestYesterdaysManifestParsesWithNoCapabilities
+// gives Capabilities.
+//
+// Mutation-mindedness: make Label or Notes required, or reject a manifest
+// missing either key, and this test goes red — which would mean every
+// manifest shipped before today's change stopped parsing.
+func TestYesterdaysManifestParsesWithNoLabelOrNotes(t *testing.T) {
+ yesterday := `{
+ "schema": 1,
+ "app": "kass",
+ "version": "9f3c1ad",
+ "kind": "binary",
+ "commit": "9f3c1ad",
+ "artifacts": { "linux-arm64": { "file": "linux-arm64", "sha256": "` + strings.Repeat("ab", 32) + `", "size": 12 } }
+}`
+ var m Manifest
+ if err := json.Unmarshal([]byte(yesterday), &m); err != nil {
+ t.Fatalf("a manifest already in every production bucket stopped parsing: %v", err)
+ }
+ if m.Label != "" || m.Notes != "" {
+ t.Fatalf("Label=%q Notes=%q, want both empty — absent means none", m.Label, m.Notes)
+ }
+ if err := validateManifest(&m); err != nil {
+ t.Fatalf("validate: %v", err)
+ }
+}
+
+// goldenLabeledManifest is a manifest carrying the release-versioning spec's
+// two new fields: Label (a short one-line intent) and Notes (a changelog
+// fragment), both written at ship time. Captured from json.MarshalIndent
+// on schema 1 with every field the ship path populates set.
+//
+// Mutation-mindedness: reorder the Manifest struct's fields, drop
+// `,omitempty` from Label or Notes, or rename either json tag, and the
+// marshal half of this test fails; corrupt the decoder and the unmarshal
+// half does.
+//
+// A var, not a const: strings.Repeat is not a constant expression (same
+// reason TestYesterdaysManifestParsesWithNoCapabilities's "yesterday" is a
+// var too).
+var goldenLabeledManifest = `{
+ "schema": 1,
+ "app": "kass",
+ "version": "9f3c1ad",
+ "kind": "binary",
+ "commit": "9f3c1ad",
+ "built_by": "ci@carlosframework.com",
+ "built_at": "2026-08-08T09:15:00Z",
+ "label": "fixing modals",
+ "notes": "- focus trap fixed\n- escape closes on nested dialogs",
+ "artifacts": {
+ "linux-arm64": {
+ "file": "linux-arm64",
+ "sha256": "` + strings.Repeat("ab", 32) + `",
+ "size": 12
+ }
+ }
+}`
+
+func goldenLabeledManifestValue() *Manifest {
+ return &Manifest{
+ Schema: 1,
+ App: "kass",
+ Version: "9f3c1ad",
+ Kind: "binary",
+ Commit: "9f3c1ad",
+ BuiltBy: "ci@carlosframework.com",
+ BuiltAt: "2026-08-08T09:15:00Z",
+ Label: "fixing modals",
+ Notes: "- focus trap fixed\n- escape closes on nested dialogs",
+ Artifacts: map[string]Artifact{
+ "linux-arm64": {File: "linux-arm64", SHA256: strings.Repeat("ab", 32), Size: 12},
+ },
+ }
+}
+
+// TestGoldenLabeledManifestRoundTrips is the labeled-manifest half of the
+// wire-shape lock: the Go type marshals to exactly the golden bytes, and the
+// golden bytes decode to exactly the Go value — both directions, so a
+// regression in either the encoder or the decoder is caught here rather than
+// downstream in Ship or console rendering.
+func TestGoldenLabeledManifestRoundTrips(t *testing.T) {
+ want := goldenLabeledManifestValue()
+ raw, err := json.MarshalIndent(want, "", " ")
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if string(raw) != goldenLabeledManifest {
+ t.Fatalf("marshaled manifest =\n%s\nwant golden\n%s", raw, goldenLabeledManifest)
+ }
+ var got Manifest
+ if err := json.Unmarshal([]byte(goldenLabeledManifest), &got); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if !reflect.DeepEqual(&got, want) {
+ t.Fatalf("golden manifest decoded as\n%+v\nwant\n%+v", got, want)
+ }
+ if err := validateManifest(&got); err != nil {
+ t.Fatalf("validate: %v", err)
+ }
+}
+
+// goldenVersionTarget is the version-target.json bucket object (release
+// versioning spec, 2026-08-08; see target.go's TargetKey) — the per-app
+// "what we're building toward" that SetTarget writes and GetTarget reads.
+// Schema 1, every field set.
+//
+// Mutation-mindedness: reorder VersionTarget's fields, rename a json tag, or
+// drop `,omitempty` from SetBy, and the marshal half fails; break the
+// decoder's handling of set_at as RFC3339 and the unmarshal half does.
+const goldenVersionTarget = `{
+ "schema": 1,
+ "target": "0.5.0",
+ "set_by": "paul@example.com",
+ "set_at": "2026-08-08T09:15:00Z"
+}`
+
+func goldenVersionTargetValue() *VersionTarget {
+ return &VersionTarget{
+ Schema: 1,
+ Target: "0.5.0",
+ SetBy: "paul@example.com",
+ SetAt: time.Date(2026, 8, 8, 9, 15, 0, 0, time.UTC),
+ }
+}
+
+// TestGoldenVersionTargetRoundTrips locks the version-target.json wire shape
+// down in both directions, matching the pattern every other object in this
+// file follows.
+func TestGoldenVersionTargetRoundTrips(t *testing.T) {
+ want := goldenVersionTargetValue()
+ raw, err := json.MarshalIndent(want, "", " ")
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if string(raw) != goldenVersionTarget {
+ t.Fatalf("marshaled version target =\n%s\nwant golden\n%s", raw, goldenVersionTarget)
+ }
+ var got VersionTarget
+ if err := json.Unmarshal([]byte(goldenVersionTarget), &got); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if !reflect.DeepEqual(&got, want) {
+ t.Fatalf("golden version target decoded as\n%+v\nwant\n%+v", got, want)
+ }
+}
+
+// goldenTagWithChangelog is a Tag as written by EnsureTag at promote-to-stable
+// time: every field populated, including the compiled changelog spanning
+// more than one iteration. Tags are unsigned read-side sugar (tag.go's
+// package doc), so unlike a channel pointer this shape has no signature to
+// pin — just the JSON.
+//
+// Mutation-mindedness: reorder Tag's fields, rename a json tag, or drop
+// `,omitempty` from Commit/Actor/Changelog, and the marshal half fails.
+const goldenTagWithChangelog = `{
+ "schema": 1,
+ "version": "0.5.0",
+ "release": "0.5.0-3",
+ "commit": "9f3c1ad",
+ "tagged_at": "2026-08-08T09:15:00Z",
+ "actor": "paul@example.com",
+ "changelog": "## 0.5.0-1 — fixing modals\n\n- focus trap fixed\n\n## 0.5.0-3 — polish\n\n- rounded corners\n"
+}`
+
+func goldenTagWithChangelogValue() *Tag {
+ return &Tag{
+ Schema: 1,
+ Version: "0.5.0",
+ Release: "0.5.0-3",
+ Commit: "9f3c1ad",
+ TaggedAt: time.Date(2026, 8, 8, 9, 15, 0, 0, time.UTC),
+ Actor: "paul@example.com",
+ Changelog: "## 0.5.0-1 — fixing modals\n\n- focus trap fixed\n\n## 0.5.0-3 — polish\n\n- rounded corners\n",
+ }
+}
+
+// TestGoldenTagWithChangelogRoundTrips locks the tag wire shape down in both
+// directions.
+func TestGoldenTagWithChangelogRoundTrips(t *testing.T) {
+ want := goldenTagWithChangelogValue()
+ raw, err := json.MarshalIndent(want, "", " ")
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if string(raw) != goldenTagWithChangelog {
+ t.Fatalf("marshaled tag =\n%s\nwant golden\n%s", raw, goldenTagWithChangelog)
+ }
+ var got Tag
+ if err := json.Unmarshal([]byte(goldenTagWithChangelog), &got); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if !reflect.DeepEqual(&got, want) {
+ t.Fatalf("golden tag decoded as\n%+v\nwant\n%+v", got, want)
+ }
+}