carlos / platform Public

deploy: a static app with no route mints its platform host

#186's static half. deploy answers errNoStaticHost by writing the manifest
(idempotent upsert, and its response is where the account's pinned domain
comes from — the CLI never has to know the deployment's apps domain) and
then a kind:static record at <app>.<sqid>.<apps-domain>.

Before the ship, on attachUnclaimedHost's precedent: a failed mint must not
leave a published version with nothing serving it.

The channel guard on the mint is load-bearing, not defensive. deployTargets
returns this refusal with an EMPTY channel when the app has no resolvable
channel either, and a record written with no channel is defaulted to stable
by the door — the unfixable route this whole path exists to avoid. With no
channel there is nothing to mint onto, so the refusal stands.

A deployment with no apps domain (503) or an operator-pinned instance
domain (409) falls back to the -host refusal with the door's own reason
attached, classified by STATUS — reading English to make that decision is
the mistake errAmbiguousChannel records.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 8f18645af695d9bb7ab4de7394250d41c91ffe53 parent a93326f
5 files changed, +447 −3
  • apimode.go +26 −1
  • deploy.go +60 −1
  • deploy_e2e_test.go +167 −0
  • deploy_test.go +100 −1
  • internal/console/channelmove_test.go +94 −0
diff --git a/apimode.go b/apimode.go
index a29d61d04..84fedc3cd 100644
--- a/apimode.go
+++ b/apimode.go
@@ -2055,6 +2055,31 @@ type apiInstanceManifest struct {
Seed []string `json:"seed"`
}
+// enableError carries the instances-manifest door's STATUS alongside its
+// message. apiErrorFromBody collapses a refusal to its sentence, which is
+// right for a message a member reads and wrong for a decision a caller has
+// to make: the deploy mint must tell "this deployment has no platform host
+// to give" from "your credential just lost authority", and reading English
+// to do it is the mistake errAmbiguousChannel's comment records. Same shape
+// as domainListError, for the same reason.
+type enableError struct {
+ status int
+ msg string
+}
+
+func (e *enableError) Error() string { return e.msg }
+
+// enableUnavailable reports whether the deployment simply has no platform
+// host for this app: no apps domain configured at all (503), or an
+// instance domain an operator pinned elsewhere in store mode (409), which
+// this door refuses to speak for. Both mean "ask the member for a hostname
+// instead"; every other status is a real failure and must not be swallowed.
+func enableUnavailable(err error) bool {
+ var ee *enableError
+ return errors.As(err, &ee) &&
+ (ee.status == http.StatusServiceUnavailable || ee.status == http.StatusConflict)
+}
+
// instancesEnable asks the console to write (or update) the app's instances
// manifest under the account's own platform subdomain — the member-facing
// half of `carlos instances enable` (issue #66). channel and seed are
@@ -2071,7 +2096,7 @@ func (s *apiSession) instancesEnable(ctx context.Context, a apiAcct, app string,
return apiInstanceManifest{}, err
}
if status != http.StatusOK {
- return apiInstanceManifest{}, apiErrorFromBody(status, body)
+ return apiInstanceManifest{}, &enableError{status: status, msg: apiErrorFromBody(status, body).Error()}
}
var out apiInstanceManifest
if err := json.Unmarshal(body, &out); err != nil {
diff --git a/deploy.go b/deploy.go
index 2d5eea14e..6e5a9415d 100644
--- a/deploy.go
+++ b/deploy.go
@@ -42,7 +42,7 @@ func runDeploy(args []string) {
target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)")
capsFile := fs.String("capabilities", "", "path to a JSON capability block (internal/caps) to embed in the manifest")
channelFlag := fs.String("channel", "", "channel to promote to (default: resolved from the app's instances, or its one channel if it has no instances; error if ambiguous)")
- hostFlag := fs.String("host", "", "scope channel resolution and the watch to one instance host (required for an app with no instances, e.g. a static site)")
+ hostFlag := fs.String("host", "", "serve on this hostname instead of the app's own platform host (also scopes channel resolution and the watch to it)")
timeout := fs.Duration("timeout", defaultDeployTimeout, "how long to watch the URL before failing")
fs.Parse(args)
usage := "usage: carlos deploy -app <a> [-version <sha>] [-kind binary|static] [-target linux-arm64] " +
@@ -188,6 +188,32 @@ func runDeploy(args []string) {
func() (apiChannelsResult, error) { return sess.channels(ctx, acct, *app) })
}
}
+ // A static app with nothing serving it yet: mint its platform host
+ // rather than refusing (#186's static half). Typed, never text-matched,
+ // and BEFORE the ship — attachUnclaimedHost's ordering, so a failed mint
+ // never leaves a published version with nothing to serve it.
+ //
+ // The channel guard is load-bearing, not defensive: deployTargets
+ // returns this refusal with an empty channel when the app has no
+ // resolvable channel EITHER, and minting a record with no channel is
+ // exactly the stable-defaulted route this path exists to avoid. With no
+ // channel there is nothing to mint onto, so the refusal stands and names
+ // -host, which is the step that makes -channel askable next.
+ if errors.Is(err, errNoStaticHost) && channel != "" {
+ host, mintErr := mintPlatformHost(ctx, sess, acct, *app, channel)
+ switch {
+ case mintErr == nil:
+ hosts, err = []string{host}, nil
+ case enableUnavailable(mintErr):
+ // This deployment has no platform host to give — no apps domain,
+ // or an operator-pinned instance domain. The original refusal is
+ // the actionable one; the door's own reason rides along so the
+ // member is not left wondering why nothing was offered.
+ mutedf(stdout, "%v\n", mintErr)
+ default:
+ log.Fatalf("deploy: mint the platform host: %v", mintErr)
+ }
+ }
if err != nil {
log.Fatalf("deploy: %v", err)
}
@@ -726,6 +752,39 @@ var errAmbiguousChannel = errors.New("ambiguous channel")
// mentioned one.
var errNoStaticHost = errors.New("no static host")
+// mintPlatformHost gives an app its platform hostname —
+// <app>.<sqid>.<apps-domain> — and the static route record that serves it.
+// This is the whole of what #186's static half was missing: DNS already
+// resolves every name under the apps domain to the edge, and the edge
+// derives its own per-account wildcard cert from the live route table, so a
+// record is the only thing standing between a fresh static app and a URL.
+//
+// Two existing doors, in order, and no new authority anywhere. The manifest
+// PUT is an idempotent upsert — it CREATES when absent and otherwise
+// preserves every field, since nil channel and nil seed both mean "leave it"
+// — and it answers with the domain the console pins to the account, so this
+// client never has to know the deployment's apps domain to build the host.
+//
+// Then the record, carrying the kind and — the rail that must not bend — an
+// EXPLICIT channel. An omitted one is defaulted by the door to the
+// manifest's, then to "stable", while a fresh app's pipeline entry is
+// "edge": that record would follow a channel nothing ever promotes to, and
+// no door can change a record's channel afterwards. Repairing exactly that
+// took a raw bucket write (hello.bdn, 2026-08-22).
+func mintPlatformHost(ctx context.Context, sess *apiSession, acct apiAcct, app, channel string) (string, error) {
+ m, err := sess.instancesEnable(ctx, acct, app, nil, nil)
+ if err != nil {
+ return "", err
+ }
+ rec, err := sess.instanceCreate(ctx, acct, app, app+"."+m.Domain, "", channel, "static", nil, nil)
+ if err != nil {
+ return "", err
+ }
+ fmt.Fprintf(stdout, "%s %s (static, channel %s)\n",
+ colorFor(stdout).ok("minted"), rec.Host, rec.Channel)
+ return rec.Host, nil
+}
+
// attachUnclaimedHost auto-attaches -host before shipping, when this deploy
// is targeting a fresh static host with no route of its own yet (self-serve
// custom domains, #82/#151): the app's first deploy to a brand-new hostname
diff --git a/deploy_e2e_test.go b/deploy_e2e_test.go
index a810f457e..76f9e5357 100644
--- a/deploy_e2e_test.go
+++ b/deploy_e2e_test.go
@@ -445,3 +445,170 @@ func TestE2EDeployThroughConsole(t *testing.T) {
// Nothing here may have opened a local registry: this is API mode.
checkNoRegistry()
}
+
+// TestE2EStaticDeployMintsPlatformHost is #186's static half, end to end: an
+// app with no route and no -host gets one. The console, the edge and the
+// bucket are all real, so what this proves is not "the CLI sent a request"
+// but that the record the mint writes is the record the edge serves from.
+//
+// The member never names a hostname. That is the whole feature: DNS under
+// the apps domain already resolves to the edge and the edge already derives
+// its own wildcard cert, so a record was the only missing piece.
+func TestE2EStaticDeployMintsPlatformHost(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ const (
+ addr = "paul@keymail.dev"
+ app = "freshly"
+ channel = "canary/site"
+ // What the mint must derive on its own: the app's label under the
+ // account's platform subdomain, ops being "bab".
+ wantHost = app + ".bab.oncarlos.test"
+ )
+
+ tmp := t.TempDir()
+ relStore := release.NewDirStore(filepath.Join(tmp, "store"))
+ seed, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv, err := release.ParseSigningKey(seed, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, err := release.ParsePublicKey(pubB64, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cst, err := console.Open(filepath.Join(tmp, "console.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { cst.Close() })
+
+ var srv *console.Server
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ srv.ServeHTTP(w, r)
+ }))
+ t.Cleanup(ts.Close)
+ srv, err = console.NewServer(console.Config{
+ Origin: ts.URL,
+ Keymail: "http://keymail.invalid",
+ Registry: filepath.Join(tmp, "registry.db"),
+ Releases: relStore,
+ ReleasePub: pub,
+ Key: priv,
+ AppScheme: "http",
+ AppsDomain: "oncarlos.test",
+ }, cst)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := cst.CreateAppIn(console.OpsAccountID, app, addr, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ token, expiresAt := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@laptop")
+ credFile, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt})
+ if err := credFile.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ edgeReg, err := registry.Open(filepath.Join(tmp, "edge-registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { edgeReg.Close() })
+ ed, err := edge.New(edgeReg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ed.EnableStatic(relStore, pub, filepath.Join(tmp, "edge-cache"))
+ ed.SetStaticRecheck(0)
+ edgeSrv := httptest.NewServer(ed)
+ t.Cleanup(edgeSrv.Close)
+ // Standing in for the reconcile pass that materializes the record on a
+ // box: the same route, on the loopback host the watch dials.
+ if err := edgeReg.Upsert(registry.Route{
+ Host: "127.0.0.1", Kind: "static", App: app, Channel: channel, Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := ed.Reload(); err != nil {
+ t.Fatal(err)
+ }
+ origOrigin := watchOrigin
+ watchOrigin = func(h string) string {
+ if h != wantHost {
+ t.Errorf("watch dialled %q, want the MINTED host %q", h, wantHost)
+ }
+ return edgeSrv.URL
+ }
+ t.Cleanup(func() { watchOrigin = origOrigin })
+
+ siteDir := filepath.Join(tmp, "site")
+ if err := os.MkdirAll(siteDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("hello"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ buf := captureStdout(t)
+ runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001",
+ "-channel", channel, "-timeout", "30s", siteDir})
+ out := buf.String()
+ for _, wantLine := range []string{
+ "minted " + wantHost,
+ "deploying https://" + wantHost,
+ "promoted " + app + " sha0001 -> " + channel,
+ "live https://" + wantHost,
+ } {
+ if !strings.Contains(out, wantLine) {
+ t.Fatalf("mint deploy stdout missing %q:\n%s", wantLine, out)
+ }
+ }
+
+ // The record is the deliverable: a box's reconcile pass mints the route
+ // from THIS, so the kind and the channel are what decide whether the URL
+ // ever serves anything.
+ rec, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, app, wantHost)
+ if err != nil {
+ t.Fatalf("record after mint: %v", err)
+ }
+ if rec.Kind != "static" {
+ t.Fatalf("record Kind = %q, want static", rec.Kind)
+ }
+ if rec.Channel != channel {
+ t.Fatalf("record Channel = %q, want %q — an omitted channel would have written stable", rec.Channel, channel)
+ }
+ assertServes(t, edgeSrv.URL, "hello", "sha0001")
+
+ // A REDEPLOY must not mint again: the record is now in the instance
+ // list, so the host resolves from it.
+ if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("hello2"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ buf.Reset()
+ runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0002",
+ "-timeout", "30s", siteDir})
+ out = buf.String()
+ if strings.Contains(out, "minted") {
+ t.Fatalf("redeploy minted a second time:\n%s", out)
+ }
+ if !strings.Contains(out, "live https://"+wantHost) {
+ t.Fatalf("redeploy did not resolve the host from the record:\n%s", out)
+ }
+ assertServes(t, edgeSrv.URL, "hello2", "sha0002")
+
+ checkNoRegistry()
+}
diff --git a/deploy_test.go b/deploy_test.go
index 27e38ba46..70ae92f03 100644
--- a/deploy_test.go
+++ b/deploy_test.go
@@ -1590,12 +1590,18 @@ func TestDeployTargetsStaticNoHostCarriesResolvedChannel(t *testing.T) {
// Both unresolved: the host refusal names something the member can supply,
// so it is the one that surfaces.
func TestDeployTargetsStaticNoHostNoChannelPrefersHostRefusal(t *testing.T) {
- _, _, _, err := deployTargets(nil, "static", "", "", func() (apiChannelsResult, error) {
+ channel, _, _, err := deployTargets(nil, "static", "", "", func() (apiChannelsResult, error) {
return apiChannelsResult{}, nil
})
if !errors.Is(err, errNoStaticHost) {
t.Fatalf("err = %v, want errNoStaticHost", err)
}
+ // And the channel is EMPTY here, which is the whole reason runDeploy
+ // guards its mint on one: a record written with no channel is defaulted
+ // to stable by the door, and nothing can change it afterwards.
+ if channel != "" {
+ t.Fatalf("channel = %q, want empty — runDeploy must have nothing to mint onto", channel)
+ }
}
// Unchanged: a -host whose static record names a channel resolves from the
@@ -1610,3 +1616,96 @@ func TestDeployTargetsStaticHostRecordChannelSkipsListing(t *testing.T) {
t.Fatalf("got %q %v %v %v", channel, hosts, static, err)
}
}
+
+// TestMintPlatformHostSendsKindAndChannel pins the two fields the mint
+// cannot get wrong: the record's kind, and an EXPLICIT channel (an omitted
+// one is defaulted to stable by the door, and no door can change a record's
+// channel afterwards).
+func TestMintPlatformHostSendsKindAndChannel(t *testing.T) {
+ var create map[string]any
+ var enabled bool
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case strings.HasSuffix(r.URL.Path, "/instances-manifest"):
+ enabled = true
+ io.WriteString(w, `{"app":"mysite","domain":"bab.oncarlos.test"}`)
+ case strings.HasSuffix(r.URL.Path, "/instances"):
+ if err := json.NewDecoder(r.Body).Decode(&create); err != nil {
+ t.Errorf("decode create: %v", err)
+ }
+ w.WriteHeader(http.StatusCreated)
+ io.WriteString(w, `{"host":"mysite.bab.oncarlos.test","kind":"static","channel":"edge"}`)
+ default:
+ t.Errorf("unexpected path %s", r.URL.Path)
+ }
+ }))
+ defer ts.Close()
+
+ out := captureStdout(t)
+ host, err := mintPlatformHost(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "mysite", "edge")
+ if err != nil {
+ t.Fatalf("mintPlatformHost: %v", err)
+ }
+ if !enabled {
+ t.Fatal("the manifest was never written; the create door refuses without one")
+ }
+ if host != "mysite.bab.oncarlos.test" {
+ t.Fatalf("host = %q, want the app's label under the manifest's domain", host)
+ }
+ if create["kind"] != "static" {
+ t.Fatalf("create kind = %#v, want static", create["kind"])
+ }
+ if create["channel"] != "edge" {
+ t.Fatalf("create channel = %#v, want an EXPLICIT edge", create["channel"])
+ }
+ if got := out.String(); !strings.Contains(got, "minted") || !strings.Contains(got, host) {
+ t.Fatalf("stdout = %q, want it to name what was minted", got)
+ }
+}
+
+// A deployment with no platform namespace (503) and one whose instance
+// domain an operator pinned elsewhere (409) both mean the same thing: this
+// deployment will not mint a platform host for this app. Classified by
+// STATUS, never by reading the message.
+func TestMintPlatformHostUnavailableIsClassifiedByStatus(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ status int
+ body string
+ }{
+ {"no apps domain", http.StatusServiceUnavailable,
+ `{"error":"this console has no platform apps domain configured"}`},
+ {"operator-pinned domain", http.StatusConflict,
+ `{"error":"instances for mysite are managed under go.tito.io"}`},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(tc.status)
+ io.WriteString(w, tc.body)
+ }))
+ defer ts.Close()
+ _, err := mintPlatformHost(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "mysite", "edge")
+ if err == nil {
+ t.Fatal("want a refusal")
+ }
+ if !enableUnavailable(err) {
+ t.Fatalf("err = %v, want it classified unavailable", err)
+ }
+ })
+ }
+}
+
+// Every other failure is a real one and must NOT be classified as "this
+// deployment has no platform host" — a 403 means the credential lost
+// authority, which is worth stopping the deploy for.
+func TestMintPlatformHostForbiddenIsNotUnavailable(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ io.WriteString(w, `{"error":"not a member"}`)
+ }))
+ defer ts.Close()
+ _, err := mintPlatformHost(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "mysite", "edge")
+ if err == nil || enableUnavailable(err) {
+ t.Fatalf("err = %v, want an unclassified failure", err)
+ }
+}
diff --git a/internal/console/channelmove_test.go b/internal/console/channelmove_test.go
new file mode 100644
index 000000000..fccdcc7eb
--- /dev/null
+++ b/internal/console/channelmove_test.go
@@ -0,0 +1,94 @@
+package console
+
+import (
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func movePipe(chans ...release.PipelineChannel) *release.Pipeline {
+ return &release.Pipeline{Schema: 1, Channels: chans}
+}
+
+// The bypass the union exists to close, and the reason guarding only the
+// DESTINATION is not enough: the guarded channel is the one being LEFT, and
+// the destination carries no rules at all.
+func TestMoveGateTakesRulesFromTheChannelBeingLeft(t *testing.T) {
+ p := movePipe(
+ release.PipelineChannel{Name: "edge"},
+ release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}},
+ )
+ passkey, approvals := moveGate(p, "production", "edge")
+ if approvals != 2 {
+ t.Fatalf("approvals = %d, want 2 — moving OFF a guarded channel serves its URL unreviewed code", approvals)
+ }
+ if passkey {
+ t.Fatalf("passkey = true, want false — neither channel asks for one")
+ }
+}
+
+// And the other half: guarding only the source would let a route be walked
+// ONTO a channel whose policy the mover never met.
+func TestMoveGateTakesRulesFromTheChannelBeingJoined(t *testing.T) {
+ p := movePipe(
+ release.PipelineChannel{Name: "edge"},
+ release.PipelineChannel{Name: "production", Rules: release.ChannelRules{Passkey: true}},
+ )
+ passkey, approvals := moveGate(p, "edge", "production")
+ if !passkey || approvals != 0 {
+ t.Fatalf("passkey=%v approvals=%d, want true/0", passkey, approvals)
+ }
+}
+
+// Each rule at its strictest, across both ends and both approval fields.
+func TestMoveGateTakesTheMaximumAcrossBothEndsAndBothRules(t *testing.T) {
+ p := movePipe(
+ release.PipelineChannel{Name: "a", Rules: release.ChannelRules{ChangeApprovals: 3}},
+ release.PipelineChannel{Name: "b", Rules: release.ChannelRules{PromoteApprovals: 1}},
+ )
+ if _, approvals := moveGate(p, "a", "b"); approvals != 3 {
+ t.Fatalf("approvals = %d, want 3", approvals)
+ }
+ if _, approvals := moveGate(p, "b", "a"); approvals != 3 {
+ t.Fatalf("approvals = %d (reversed), want 3 — the gate is symmetric", approvals)
+ }
+}
+
+// A legacy app has no pipeline: the stable RUNG keeps the console's standing
+// unconditional passkey, in BOTH directions, and nothing else is gated.
+func TestMoveGateLegacyStableNeedsPasskeyBothDirections(t *testing.T) {
+ if passkey, _ := moveGate(nil, "edge", "stable"); !passkey {
+ t.Fatal("moving ONTO stable must need a passkey")
+ }
+ if passkey, _ := moveGate(nil, "stable", "edge"); !passkey {
+ t.Fatal("moving OFF stable must need a passkey")
+ }
+ if passkey, approvals := moveGate(nil, "edge", "beta"); passkey || approvals != 0 {
+ t.Fatalf("edge→beta on a legacy app must be free, got %v/%d", passkey, approvals)
+ }
+}
+
+// canary/* is reserved platform namespace, outside every pipeline: always
+// allowed, never gated, even when the app's real channels are strict.
+func TestMoveGateCanaryIsUngated(t *testing.T) {
+ p := movePipe(release.PipelineChannel{Name: "edge", Rules: release.ChannelRules{Passkey: true, PromoteApprovals: 2}})
+ if passkey, approvals := moveGate(p, "canary/x", "canary/y"); passkey || approvals != 0 {
+ t.Fatalf("canary→canary must be ungated, got %v/%d", passkey, approvals)
+ }
+ // But a canary END does not launder the OTHER end's rules.
+ if passkey, approvals := moveGate(p, "canary/x", "edge"); !passkey || approvals != 2 {
+ t.Fatalf("canary→edge must still meet edge's rules, got %v/%d", passkey, approvals)
+ }
+}
+
+// A pipelined app whose channel names sit outside the legacy vocabulary must
+// not pick up legacy rungs by accident: "production" is not "stable".
+func TestMoveGatePipelinedNameIsNotALegacyRung(t *testing.T) {
+ p := movePipe(
+ release.PipelineChannel{Name: "edge"},
+ release.PipelineChannel{Name: "stable"}, // named like the rung, no rules declared
+ )
+ if passkey, approvals := moveGate(p, "edge", "stable"); passkey || approvals != 0 {
+ t.Fatalf("a pipelined app governs its OWN channels: got %v/%d, want free", passkey, approvals)
+ }
+}