carlos / platform Public

deploy: type the no-static-host refusal, resolve its channel alongside

The static branch nested two independent resolutions: hosts, then channel.
The mint path needs the CHANNEL precisely when the host is missing — the
record it is about to write must name one, and an omitted channel is
defaulted to stable by the door, which is a route that serves nothing and
no door can fix.

So they resolve independently and errNoStaticHost travels with a usable
channel. Typed for errAmbiguousChannel's reason: this is the refusal
runDeploy will answer by minting a hostname, and text-matching would
eventually mint in answer to a different sentence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev f51392cd2a2666dfdfb2aa74dc00ee87608a5ed4 parent 2a2ef01
2 files changed, +127 −37
  • deploy.go +67 −37
  • deploy_test.go +60 −0
diff --git a/deploy.go b/deploy.go
index eb597b75d..2d5eea14e 100644
--- a/deploy.go
+++ b/deploy.go
@@ -512,40 +512,27 @@ func (s *apiSession) accountName(acct apiAcct) string {
func deployTargets(recs []apiInstanceRecord, kind, channelFlag, hostFlag string,
channels func() (apiChannelsResult, error)) (channel string, hosts []string, static bool, err error) {
if kind == "static" && staticOnlyRecords(recs) {
- hosts, err := staticDeployHosts(hostFlag, recs)
- if err != nil {
- return "", nil, true, err
- }
- if channelFlag != "" {
- return channelFlag, hosts, true, nil
- }
- // A static record that names the deploy's own -host already knows
- // its channel — the record the first deploy's attach wrote. Same
- // convenience the instance path has always had (resolveDeployChannel
- // resolves from the -host record); a host with no record yet — the
- // SECOND static domain — falls through to the channels listing.
- if ch := staticRecordChannel(recs, hostFlag); ch != "" {
- return ch, hosts, true, nil
- }
- res, err := channels()
- if err != nil {
- return "", nil, true, fmt.Errorf("list channels: %w", err)
- }
- // No channels at all AND a declared pipeline: this is spec §2's
- // entry channel doing its job — a brand-new one-channel app's
- // FIRST deploy just lands on it, rather than being refused for a
- // channel that (being pipelined) was never going to be created any
- // other way. A pipeline whose channels have ALREADY been promoted
- // onto is not this case — staticDeployChannel below resolves it
- // exactly as before, unambiguous or not.
- if len(res.Channels) == 0 && res.PipelineEntry != "" {
- return res.PipelineEntry, hosts, true, nil
- }
- channel, err := staticDeployChannel(res.Channels)
- if err != nil {
- return "", nil, true, err
+ // Hosts and channel are resolved INDEPENDENTLY, because the mint
+ // path needs the channel precisely when the host is missing: the
+ // record this deploy is about to create has to name a channel, and
+ // an omitted one is defaulted to stable by the door
+ // (internal/console/apiinstances.go) — a route that serves nothing,
+ // with no door to change it afterwards.
+ //
+ // So errNoStaticHost travels WITH a usable channel: the one place
+ // this function returns both a value and an error, and the reason
+ // the two resolutions are no longer nested.
+ hosts, herr := staticDeployHosts(hostFlag, recs)
+ channel, cerr := staticChannel(channelFlag, hostFlag, recs, channels)
+ if cerr != nil {
+ // Both unresolved: the host refusal is the one that names
+ // something the member can actually supply, so it wins.
+ if herr != nil {
+ return "", nil, true, herr
+ }
+ return "", nil, true, cerr
}
- return channel, hosts, true, nil
+ return channel, hosts, true, herr
}
if channelFlag != "" {
// Explicit channel: no ambiguity to resolve. Watch the instances that
@@ -651,10 +638,45 @@ func staticDeployHosts(hostFlag string, recs []apiInstanceRecord) ([]string, err
if len(hosts) > 0 {
return hosts, nil
}
- return nil, errors.New("this app has no instance records — a static site is served by the edge straight from its\n" +
- "channel pointer, so nothing is ever adopted and no instance exists to resolve a host from.\n" +
- "Name the hostname the site answers on, so the deploy can watch it:\n" +
- " carlos deploy -app <a> -kind static -host carlosframework.com <site-dir>")
+ return nil, fmt.Errorf("%w: this app has no instance records — a static site is served by the edge straight from its\n"+
+ "channel pointer, so nothing is ever adopted and no instance exists to resolve a host from.\n"+
+ "Name the hostname the site answers on, so the deploy can watch it:\n"+
+ " carlos deploy -app <a> -kind static -host carlosframework.com <site-dir>", errNoStaticHost)
+}
+
+// staticChannel resolves the channel a static deploy promotes onto, in the
+// order the flag, the record and the app's channels earn it. Split out of
+// deployTargets' static branch so the MINT path can resolve a channel on the
+// very path where the host resolution fails — the two answers are
+// independent, and only their nesting ever made them look otherwise.
+func staticChannel(channelFlag, hostFlag string, recs []apiInstanceRecord,
+ channels func() (apiChannelsResult, error)) (string, error) {
+ if channelFlag != "" {
+ return channelFlag, nil
+ }
+ // A static record that names the deploy's own -host already knows its
+ // channel — the record the first deploy's attach wrote. Same convenience
+ // the instance path has always had (resolveDeployChannel resolves from
+ // the -host record); a host with no record yet — the SECOND static
+ // domain — falls through to the channels listing.
+ if ch := staticRecordChannel(recs, hostFlag); ch != "" {
+ return ch, nil
+ }
+ res, err := channels()
+ if err != nil {
+ return "", fmt.Errorf("list channels: %w", err)
+ }
+ // No channels at all AND a declared pipeline: this is the pipelines
+ // spec's entry channel doing its job — a brand-new one-channel app's
+ // FIRST deploy just lands on it, rather than being refused for a channel
+ // that (being pipelined) was never going to be created any other way. A
+ // pipeline whose channels have ALREADY been promoted onto is not this
+ // case — staticDeployChannel resolves it exactly as before, unambiguous
+ // or not.
+ if len(res.Channels) == 0 && res.PipelineEntry != "" {
+ return res.PipelineEntry, nil
+ }
+ return staticDeployChannel(res.Channels)
}
// staticDeployChannel picks the channel a NO-INSTANCE deploy promotes to,
@@ -696,6 +718,14 @@ func staticDeployChannel(chans []apiChannel) (string, error) {
// (review 2026-08-13, finding 2).
var errAmbiguousChannel = errors.New("ambiguous channel")
+// errNoStaticHost marks the refusal "this static app has nothing serving it
+// yet". Typed for errAmbiguousChannel's reason, and the stakes are the same
+// shape: runDeploy answers THIS refusal by minting the app's platform host
+// (static-platform-host design §3.2), and a refusal matched by text would
+// eventually mint a hostname in answer to a different sentence that merely
+// mentioned one.
+var errNoStaticHost = errors.New("no static host")
+
// 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_test.go b/deploy_test.go
index 7c018ad60..27e38ba46 100644
--- a/deploy_test.go
+++ b/deploy_test.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -1550,3 +1551,62 @@ func TestDeployPromoteProposedExitsCleanly(t *testing.T) {
}
checkNoRegistry()
}
+
+// TestDeployTargetsStaticNoHostIsTyped pins the refusal runDeploy answers by
+// MINTING the app's platform host (static-platform-host design §3.2). It is
+// typed for errAmbiguousChannel's reason: a refusal matched by text would
+// eventually mint in answer to a different sentence that merely mentioned a
+// hostname.
+func TestDeployTargetsStaticNoHostIsTyped(t *testing.T) {
+ _, _, static, err := deployTargets(nil, "static", "", "", func() (apiChannelsResult, error) {
+ return apiChannelsResult{PipelineEntry: "edge"}, nil
+ })
+ if !static {
+ t.Fatal("static = false, want true")
+ }
+ if !errors.Is(err, errNoStaticHost) {
+ t.Fatalf("err = %v, want errNoStaticHost", err)
+ }
+}
+
+// The mint needs a channel, and the only place it can come from is the
+// resolution deployTargets already does — so errNoStaticHost travels WITH a
+// usable channel.
+func TestDeployTargetsStaticNoHostCarriesResolvedChannel(t *testing.T) {
+ channel, hosts, _, err := deployTargets(nil, "static", "", "", func() (apiChannelsResult, error) {
+ return apiChannelsResult{PipelineEntry: "edge"}, nil
+ })
+ if !errors.Is(err, errNoStaticHost) {
+ t.Fatalf("err = %v, want errNoStaticHost", err)
+ }
+ if channel != "edge" {
+ t.Fatalf("channel = %q, want %q — the mint needs it", channel, "edge")
+ }
+ if len(hosts) != 0 {
+ t.Fatalf("hosts = %v, want none", hosts)
+ }
+}
+
+// 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) {
+ return apiChannelsResult{}, nil
+ })
+ if !errors.Is(err, errNoStaticHost) {
+ t.Fatalf("err = %v, want errNoStaticHost", err)
+ }
+}
+
+// Unchanged: a -host whose static record names a channel resolves from the
+// record and never lists channels.
+func TestDeployTargetsStaticHostRecordChannelSkipsListing(t *testing.T) {
+ recs := []apiInstanceRecord{{Host: "site.example.com", Kind: "static", Channel: "beta"}}
+ channel, hosts, static, err := deployTargets(recs, "static", "", "site.example.com", func() (apiChannelsResult, error) {
+ t.Error("channels() called; the record already names the channel")
+ return apiChannelsResult{}, nil
+ })
+ if err != nil || !static || channel != "beta" || len(hosts) != 1 || hosts[0] != "site.example.com" {
+ t.Fatalf("got %q %v %v %v", channel, hosts, static, err)
+ }
+}