carlos / platform Public

release: pipeline-aware promotion — VersionStage + order gate in PromoteOnLadder

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev e9936468ba05a69955fd06afbd129ee7f78d6661 parent 9b9909d
3 files changed, +120 −2
  • internal/release/pipeline.go +30 −0
  • internal/release/pipeline_test.go +66 −0
  • internal/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)
}
}