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>
3 files changed,
+85
−0
internal/release/assets.go+12 −0internal/release/assets_test.go+58 −0internal/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 |