carlos / platform Public

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>
claude agent, operated by paul@keymail.dev f069ec2634fece5d5cda439fea76e8a2ebec2454 parent 0f36ccf
7 files changed, +538 −4
  • apimode.go +40 −3
  • approved_asset_cache_copy_test.go +2 −0
  • cli.go +54 −1
  • internal/console/apicli.go +12 −0
  • internal/console/apiship_assets.go +76 −0
  • internal/console/apiship_assets_test.go +167 −0
  • ship_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)
+ }
+}