carlos / platform Public

release: declare an optional assets block on the manifest (part 1a)

Manifest.Assets is additive to schema 1 and omitted when nil, so manifests
without it marshal byte-identically. No validation yet.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 60f91daa6efe6e22b4013622209aac8227c19df7 parent 92bf16c
3 files changed, +85 −0
  • internal/release/assets.go +12 −0
  • internal/release/assets_test.go +58 −0
  • internal/release/release.go +15 −0
diff --git a/internal/release/assets.go b/internal/release/assets.go
new file mode 100644
index 000000000..7aa7453b2
--- /dev/null
+++ b/internal/release/assets.go
@@ -0,0 +1,12 @@
+package release
+
+// Assets is a release's declaration of which URL path prefixes hold its
+// content-addressed static assets (hashed bundles, images, fonts) that an
+// edge may cache. ADDITIVE to schema 1: see Manifest.Assets.
+//
+// This type carries the declaration only. The grammar that decides which
+// paths are acceptable is a separate gate and is not defined here.
+type Assets struct {
+ // Paths are URL path prefixes such as "/assets/".
+ Paths []string `json:"paths"`
+}
diff --git a/internal/release/assets_test.go b/internal/release/assets_test.go
new file mode 100644
index 000000000..3e838194d
--- /dev/null
+++ b/internal/release/assets_test.go
@@ -0,0 +1,58 @@
+package release
+
+import (
+ "encoding/json"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+// A manifest with no assets block marshals byte-for-byte as it did before
+// the field existed: the golden labeled manifest carries no "assets" key, and
+// a nil Assets must not add one. This is what keeps an idempotent re-ship of
+// an old release working.
+func TestManifestWithoutAssetsIsByteIdentical(t *testing.T) {
+ m := goldenLabeledManifestValue()
+ if m.Assets != nil {
+ t.Fatalf("fixture has Assets = %+v, want nil", m.Assets)
+ }
+ raw, err := json.MarshalIndent(m, "", " ")
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if string(raw) != goldenLabeledManifest {
+ t.Fatalf("manifest without assets =\n%s\nwant golden\n%s", raw, goldenLabeledManifest)
+ }
+ if strings.Contains(string(raw), `"assets"`) {
+ t.Fatalf("nil Assets leaked an assets key:\n%s", raw)
+ }
+}
+
+func TestManifestAssetsRoundTrip(t *testing.T) {
+ m := goldenLabeledManifestValue()
+ m.Assets = &Assets{Paths: []string{"/assets/"}}
+
+ raw, err := json.Marshal(m)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if !strings.Contains(string(raw), `"assets":{"paths":["/assets/"]}`) {
+ t.Fatalf("marshaled manifest lacks the assets block:\n%s", raw)
+ }
+ var got Manifest
+ if err := json.Unmarshal(raw, &got); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if !reflect.DeepEqual(&got, m) {
+ t.Fatalf("round trip decoded as\n%+v\nwant\n%+v", got, m)
+ }
+
+ // An old manifest, written before the field existed, parses with nil.
+ var old Manifest
+ if err := json.Unmarshal([]byte(goldenLabeledManifest), &old); err != nil {
+ t.Fatalf("unmarshal old: %v", err)
+ }
+ if old.Assets != nil {
+ t.Fatalf("old manifest parsed with Assets = %+v, want nil", old.Assets)
+ }
+}
diff --git a/internal/release/release.go b/internal/release/release.go
index d77987961..c579fa95b 100644
--- a/internal/release/release.go
+++ b/internal/release/release.go
@@ -104,6 +104,21 @@ type Manifest struct {
// rides the same trust chain as the bytes (signed pointer -> immutable
// manifest -> sha256). Spec §3a.
Capabilities *caps.Set `json:"capabilities,omitempty"`
+
+ // Assets declares which URL path prefixes of this release hold
+ // cacheable static assets. ADDITIVE to schema 1, exactly as
+ // Capabilities was: a manifest written before this field existed has no
+ // "assets" key and parses with a nil pointer, which means nothing is
+ // declared. A POINTER with omitempty, so an asset-free manifest also
+ // MARSHALS byte-identically to before. An OLD binary ignores the key,
+ // because encoding/json drops unknown top-level keys and the manifest
+ // read path does not use DisallowUnknownFields.
+ //
+ // It lives in the release manifest for the same reason Capabilities
+ // does: it changes with the code, so a rollback rolls the declaration
+ // back with the binary, and it rides the same trust chain as the bytes
+ // (signed pointer -> immutable manifest -> sha256).
+ Assets *Assets `json:"assets,omitempty"`
}
// Artifact is one file of a release: its bucket file name and the sha256