| 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 |