release: pipeline-aware promotion — VersionStage + order gate in PromoteOnLadder
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+120
−2
internal/release/pipeline.go+30 −0internal/release/pipeline_test.go+66 −0internal/release/release.go+24 −2
diff --git a/internal/release/pipeline.go b/internal/release/pipeline.go| index 707ba99e4..cb7534373 100644 |
| --- a/internal/release/pipeline.go |
| +++ b/internal/release/pipeline.go |
| @@ -280,6 +280,36 @@ func validatePipeline(p *Pipeline, wantAcct, wantApp string) error { |
| return nil |
| } |
| +// VersionStage is VersionRung for a pipeline: the highest-Index channel |
| +// among p.Channels that currently points at version — "how far along this |
| +// app's own pipeline is this build", answered from the pointers themselves. |
| +// "" means the version is on none of them; it may still be shipped, sitting |
| +// on a channel outside the pipeline (a canary, a hotfix target). |
| +// |
| +// A channel whose pointer cannot be read or verified is skipped rather than |
| +// fatal, mirroring VersionRung: an unreadable pointer can only LOWER the |
| +// answer, which makes the order gate stricter, never looser. |
| +func VersionStage(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app, version string, p *Pipeline) (string, error) { |
| + // Verification needs a real key: ed25519.Verify PANICS on a wrong-sized |
| + // one, and a read-only console runs with none. |
| + if len(pub) != ed25519.PublicKeySize { |
| + return "", fmt.Errorf("version stage: no usable release public key") |
| + } |
| + best := "" |
| + bestIdx := -1 |
| + for _, c := range p.Channels { |
| + if i := p.Index(c.Name); i > bestIdx { |
| + ptr, err := Channel(ctx, s, pub, acct, app, c.Name) |
| + if err != nil || ptr.Version != version { |
| + continue |
| + } |
| + bestIdx = i |
| + best = c.Name |
| + } |
| + } |
| + return best, nil |
| +} |
| + |
| // PipelinePromotionAllowed is PromotionAllowed for an app-defined pipeline: |
| // order comes from the channel list, canary is unconditional, and every |
| // refusal names -hotfix — which bypasses ORDER only, never a channel's |
diff --git a/internal/release/pipeline_test.go b/internal/release/pipeline_test.go| index b53b5a0f8..0794a70d7 100644 |
| --- a/internal/release/pipeline_test.go |
| +++ b/internal/release/pipeline_test.go |
| @@ -166,3 +166,69 @@ func pipelineTestHarness(t *testing.T) (Store, ed25519.PrivateKey, ed25519.Publi |
| priv, pub := testKeys(t) |
| return s, priv, pub |
| } |
| + |
| +// VersionStage is VersionRung for a pipeline, and PromoteOnLadder must |
| +// consult it — order gate, entry, hotfix bypass, and canary staying |
| +// unconditional — the moment an app has one set (task 3, spec §3/§5). |
| +func TestVersionStageAndPipelinePromote(t *testing.T) { |
| + s, priv, pub := pipelineTestHarness(t) |
| + ctx := context.Background() |
| + chans := []PipelineChannel{{Name: "edge"}, {Name: "production"}} |
| + if _, err := SetPipeline(ctx, s, priv, "bae", "web", chans, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipRelease(t, s, "bae", "web", "v1", "linux-arm64", []byte("bin v1")) |
| + // Fresh build promotes onto the entry channel. |
| + if _, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "edge", "v1", PromoteOptions{}); err != nil { |
| + t.Fatalf("promote v1 onto entry: %v", err) |
| + } |
| + pipe, err := GetPipeline(ctx, s, pub, "bae", "web") |
| + if err != nil || pipe == nil { |
| + t.Fatalf("GetPipeline: %+v, %v", pipe, err) |
| + } |
| + if st, err := VersionStage(ctx, s, pub, "bae", "web", "v1", pipe); err != nil || st != "edge" { |
| + t.Fatalf("VersionStage = %q, %v; want edge", st, err) |
| + } |
| + // edge -> production allowed; a second fresh promote onto production refused. |
| + if _, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "production", "v1", PromoteOptions{}); err != nil { |
| + t.Fatalf("edge->production: %v", err) |
| + } |
| + shipRelease(t, s, "bae", "web", "v2", "linux-arm64", []byte("bin v2")) |
| + if _, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "production", "v2", PromoteOptions{}); err == nil { |
| + t.Fatal("v2 (on no channel) promoted straight onto production; want order refusal") |
| + } |
| + // Hotfix bypasses order. |
| + if _, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "production", "v2", PromoteOptions{Hotfix: true}); err != nil { |
| + t.Fatalf("hotfix: %v", err) |
| + } |
| + // Rung names outside the pipeline are refused for a pipelined app. |
| + if _, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "stable", "v2", PromoteOptions{}); err == nil { |
| + t.Fatal("stable promoted on a pipelined app whose pipeline has no stable") |
| + } |
| + // Canary stays unconditional. |
| + if _, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "canary/x", "v2", PromoteOptions{}); err != nil { |
| + t.Fatalf("canary on pipelined app: %v", err) |
| + } |
| +} |
| + |
| +// A grant names frozen rungs only (grant.go), so "production" is |
| +// uncoverable: the scoped signer must refuse (spec §6), and -hotfix — which |
| +// bypasses only the ORDER gate — can never widen a delegation's reach. |
| +func TestScopedKeyCannotSignPipelineChannel(t *testing.T) { |
| + s, priv, _ := pipelineTestHarness(t) |
| + ctx := context.Background() |
| + if _, err := SetPipeline(ctx, s, priv, "bae", "web", |
| + []PipelineChannel{{Name: "edge"}, {Name: "production"}}, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + rootPub := priv.Public().(ed25519.PublicKey) |
| + key, blob := mintGrant(t, priv, "builder", []string{RungEdge}, time.Hour) |
| + sg, err := ScopedSigner(key, blob, rootPub) |
| + if err != nil { |
| + t.Fatalf("ScopedSigner: %v", err) |
| + } |
| + shipRelease(t, s, "bae", "web", "v1", "linux-arm64", []byte("bin v1")) |
| + if _, err := sg.PromoteOnLadder(ctx, s, "bae", "web", "production", "v1", PromoteOptions{Hotfix: true}); err == nil { |
| + t.Fatal("scoped key signed a pipeline channel pointer; want refusal even with -hotfix") |
| + } |
| +} |
diff --git a/internal/release/release.go b/internal/release/release.go| index 82f305a35..21615f227 100644 |
| --- a/internal/release/release.go |
| +++ b/internal/release/release.go |
| @@ -862,7 +862,24 @@ func (sg Signer) PromoteOnLadder(ctx context.Context, s Store, acct, app, channe |
| app, acct, migrationHint(AppPrefix(acct, app))) |
| } |
| - from, err := VersionRung(ctx, s, pub, acct, app, version) |
| + // The pipeline decides the shape of everything below: which channels are |
| + // even in the running (VersionStage vs VersionRung) and how order is |
| + // judged (PipelinePromotionAllowed vs PromotionAllowed). A pipeline that |
| + // exists but cannot be read or verified refuses the promotion outright — |
| + // GetPipeline's error already means "not safely nil", and treating that |
| + // as "no pipeline" would silently drop back to the legacy ladder under |
| + // exactly the app that opted out of it. |
| + pipe, perr := GetPipeline(ctx, s, pub, acct, app) |
| + if perr != nil { |
| + return res, fmt.Errorf("promote: cannot read the pipeline for %s/%s: %w", acct, app, perr) |
| + } |
| + var from string |
| + var err error |
| + if pipe != nil { |
| + from, err = VersionStage(ctx, s, pub, acct, app, version, pipe) |
| + } else { |
| + from, err = VersionRung(ctx, s, pub, acct, app, version) |
| + } |
| if err != nil { |
| return res, fmt.Errorf("promote: %w", err) |
| } |
| @@ -874,7 +891,12 @@ func (sg Signer) PromoteOnLadder(ctx context.Context, s Store, acct, app, channe |
| if to == "" { |
| to = channel |
| } |
| - if err := PromotionAllowed(from, to); err != nil { |
| + if pipe != nil { |
| + err = PipelinePromotionAllowed(pipe, from, channel) |
| + } else { |
| + err = PromotionAllowed(from, to) |
| + } |
| + if err != nil { |
| return res, fmt.Errorf("promote %s %s -> %s: %w", app, version, channel, err) |
| } |
| } |