release: manifests carry an optional label and notes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+72
−0
internal/release/release.go+21 −0internal/release/release_test.go+51 −0
diff --git a/internal/release/release.go b/internal/release/release.go| index c9f689deb..b75385103 100644 |
| --- a/internal/release/release.go |
| +++ b/internal/release/release.go |
| @@ -51,6 +51,14 @@ type Manifest struct { |
| Commit string `json:"commit,omitempty"` |
| BuiltBy string `json:"built_by,omitempty"` |
| BuiltAt string `json:"built_at,omitempty"` |
| + // Label and Notes are the human half of a release (release versioning |
| + // spec, 2026-08-08): a short one-line intent ("fixing modals") and a |
| + // changelog fragment, both written at ship time. ADDITIVE to schema 1 |
| + // exactly as Capabilities was — absent from every manifest written |
| + // before the fields existed, omitted from marshal when empty, ignored |
| + // by old binaries. Nothing on the adopt path reads either. |
| + Label string `json:"label,omitempty"` |
| + Notes string `json:"notes,omitempty"` |
| // Artifacts is keyed "linux-arm64" etc for binaries, or "site" for a |
| // static tree tar. |
| Artifacts map[string]Artifact `json:"artifacts"` |
| @@ -149,6 +157,13 @@ const ( |
| auditMax = 20 |
| ) |
| +// MaxLabelLen / MaxNotesLen bound the human half of a manifest. The label |
| +// is a table cell; the notes are a changelog fragment, not a novel. |
| +const ( |
| + MaxLabelLen = 120 |
| + MaxNotesLen = 16 << 10 |
| +) |
| + |
| // Bucket keys, account-scoped: app names are unique WITHIN an account, so the |
| // account sqid is part of every key and the key path itself carries the |
| // app→account linkage (there is no separate linkage manifest). Channel names |
| @@ -265,6 +280,12 @@ func validateManifest(m *Manifest) error { |
| return fmt.Errorf("artifact %s: negative size", k) |
| } |
| } |
| + if len(m.Label) > MaxLabelLen || strings.ContainsAny(m.Label, "\r\n") { |
| + return fmt.Errorf("label: one line, at most %d characters", MaxLabelLen) |
| + } |
| + if len(m.Notes) > MaxNotesLen { |
| + return fmt.Errorf("notes: at most %d bytes", MaxNotesLen) |
| + } |
| // LAST, and deliberately the same call on both sides of the trust chain: |
| // Ship runs validateManifest before writing a byte (a human is present to |
| // read the refusal), and GetManifest runs it on every read (so a box |
diff --git a/internal/release/release_test.go b/internal/release/release_test.go| index 727a9fcd6..fc9b380f2 100644 |
| --- a/internal/release/release_test.go |
| +++ b/internal/release/release_test.go |
| @@ -1,6 +1,7 @@ |
| package release |
| import ( |
| + "bytes" |
| "context" |
| "crypto/ed25519" |
| "encoding/base64" |
| @@ -776,3 +777,53 @@ func TestFastTrackIsAdditiveOnTheWire(t *testing.T) { |
| t.Fatalf("old shape read version %q", old.Version) |
| } |
| } |
| + |
| +func TestManifestLabelNotesAdditive(t *testing.T) { |
| + // A manifest written before label/notes existed must still parse (the |
| + // production buckets are full of them) … |
| + old := []byte(`{"schema":1,"app":"km","version":"de2766f8","kind":"binary","artifacts":{"linux-arm64":{"file":"linux-arm64","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":1}}}`) |
| + var m Manifest |
| + if err := json.Unmarshal(old, &m); err != nil { |
| + t.Fatalf("old manifest no longer parses: %v", err) |
| + } |
| + if m.Label != "" || m.Notes != "" { |
| + t.Fatalf("absent fields decoded non-empty: %q %q", m.Label, m.Notes) |
| + } |
| + // … and a label-free manifest must MARSHAL byte-identically to before, |
| + // or idempotent re-ship of an old release breaks on its own metadata. |
| + out, err := json.Marshal(m) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if bytes.Contains(out, []byte("label")) || bytes.Contains(out, []byte("notes")) { |
| + t.Fatalf("empty label/notes leaked into marshal: %s", out) |
| + } |
| +} |
| + |
| +func TestValidateManifestLabelNotesLimits(t *testing.T) { |
| + base := func() *Manifest { |
| + return &Manifest{Schema: 1, App: "km", Version: "0.5.0-1", Kind: "binary", |
| + Artifacts: map[string]Artifact{"linux-arm64": {File: "linux-arm64", SHA256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Size: 1}}} |
| + } |
| + m := base() |
| + m.Label = "fixing modals" |
| + m.Notes = "- rounded the corners\n- fixed the focus trap" |
| + if err := validateManifest(m); err != nil { |
| + t.Fatalf("valid label/notes refused: %v", err) |
| + } |
| + m = base() |
| + m.Label = strings.Repeat("x", MaxLabelLen+1) |
| + if err := validateManifest(m); err == nil { |
| + t.Fatal("overlong label accepted") |
| + } |
| + m = base() |
| + m.Label = "two\nlines" |
| + if err := validateManifest(m); err == nil { |
| + t.Fatal("multi-line label accepted") |
| + } |
| + m = base() |
| + m.Notes = strings.Repeat("x", MaxNotesLen+1) |
| + if err := validateManifest(m); err == nil { |
| + t.Fatal("overlong notes accepted") |
| + } |
| +} |