release: a promotion onto an app-defined channel names the channel as To
PromoteResult.To was Rung(channel) with no fallback, so every promotion onto a channel a pipeline declared reported "" — an empty "to" in the promote API's answer, and an empty To in the Promotion record written into the channel pointer's own audit trail, which is the permanent record of where a build went. From already carried the channel name on that path (VersionStage answers in channel names), so a pipelined app's trail read "edge -> " for its whole history. The fallback is the channel's own name. Frozen rungs are untouched: canary/x still reports the rung "canary", edge reports "edge". Wire- additive in both places — a fuller string exactly where an empty one used to sit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+120
−6
internal/console/apicli_test.go+36 −0internal/release/pipeline_test.go+66 −0internal/release/release.go+18 −6
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go| index 18a1fe3b2..fa84c7441 100644 |
| --- a/internal/console/apicli_test.go |
| +++ b/internal/console/apicli_test.go |
| @@ -597,6 +597,42 @@ func TestAPIPromoteHappy(t *testing.T) { |
| } |
| } |
| +// TestAPIPromotePipelinedChannelNamesItselfInTo: the promote door's "to" |
| +// field is PromoteResult.To, which was the target channel's RUNG — and an |
| +// app-defined channel is not a rung, so a pipelined promotion answered with |
| +// "to": "". The CLI does not print it (it prints what it asked for), but the |
| +// field is the API's own record of where the build went, and "" is a worse |
| +// answer than the channel's name for every other client. Wire-additive: a |
| +// fuller string where an empty one used to sit. |
| +func TestAPIPromotePipelinedChannelNamesItselfInTo(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-promote-pipelined") |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", |
| + map[string]string{"name": "piped"}, token); status != http.StatusOK { |
| + t.Fatalf("create app = %d %v", status, out) |
| + } |
| + if status, out := putJSON(t, fx.ts.URL+"/api/cli/apps/bab/piped/pipeline", |
| + map[string]any{"channels": []map[string]any{{"name": "edge"}, {"name": "production"}}}, token); status != http.StatusOK { |
| + t.Fatalf("pipeline put = %d %v", status, out) |
| + } |
| + shipVersion(t, fx, "piped", "abc1234") |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/piped/promote", |
| + map[string]string{"version": "abc1234", "channel": "edge"}, token); status != http.StatusOK { |
| + t.Fatalf("promote to edge = %d %v", status, out) |
| + } |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/piped/promote", |
| + map[string]string{"version": "abc1234", "channel": "production"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("promote to production = %d %v", status, out) |
| + } |
| + if got := str(t, out, "to"); got != "production" { |
| + t.Errorf("promote body to = %q, want %q: %v", got, "production", out) |
| + } |
| + if got := str(t, out, "from"); got != "edge" { |
| + t.Errorf("promote body from = %q, want %q: %v", got, "edge", out) |
| + } |
| +} |
| + |
| // TestAPIPromoteStableRefused: stable cannot be reached over the API at all — |
| // there is no passkey to step up to — and the refusal is byte-identical for a |
| // member token and an owner token, so a token holder learns nothing about |
diff --git a/internal/release/pipeline_test.go b/internal/release/pipeline_test.go| index 0794a70d7..85783da15 100644 |
| --- a/internal/release/pipeline_test.go |
| +++ b/internal/release/pipeline_test.go |
| @@ -232,3 +232,69 @@ func TestScopedKeyCannotSignPipelineChannel(t *testing.T) { |
| t.Fatal("scoped key signed a pipeline channel pointer; want refusal even with -hotfix") |
| } |
| } |
| + |
| +// TestPipelinePromoteNamesTheChannelAsTo: PromoteResult.To was Rung(channel) |
| +// with no fallback, so a promotion onto an app-defined channel reported "" |
| +// — an empty "to" in the API's answer and, worse, an empty To in the |
| +// Promotion record written into the pointer's own audit trail, which is the |
| +// permanent record of where a build went. From already carried the channel |
| +// name on this path (VersionStage answers in channel names), so the trail |
| +// read "edge -> " for the app's whole history. The fallback is the channel's |
| +// own name; frozen rungs keep answering with the rung. |
| +func TestPipelinePromoteNamesTheChannelAsTo(t *testing.T) { |
| + s, priv, pub := pipelineTestHarness(t) |
| + ctx := context.Background() |
| + if _, err := SetPipeline(ctx, s, priv, "bae", "web", |
| + []PipelineChannel{{Name: "edge"}, {Name: "production"}}, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipRelease(t, s, "bae", "web", "v1", "linux-arm64", []byte("bin v1")) |
| + if _, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "edge", "v1", PromoteOptions{}); err != nil { |
| + t.Fatalf("promote to edge: %v", err) |
| + } |
| + res, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "production", "v1", PromoteOptions{Actor: "paul@keymail.dev"}) |
| + if err != nil { |
| + t.Fatalf("promote to production: %v", err) |
| + } |
| + if res.To != "production" { |
| + t.Errorf("PromoteResult.To = %q, want %q — an app-defined channel names itself", res.To, "production") |
| + } |
| + if res.From != "edge" { |
| + t.Errorf("PromoteResult.From = %q, want %q", res.From, "edge") |
| + } |
| + p, err := Channel(ctx, s, pub, "bae", "web", "production") |
| + if err != nil { |
| + t.Fatalf("read pointer: %v", err) |
| + } |
| + if len(p.Audit) == 0 { |
| + t.Fatalf("pointer carries no audit trail: %+v", p) |
| + } |
| + if got := p.Audit[len(p.Audit)-1]; got.To != "production" || got.From != "edge" { |
| + t.Errorf("promotion record = %+v, want from edge to production", got) |
| + } |
| +} |
| + |
| +// The frozen rungs are untouched: a legacy promote still reports the RUNG, |
| +// which is not always the channel name (canary/x reports "canary"). |
| +func TestLegacyPromoteStillReportsTheRung(t *testing.T) { |
| + s, priv, _ := pipelineTestHarness(t) |
| + ctx := context.Background() |
| + shipRelease(t, s, "bae", "web", "v1", "linux-arm64", []byte("bin v1")) |
| + res, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "canary/x", "v1", PromoteOptions{}) |
| + if err != nil { |
| + t.Fatalf("promote to canary/x: %v", err) |
| + } |
| + if res.To != RungCanary { |
| + t.Errorf("PromoteResult.To = %q, want the rung %q", res.To, RungCanary) |
| + } |
| + // A different build for edge: v1 is on canary now, and the legacy ladder |
| + // only lets a build with no rung yet enter at edge. |
| + shipRelease(t, s, "bae", "web", "v2", "linux-arm64", []byte("bin v2")) |
| + res, err = PromoteOnLadder(ctx, s, priv, "bae", "web", "edge", "v2", PromoteOptions{}) |
| + if err != nil { |
| + t.Fatalf("promote to edge: %v", err) |
| + } |
| + if res.To != RungEdge { |
| + t.Errorf("PromoteResult.To = %q, want the rung %q", res.To, RungEdge) |
| + } |
| +} |
diff --git a/internal/release/release.go b/internal/release/release.go| index 21615f227..a470e04d8 100644 |
| --- a/internal/release/release.go |
| +++ b/internal/release/release.go |
| @@ -825,7 +825,21 @@ func (sg Signer) PromoteOnLadder(ctx context.Context, s Store, acct, app, channe |
| if err := sg.allows(channel, time.Now().UTC()); err != nil { |
| return res, fmt.Errorf("promote: %w", err) |
| } |
| - res.To, _ = Rung(channel) |
| + // The target, named: its RUNG when the channel is one of the frozen |
| + // rungs (canary/x reports "canary" — the rung, not the channel, which is |
| + // what the ladder's vocabulary means by "to"), else the channel's own |
| + // name. Without the fallback an app-defined channel reported "", which is |
| + // not merely a thin API answer: it is the To written into the Promotion |
| + // record in the pointer's own audit trail, the permanent record of where |
| + // a build went — and From on that same path already carries the channel |
| + // name (VersionStage answers in channel names), so a pipelined app's |
| + // trail read "edge -> " forever. Wire-additive in both places: a fuller |
| + // string exactly where an empty one used to be. |
| + if rung, ok := Rung(channel); ok { |
| + res.To = rung |
| + } else { |
| + res.To = channel |
| + } |
| pub := sg.verifyPub() |
| // A repeat is a no-op, checked before anything else: re-running a command |
| @@ -885,12 +899,10 @@ func (sg Signer) PromoteOnLadder(ctx context.Context, s Store, acct, app, channe |
| } |
| res.From = from |
| if !opt.Hotfix { |
| - // A channel that is not a rung is named as itself in the refusal — |
| - // "wip is not a rung" reads better than an empty target. |
| + // res.To already names a channel that is not a rung as itself (see |
| + // its fallback above) — "wip is not a rung" reads better in the |
| + // refusal than an empty target. |
| to := res.To |
| - if to == "" { |
| - to = channel |
| - } |
| if pipe != nil { |
| err = PipelinePromotionAllowed(pipe, from, channel) |
| } else { |