carlos / platform Public

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>
Paul Campbell da9554b06b82da5ef2e01ad0e27521ce88a2134d parent 2485500
3 files changed, +120 −6
  • internal/console/apicli_test.go +36 −0
  • internal/release/pipeline_test.go +66 −0
  • internal/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 {