ship: carlos ship --assets and the console's ship door (edge asset cache plan 1, task 2)
--assets is repeatable and checked before the mode split (EAC-8 on a static release, then release.ValidAssetPaths), so neither door uploads a bad list. Direct mode writes Manifest.Assets; API mode carries it in the ship meta as assets.paths and refuses an answer without the echo (EAC-22). The console re-checks the grammar before reading a part, stores the block, refuses a re-ship with other folders (409, EAC-21) and echoes the stored block. The door's sentences live in internal/console/apiship_assets.go, not apicli.go. Copy gate: EAC-1, EAC-8@cli.go, EAC-8@internal/console/apiship_assets.go, EAC-21, EAC-22.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+538
−4
apimode.go+40 −3approved_asset_cache_copy_test.go+2 −0cli.go+54 −1internal/console/apicli.go+12 −0internal/console/apiship_assets.go+76 −0internal/console/apiship_assets_test.go+167 −0ship_assets_test.go+187 −0
diff --git a/apimode.go b/apimode.go| index 42621bc70..cd94dee40 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -28,6 +28,7 @@ import ( |
| "net/url" |
| "os" |
| "path/filepath" |
| + "slices" |
| "sort" |
| "strings" |
| "time" |
| @@ -83,6 +84,9 @@ type apiSession struct { |
| // set from --swap by runShip and runDeploy. A field for deferSaves' |
| // reason: ship has many callers and only those two take the flag. |
| shipSwap string |
| + // shipAssets is the release's asset folders for ship (--assets), set by |
| + // runShip, carried exactly as shipSwap is. nil sends no assets key. |
| + shipAssets []string |
| } |
| // resolveMode decides which mode the CLI is in. (nil, nil) means direct mode |
| @@ -4473,7 +4477,8 @@ func (s *apiSession) ship(ctx context.Context, a apiAcct, app, version, kind, la |
| Capabilities json.RawMessage `json:"capabilities,omitempty"` |
| ProgramChange bool `json:"program_change,omitempty"` |
| Swap string `json:"swap,omitempty"` |
| - }{version, kind, label, notes, message, capsRaw, programChange, s.shipSwap}) |
| + Assets *release.Assets `json:"assets,omitempty"` |
| + }{version, kind, label, notes, message, capsRaw, programChange, s.shipSwap, shipAssetsBlock(s.shipAssets)}) |
| if err != nil { |
| return err |
| } |
| @@ -4510,12 +4515,16 @@ func (s *apiSession) ship(ctx context.Context, a apiAcct, app, version, kind, la |
| // call sent: a minted ship (empty version in) has no other way to |
| // learn what the console actually shipped it as. |
| var out struct { |
| - Version string `json:"version"` |
| - BuildProbe string `json:"build_probe"` |
| + Version string `json:"version"` |
| + BuildProbe string `json:"build_probe"` |
| + Assets *release.Assets `json:"assets"` |
| } |
| if err := json.Unmarshal(body, &out); err != nil { |
| return apiShipResult{}, fmt.Errorf("ship: malformed response: %w", err) |
| } |
| + if err := checkAssetsEcho(s.shipAssets, out.Assets); err != nil { |
| + return apiShipResult{}, err |
| + } |
| return apiShipResult{NoOp: status == http.StatusOK, Version: out.Version, BuildProbe: out.BuildProbe}, nil |
| default: |
| return apiShipResult{}, apiErrorFromBody(status, body) |
| @@ -4592,6 +4601,34 @@ func checkSwapEcho(sent string, body []byte) error { |
| return nil |
| } |
| +// checkAssetsEcho is checkSwapEcho's twin for the asset folders (copy row |
| +// EAC-22): a console from before the field accepts the ship and stores a |
| +// release that declares none, so nothing would be kept near visitors. Checked |
| +// only when folders were sent; dropping an empty list loses nothing. The |
| +// echo is the stored block, so the folders are compared as a set: a re-ship |
| +// that named them in another order is answered with the first ship's order. |
| +func checkAssetsEcho(sent []string, echoed *release.Assets) error { |
| + if len(sent) == 0 { |
| + return nil |
| + } |
| + if echoed == nil || !sameAssetPaths(sent, echoed.Paths) { |
| + return errors.New("this console is older than --assets and stored the release without your asset folders, so nothing would be kept near your visitors. nothing was promoted. update the console, then ship again under a new version.") |
| + } |
| + return nil |
| +} |
| + |
| +// sameAssetPaths reports whether two folder lists name the same folders, |
| +// in any order. The grammar refuses duplicates, so a sorted compare is exact. |
| +func sameAssetPaths(a, b []string) bool { |
| + if len(a) != len(b) { |
| + return false |
| + } |
| + x, y := slices.Clone(a), slices.Clone(b) |
| + slices.Sort(x) |
| + slices.Sort(y) |
| + return slices.Equal(x, y) |
| +} |
| + |
| // writeFilePart streams one local file into a multipart part named name, so |
| // a 30MiB artifact is read a chunk at a time rather than buffered whole |
| // before it starts crossing the wire. |
diff --git a/approved_asset_cache_copy_test.go b/approved_asset_cache_copy_test.go| index 41057f099..43273793a 100644 |
| --- a/approved_asset_cache_copy_test.go |
| +++ b/approved_asset_cache_copy_test.go |
| @@ -45,6 +45,8 @@ var assetCacheCopyIDs = func() []string { |
| var assetCacheCopyBuilt = []string{ |
| "EAC-3", "EAC-4", "EAC-5", "EAC-6", "EAC-7", |
| "EAC-8@internal/release/release.go", |
| + "EAC-1", "EAC-8@cli.go", "EAC-8@internal/console/apiship_assets.go", |
| + "EAC-21", "EAC-22", |
| } |
| // assetCacheBuiltRows turns built entries into the rows to check: one per |
diff --git a/cli.go b/cli.go| index 135fda274..c575d40bb 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -2146,6 +2146,51 @@ const ( |
| shipHoldNote = " this release swaps in hold mode: each instance stops before the new build starts" |
| ) |
| +// The --assets flag's help (copy row EAC-1, shared by ship and deploy) and |
| +// its refusal on a static release (EAC-8), both approved text verbatim |
| +// (docs/superpowers/plans/2026-10-24-edge-asset-cache-copy.md). |
| +const ( |
| + shipAssetsFlagHelp = "a folder your build writes files to, for example /assets/. repeat the flag for up to 8 folders. servers near your visitors may keep copies of the files in them" |
| + shipAssetsStatic = "a static site is already served from every region, so it has no asset folders to declare. leave --assets off." |
| +) |
| + |
| +// assetPathsFlag is --assets: repeatable, kept in the order given. Set does |
| +// not validate; checkShipAssets runs the grammar once over the whole list, so |
| +// a nested pair is refused as a pair. nil when the flag was never given. |
| +type assetPathsFlag []string |
| + |
| +func (f *assetPathsFlag) String() string { return strings.Join(*f, ",") } |
| + |
| +func (f *assetPathsFlag) Set(v string) error { |
| + *f = append(*f, v) |
| + return nil |
| +} |
| + |
| +// checkShipAssets is --assets' client-side gate on ship (edge asset cache |
| +// spec): any folder on a static release is refused, and the folders meet |
| +// release.ValidAssetPaths, the grammar the console's ship door and the |
| +// manifest gate run again. Checked before the mode split, so both doors |
| +// refuse before a byte is uploaded. |
| +func checkShipAssets(kind string, paths []string) error { |
| + if paths == nil { |
| + return nil |
| + } |
| + if kind == "static" { |
| + return errors.New(shipAssetsStatic) |
| + } |
| + return release.ValidAssetPaths(paths) |
| +} |
| + |
| +// shipAssetsBlock is the manifest's assets block for the folders given: nil |
| +// when --assets was not given, so the manifest's bytes are what they were |
| +// before the flag existed. |
| +func shipAssetsBlock(paths []string) *release.Assets { |
| + if paths == nil { |
| + return nil |
| + } |
| + return &release.Assets{Paths: paths} |
| +} |
| + |
| // validSwapFlag is --swap's client-side gate on ship and deploy (spec §4.2): |
| // a release may demand hold, or nothing. |
| func validSwapFlag(s string) error { |
| @@ -2169,6 +2214,8 @@ func runShip(args []string) { |
| notes := fs.String("notes", "", "changelog fragment for this release") |
| notesFile := fs.String("notes-file", "", "file holding the changelog fragment (mutually exclusive with --notes)") |
| swap := fs.String("swap", "", shipSwapFlagHelp) |
| + var assets assetPathsFlag |
| + fs.Var(&assets, "assets", shipAssetsFlagHelp) |
| programChange := fs.Bool("program-change", false, "accept that this release's Go main package differs from the app's previous release (recorded in the manifest)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| @@ -2177,7 +2224,7 @@ func runShip(args []string) { |
| // the target (release versioning spec, 2026-08-08). Everything else about |
| // this check is unchanged — an app and at least one file are still the |
| // minimum a ship can mean. |
| - usage := "usage: carlos ship --app <a> [--version <sha>] [--label <one line>] [--notes <text> | --notes-file <file>] [--kind binary|static] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>] [--capabilities <file.json>] [--program-change] [--swap hold] <files-or-site-dir>" |
| + usage := "usage: carlos ship --app <a> [--version <sha>] [--label <one line>] [--notes <text> | --notes-file <file>] [--kind binary|static] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>] [--capabilities <file.json>] [--program-change] [--swap hold] [--assets <folder>]... <files-or-site-dir>" |
| if fs.NArg() == 0 { |
| log.Fatal(usage) |
| } |
| @@ -2207,6 +2254,9 @@ func runShip(args []string) { |
| if err := validSwapFlag(*swap); err != nil { |
| log.Fatal(err) |
| } |
| + if err := checkShipAssets(*kind, assets); err != nil { |
| + log.Fatal(err) |
| + } |
| // Read and decode the declaration BEFORE the mode split, so both doors |
| // refuse the same bytes with the same message at the operator's terminal |
| @@ -2259,6 +2309,7 @@ func runShip(args []string) { |
| } |
| } |
| sess.shipSwap = *swap |
| + sess.shipAssets = assets |
| if _, err := runShipAPI(sess, acct, fs, app2, *ver, *kind, *target, *label, noteText, capsSet, false, *programChange, message); err != nil { |
| log.Fatal(err) |
| } |
| @@ -2346,6 +2397,8 @@ func runShip(args []string) { |
| Capabilities: capsSet, |
| // "" when --swap was not given: omitempty keeps the bytes as before. |
| Swap: *swap, |
| + // nil when --assets was not given: omitempty keeps the bytes as before. |
| + Assets: shipAssetsBlock(assets), |
| } |
| // The wrong-program rule, shared with the console's API door: refuse a |
| // release whose Go main package differs from the app's previous release |
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index 69e5f77f4..80d29eb82 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -1923,6 +1923,9 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) { |
| // Swap is the release's own demand (zero-downtime spec §4.2): "hold" |
| // or absent. ADDITIVE: absent from every request without --swap. |
| Swap string `json:"swap,omitempty"` |
| + // Assets is the release's asset folders (`carlos ship --assets`, |
| + // edge asset cache spec). ADDITIVE: absent without the flag. |
| + Assets *release.Assets `json:"assets,omitempty"` |
| } |
| // The budget is release.MaxNotesLen — the biggest single field the meta |
| // part now carries — plus 8KiB of headroom for everything else in it: |
| @@ -2012,6 +2015,9 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) { |
| if refuseBadSwap(w, meta.Swap) { |
| return |
| } |
| + if meta.Assets != nil && refuseBadAssets(w, meta.Assets.Paths, meta.Kind) { |
| + return |
| + } |
| maxParts := apiShipMaxPartsBinary |
| if meta.Kind == "static" { |
| maxParts = apiShipMaxPartsStatic |
| @@ -2175,6 +2181,7 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) { |
| Label: meta.Label, |
| Notes: meta.Notes, |
| Swap: meta.Swap, |
| + Assets: meta.Assets, // nil when the meta part carried no key |
| } |
| // Longer than the other CLI doors' 15-20s: this one moves real bytes — |
| // up to apiShipMaxBytes of them — into the bucket, not a handful of |
| @@ -2221,6 +2228,9 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) { |
| if refuseSwapConflict(w, existing.Swap, meta.Swap, ver) { |
| return |
| } |
| + if refuseAssetsConflict(w, existing.Assets, meta.Assets, ver) { |
| + return |
| + } |
| // Identical content already shipped: answer success without |
| // writing anything — calling release.Ship again would only |
| // rediscover this the slow way, and would in fact trip its OWN |
| @@ -2237,6 +2247,7 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) { |
| // did this version end up declaring". |
| addCapabilitiesEcho(body, existing.Capabilities) |
| addSwapEcho(body, existing.Swap) |
| + addAssetsEcho(body, existing.Assets) |
| addBuildProbe(body, probe) |
| writeJSON(w, http.StatusOK, body) |
| return |
| @@ -2292,6 +2303,7 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) { |
| body := map[string]any{"result": "shipped", "version": ver, "artifacts": out} |
| addCapabilitiesEcho(body, m.Capabilities) |
| addSwapEcho(body, m.Swap) |
| + addAssetsEcho(body, m.Assets) |
| addBuildProbe(body, probe) |
| writeJSON(w, http.StatusCreated, body) |
| } |
diff --git a/internal/console/apiship_assets.go b/internal/console/apiship_assets.go| new file mode 100644 |
| index 000000000..22da9b1ec |
| --- /dev/null |
| +++ b/internal/console/apiship_assets.go |
| @@ -0,0 +1,76 @@ |
| +package console |
| + |
| +import ( |
| + "fmt" |
| + "net/http" |
| + "slices" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The ship door's asset-folder sentences live here, not in apicli.go: the |
| +// moves copy gate pins the jsonError text of that file. Both are approved |
| +// copy rows (docs/superpowers/plans/2026-10-24-edge-asset-cache-copy.md): |
| +// EAC-8 for a static release, EAC-21 for a re-ship with other folders. The |
| +// grammar's own refusals (EAC-3 to EAC-7) are release.ValidAssetPaths'. |
| +const ( |
| + apiShipAssetsStatic = "a static site is already served from every region, so it has no asset folders to declare. leave --assets off." |
| + apiShipAssetsConflict = "version %s was already shipped with different asset folders. ship a new version to change them." |
| +) |
| + |
| +// refuseBadAssets answers 400 and reports true when the meta part's assets |
| +// block cannot be stored. The caller calls it only when the meta part carries |
| +// an assets block, so on a static release any block is refused, even an empty |
| +// one (validateManifest's rule). Otherwise the folders meet the grammar here, |
| +// before any artifact part is read: the door does not trust the CLI to have |
| +// checked them. |
| +func refuseBadAssets(w http.ResponseWriter, paths []string, kind string) bool { |
| + if kind == "static" { |
| + jsonError(w, http.StatusBadRequest, apiShipAssetsStatic) |
| + return true |
| + } |
| + if err := release.ValidAssetPaths(paths); err != nil { |
| + jsonError(w, http.StatusBadRequest, err.Error()) |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// shipAssetsMatch reports whether a re-ship asks for the asset folders the |
| +// stored version declares. Absent and an empty list both declare nothing, and |
| +// the order of the folders does not change what they declare (the grammar |
| +// refuses duplicates, so a sorted compare is exact). |
| +func shipAssetsMatch(stored, asked *release.Assets) bool { |
| + var s, a []string |
| + if stored != nil { |
| + s = slices.Clone(stored.Paths) |
| + } |
| + if asked != nil { |
| + a = slices.Clone(asked.Paths) |
| + } |
| + slices.Sort(s) |
| + slices.Sort(a) |
| + return slices.Equal(s, a) |
| +} |
| + |
| +// refuseAssetsConflict answers 409 and reports true when a re-ship of an |
| +// existing version adds, drops or changes its asset folders. The folders |
| +// decide what an edge may keep, so such a re-ship is a different release: |
| +// the capability block's rule. |
| +func refuseAssetsConflict(w http.ResponseWriter, stored, asked *release.Assets, ver string) bool { |
| + if !shipAssetsMatch(stored, asked) { |
| + jsonError(w, http.StatusConflict, fmt.Sprintf(apiShipAssetsConflict, ver)) |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// addAssetsEcho adds the release's stored asset folders to a ship response, |
| +// closing the gap addSwapEcho closes: a console from before the field ignores |
| +// it and stores a release that declares none, and a client that sent folders |
| +// refuses an answer without their echo (checkAssetsEcho, apimode.go). |
| +func addAssetsEcho(resp map[string]any, a *release.Assets) { |
| + if a != nil { |
| + resp["assets"] = a |
| + } |
| +} |
diff --git a/internal/console/apiship_assets_test.go b/internal/console/apiship_assets_test.go| new file mode 100644 |
| index 000000000..1ff007041 |
| --- /dev/null |
| +++ b/internal/console/apiship_assets_test.go |
| @@ -0,0 +1,167 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The approved copy rows the ship door speaks (docs/superpowers/plans/ |
| +// 2026-10-24-edge-asset-cache-copy.md). |
| +const ( |
| + eac4Text = "asset path / would cache every page on the site. name the folder your build writes to, for example /assets/." |
| + eac8Text = "a static site is already served from every region, so it has no asset folders to declare. leave --assets off." |
| + eac21Text = "version v1 was already shipped with different asset folders. ship a new version to change them." |
| +) |
| + |
| +// metaPartAssets is metaPart with an assets block, the way `carlos ship |
| +// --assets` sends it. |
| +func metaPartAssets(t *testing.T, version, kind string, paths ...string) shipPart { |
| + t.Helper() |
| + raw, err := json.Marshal(map[string]any{"version": version, "kind": kind, "assets": map[string]any{"paths": paths}}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return shipPart{name: "meta", body: raw} |
| +} |
| + |
| +// shipAssetsFixture is the console fixture with an app named shop the |
| +// fixture's owner may ship to, and the shop releases URL. |
| +func shipAssetsFixture(t *testing.T, code string) (*consoleFixture, string, string) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + liftShipBudget(fx) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "shop", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", code) |
| + return fx, token, fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/shop/releases" |
| +} |
| + |
| +func assetPathsOf(v any) []string { |
| + m, _ := v.(map[string]any) |
| + raw, _ := m["paths"].([]any) |
| + var out []string |
| + for _, p := range raw { |
| + s, _ := p.(string) |
| + out = append(out, s) |
| + } |
| + return out |
| +} |
| + |
| +func samePathList(a, b []string) bool { |
| + if len(a) != len(b) { |
| + return false |
| + } |
| + for i := range a { |
| + if a[i] != b[i] { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +func TestAPIShipCarriesAssetsIntoTheManifest(t *testing.T) { |
| + fx, token, u := shipAssetsFixture(t, "code-ship-assets") |
| + art := shipPart{name: "linux-arm64", body: randomBytes(t, 64)} |
| + code, out := postShip(t, u, token, []shipPart{metaPartAssets(t, "v1", "binary", "/assets/", "/img/"), art}) |
| + if code != http.StatusCreated { |
| + t.Fatalf("ship = %d %v, want 201", code, out) |
| + } |
| + m, err := release.GetManifest(context.Background(), fx.rel, fx.sqid, "shop", "v1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if m.Assets == nil || !samePathList(m.Assets.Paths, []string{"/assets/", "/img/"}) { |
| + t.Fatalf("stored assets = %+v, want [/assets/ /img/]", m.Assets) |
| + } |
| +} |
| + |
| +// TestAPIShipRefusesBadAssets: a CLI that skipped its own check (this body is |
| +// hand-built) meets the same grammar at the door, before any part is read or |
| +// anything stored. |
| +func TestAPIShipRefusesBadAssets(t *testing.T) { |
| + fx, token, u := shipAssetsFixture(t, "code-ship-assets-bad") |
| + code, out := postShip(t, u, token, []shipPart{metaPartAssets(t, "v1", "binary", "/"), {name: "linux-arm64", body: randomBytes(t, 8)}}) |
| + if code != http.StatusBadRequest || out["error"] != eac4Text { |
| + t.Fatalf("ship with / = %d %v, want 400 %q", code, out, eac4Text) |
| + } |
| + if _, err := release.GetManifest(context.Background(), fx.rel, fx.sqid, "shop", "v1"); err == nil { |
| + t.Fatal("a refused ship stored a manifest") |
| + } |
| +} |
| + |
| +func TestAPIShipRefusesAssetsOnStatic(t *testing.T) { |
| + fx, token, u := shipAssetsFixture(t, "code-ship-assets-static") |
| + for _, paths := range [][]string{{"/assets/"}, {}} { |
| + code, out := postShip(t, u, token, []shipPart{metaPartAssets(t, "v1", "static", paths...), {name: "site.tar.gz", body: randomBytes(t, 8)}}) |
| + if code != http.StatusBadRequest || out["error"] != eac8Text { |
| + t.Fatalf("static ship with assets %q = %d %v, want 400 %q", paths, code, out, eac8Text) |
| + } |
| + } |
| + if _, err := release.GetManifest(context.Background(), fx.rel, fx.sqid, "shop", "v1"); err == nil { |
| + t.Fatal("a refused ship stored a manifest") |
| + } |
| +} |
| + |
| +// TestAPIShipRefusesReassetingAReship: a re-ship of a stored version with |
| +// other folders, or with none, or adding some to a version stored without |
| +// them, is a different release and meets 409, the capability block's rule. |
| +// The same folders in another order are the same declaration. |
| +func TestAPIShipRefusesReassetingAReship(t *testing.T) { |
| + fx, token, u := shipAssetsFixture(t, "code-ship-assets-reship") |
| + art := shipPart{name: "linux-arm64", body: randomBytes(t, 64)} |
| + if code, out := postShip(t, u, token, []shipPart{metaPartAssets(t, "v1", "binary", "/assets/", "/img/"), art}); code != http.StatusCreated { |
| + t.Fatalf("ship = %d %v", code, out) |
| + } |
| + if code, out := postShip(t, u, token, []shipPart{metaPartAssets(t, "v1", "binary", "/img/", "/assets/"), art}); code != http.StatusOK { |
| + t.Fatalf("re-ship, folders reordered = %d %v, want 200", code, out) |
| + } |
| + for name, meta := range map[string]shipPart{ |
| + "other folders": metaPartAssets(t, "v1", "binary", "/assets/"), |
| + "no folders": metaPart(t, "v1", "binary"), |
| + } { |
| + if code, out := postShip(t, u, token, []shipPart{meta, art}); code != http.StatusConflict || out["error"] != eac21Text { |
| + t.Fatalf("re-ship with %s = %d %v, want 409 %q", name, code, out, eac21Text) |
| + } |
| + } |
| + m, err := release.GetManifest(context.Background(), fx.rel, fx.sqid, "shop", "v1") |
| + if err != nil || m.Assets == nil || !samePathList(m.Assets.Paths, []string{"/assets/", "/img/"}) { |
| + t.Fatalf("stored assets = %+v, %v, want them unchanged", m.Assets, err) |
| + } |
| + |
| + plain := shipPart{name: "linux-arm64", body: randomBytes(t, 64)} |
| + if code, out := postShip(t, u, token, []shipPart{metaPart(t, "v2", "binary"), plain}); code != http.StatusCreated { |
| + t.Fatalf("ship v2 = %d %v", code, out) |
| + } |
| + code, out := postShip(t, u, token, []shipPart{metaPartAssets(t, "v2", "binary", "/assets/"), plain}) |
| + if code != http.StatusConflict { |
| + t.Fatalf("re-ship adding folders = %d %v, want 409", code, out) |
| + } |
| +} |
| + |
| +// TestAPIShipEchoesTheStoredAssets: both success answers echo what is on |
| +// record, and a ship that declared none echoes no key. |
| +func TestAPIShipEchoesTheStoredAssets(t *testing.T) { |
| + _, token, u := shipAssetsFixture(t, "code-ship-assets-echo") |
| + art := shipPart{name: "linux-arm64", body: randomBytes(t, 64)} |
| + code, out := postShip(t, u, token, []shipPart{metaPartAssets(t, "v1", "binary", "/assets/", "/img/"), art}) |
| + if code != http.StatusCreated || !samePathList(assetPathsOf(out["assets"]), []string{"/assets/", "/img/"}) { |
| + t.Fatalf("ship = %d %v, want 201 echoing the folders", code, out) |
| + } |
| + code, out = postShip(t, u, token, []shipPart{metaPartAssets(t, "v1", "binary", "/img/", "/assets/"), art}) |
| + if code != http.StatusOK || !samePathList(assetPathsOf(out["assets"]), []string{"/assets/", "/img/"}) { |
| + t.Fatalf("re-ship = %d %v, want 200 echoing the stored order", code, out) |
| + } |
| + code, out = postShip(t, u, token, []shipPart{metaPart(t, "v2", "binary"), {name: "linux-arm64", body: randomBytes(t, 64)}}) |
| + if code != http.StatusCreated { |
| + t.Fatalf("ship v2 = %d %v", code, out) |
| + } |
| + if _, present := out["assets"]; present { |
| + t.Fatalf("a ship with no folders echoes assets: %v", out) |
| + } |
| +} |
diff --git a/ship_assets_test.go b/ship_assets_test.go| new file mode 100644 |
| index 000000000..6dd0d6323 |
| --- /dev/null |
| +++ b/ship_assets_test.go |
| @@ -0,0 +1,187 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "io" |
| + "io/fs" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The approved copy rows this file pins (docs/superpowers/plans/ |
| +// 2026-10-24-edge-asset-cache-copy.md). |
| +const ( |
| + eac4Text = "asset path / would cache every page on the site. name the folder your build writes to, for example /assets/." |
| + eac8Text = "a static site is already served from every region, so it has no asset folders to declare. leave --assets off." |
| + eac22Text = "this console is older than --assets and stored the release without your asset folders, so nothing would be kept near your visitors. nothing was promoted. update the console, then ship again under a new version." |
| +) |
| + |
| +// TestAssetPathsFlag: the flag is repeatable and keeps the order given, and |
| +// Set never validates (the grammar runs once, over the whole list). |
| +func TestAssetPathsFlag(t *testing.T) { |
| + var f assetPathsFlag |
| + for _, v := range []string{"/assets/", "/img/", "/"} { |
| + if err := f.Set(v); err != nil { |
| + t.Fatalf("Set(%q) = %v, want no validation at Set", v, err) |
| + } |
| + } |
| + if got := []string(f); len(got) != 3 || got[0] != "/assets/" || got[1] != "/img/" || got[2] != "/" { |
| + t.Fatalf("flag = %q", got) |
| + } |
| + if f.String() != "/assets/,/img/,/" { |
| + t.Fatalf("String() = %q", f.String()) |
| + } |
| + var unset assetPathsFlag |
| + if unset != nil || unset.String() != "" { |
| + t.Fatalf("an unset flag = %#v %q, want nil", unset, unset.String()) |
| + } |
| +} |
| + |
| +// TestShipDirectModeEmbedsAssets: direct-mode `carlos ship --assets` writes |
| +// the declared folders into the manifest in the order given, and a ship |
| +// without the flag writes no "assets" key at all, so its bytes are what the |
| +// verb wrote before the flag existed. |
| +func TestShipDirectModeEmbedsAssets(t *testing.T) { |
| + clearDeploymentEnv(t) |
| + dir := t.TempDir() |
| + t.Setenv("CARLOS_DEPLOYMENT_DIR", dir) |
| + bin := filepath.Join(t.TempDir(), "shop") |
| + if err := os.WriteFile(bin, []byte("binary bytes"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + captureStdout(t) |
| + runShip([]string{"-app", "shop", "-version", "abc1234", "-target", "linux-arm64", |
| + "--assets", "/assets/", "--assets", "/img/", bin}) |
| + runShip([]string{"-app", "shop", "-version", "def5678", "-target", "linux-arm64", bin}) |
| + |
| + ctx := context.Background() |
| + store := release.NewDirStore(dir) |
| + m, err := release.GetManifest(ctx, store, release.OpsAccount, "shop", "abc1234") |
| + if err != nil { |
| + t.Fatalf("manifest: %v", err) |
| + } |
| + if m.Assets == nil || len(m.Assets.Paths) != 2 || m.Assets.Paths[0] != "/assets/" || m.Assets.Paths[1] != "/img/" { |
| + t.Fatalf("assets = %+v, want [/assets/ /img/]", m.Assets) |
| + } |
| + raw, _, err := store.Get(ctx, release.ManifestKey(release.OpsAccount, "shop", "def5678")) |
| + if err != nil { |
| + t.Fatalf("read the flagless manifest: %v", err) |
| + } |
| + if strings.Contains(string(raw), "assets") { |
| + t.Fatalf("a ship with no --assets grew a key:\n%s", raw) |
| + } |
| +} |
| + |
| +// TestShipRefusesBadAssetsBeforeUpload: a bad folder, or any folder on a |
| +// static release, is refused at the terminal with the approved sentence, and |
| +// nothing reaches the bucket. |
| +func TestShipRefusesBadAssetsBeforeUpload(t *testing.T) { |
| + run, bucket := versioningCLI(t) |
| + site := t.TempDir() |
| + if err := os.WriteFile(filepath.Join(site, "index.html"), []byte("<p>shop</p>"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + cases := []struct { |
| + name string |
| + args []string |
| + want string |
| + }{ |
| + {"slash alone", []string{"ship", "-app", "shop", "-version", "abc1234", "--assets", "/", versioningArtifact(t, "build one")}, eac4Text}, |
| + {"static", []string{"ship", "-app", "shop", "-version", "abc1234", "-kind", "static", "--assets", "/assets/", site}, eac8Text}, |
| + } |
| + for _, c := range cases { |
| + out, err := run(c.args...) |
| + if err == nil { |
| + t.Fatalf("%s: ship succeeded:\n%s", c.name, out) |
| + } |
| + if !strings.Contains(out, c.want) { |
| + t.Fatalf("%s: refusal does not carry the approved text %q:\n%s", c.name, c.want, out) |
| + } |
| + } |
| + var stored []string |
| + filepath.WalkDir(bucket, func(p string, d fs.DirEntry, err error) error { |
| + if err == nil && !d.IsDir() && strings.Contains(p, "shop") { |
| + stored = append(stored, p) |
| + } |
| + return nil |
| + }) |
| + if len(stored) != 0 { |
| + t.Fatalf("a refused ship uploaded %q", stored) |
| + } |
| +} |
| + |
| +// TestCheckAssetsEcho: checked only when folders were sent; an answer without |
| +// them, or with other folders, is EAC-22; the console's order does not matter. |
| +func TestCheckAssetsEcho(t *testing.T) { |
| + sent := []string{"/assets/", "/img/"} |
| + if err := checkAssetsEcho(nil, nil); err != nil { |
| + t.Errorf("nothing sent: %v", err) |
| + } |
| + if err := checkAssetsEcho(sent, &release.Assets{Paths: []string{"/img/", "/assets/"}}); err != nil { |
| + t.Errorf("echoed: %v", err) |
| + } |
| + for _, echoed := range []*release.Assets{nil, {}, {Paths: []string{"/assets/"}}} { |
| + if err := checkAssetsEcho(sent, echoed); err == nil || err.Error() != eac22Text { |
| + t.Errorf("echo %+v passed or said %v", echoed, err) |
| + } |
| + } |
| +} |
| + |
| +// TestAPISessionShipCarriesAssetsInMeta: API mode sends the folders in the |
| +// meta part, refuses an answer that does not echo them, and sends no key when |
| +// none were given. |
| +func TestAPISessionShipCarriesAssetsInMeta(t *testing.T) { |
| + var meta string |
| + echo := `,"assets":{"paths":["/assets/","/img/"]}` |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + mr, err := r.MultipartReader() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + p, err := mr.NextPart() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b, _ := io.ReadAll(p) |
| + meta = string(b) |
| + w.Header().Set("Content-Type", "application/json") |
| + w.WriteHeader(http.StatusCreated) |
| + io.WriteString(w, `{"result":"shipped","version":"1.2.3"`+echo+`}`) |
| + })) |
| + defer ts.Close() |
| + file := filepath.Join(t.TempDir(), "bin") |
| + if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + ship := func(s *apiSession) error { |
| + _, err := s.ship(context.Background(), apiAcct{Seg: "abc"}, "shop", "1.2.3", "binary", "", "", |
| + map[string]string{"linux-arm64": file}, nil, false) |
| + return err |
| + } |
| + s := newTestSession(ts.URL) |
| + s.shipAssets = []string{"/assets/", "/img/"} |
| + if err := ship(s); err != nil { |
| + t.Fatalf("ship: %v", err) |
| + } |
| + if !strings.Contains(meta, `"assets":{"paths":["/assets/","/img/"]}`) { |
| + t.Fatalf("meta = %s, want assets.paths", meta) |
| + } |
| + echo = "" |
| + if err := ship(s); err == nil || err.Error() != eac22Text { |
| + t.Fatalf("an answer without the echo: %v", err) |
| + } |
| + s.shipAssets = nil |
| + if err := ship(s); err != nil { |
| + t.Fatalf("a ship with no folders needs no echo: %v", err) |
| + } |
| + if strings.Contains(meta, "assets") { |
| + t.Fatalf("a ship with no folders sent some: %s", meta) |
| + } |
| +} |