carlos / platform Public

release: pipeline promotion order — entry channel, predecessors, hotfix names

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 9b9909d93a6dcc77651f713a033ec363684edc55 parent 76fa3b5
2 files changed, +68 −0
  • internal/release/pipeline.go +35 −0
  • internal/release/pipeline_test.go +33 −0
diff --git a/internal/release/pipeline.go b/internal/release/pipeline.go
index e77b767ab..707ba99e4 100644
--- a/internal/release/pipeline.go
+++ b/internal/release/pipeline.go
@@ -279,3 +279,38 @@ func validatePipeline(p *Pipeline, wantAcct, wantApp string) error {
}
return 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
+// rules; those are the console's gates (spec §5).
+func PipelinePromotionAllowed(p *Pipeline, from, to string) error {
+ if r, ok := Rung(to); ok && r == RungCanary {
+ return nil
+ }
+ names := make([]string, len(p.Channels))
+ for i, c := range p.Channels {
+ names[i] = c.Name
+ }
+ i := p.Index(to)
+ if i < 0 {
+ if len(p.Channels) == 1 {
+ return fmt.Errorf("%q is not one of this app's channels — it has one channel (%s); `carlos pipeline` sets up more, or -hotfix points outside the pipeline",
+ to, p.Entry())
+ }
+ return fmt.Errorf("%q is not one of this app's channels (%s) — use -hotfix to point a channel outside the pipeline",
+ to, strings.Join(names, ", "))
+ }
+ if i == 0 {
+ if from == "" {
+ return nil // a fresh build entering the pipeline — deploys land here
+ }
+ return fmt.Errorf("nothing is promoted onto %s — deploys land there, and this build is already on %s; use -hotfix to override",
+ to, from)
+ }
+ if from == p.Channels[i-1].Name {
+ return nil
+ }
+ return fmt.Errorf("%s is entered from %s only, and this build is on %s — use -hotfix to bypass the order",
+ to, p.Channels[i-1].Name, describeRung(from))
+}
diff --git a/internal/release/pipeline_test.go b/internal/release/pipeline_test.go
index 8bf1c1c47..b53b5a0f8 100644
--- a/internal/release/pipeline_test.go
+++ b/internal/release/pipeline_test.go
@@ -123,6 +123,39 @@ func TestGetPipelineRefusesTamperAndWrongApp(t *testing.T) {
}
}
+func TestPipelinePromotionAllowed(t *testing.T) {
+ p := &Pipeline{Schema: 1, Channels: []PipelineChannel{
+ {Name: "edge"}, {Name: "staging"}, {Name: "production"}}}
+ for _, tc := range []struct {
+ name, from, to string
+ wantErr string // substring; "" = allowed
+ }{
+ {"canary always", "production", "canary/x", ""},
+ {"fresh build enters at entry", "", "edge", ""},
+ {"nothing else enters at entry", "staging", "edge", "-hotfix"},
+ {"edge to staging", "edge", "staging", ""},
+ {"staging to production", "staging", "production", ""},
+ {"skip refused", "edge", "production", "staging"},
+ {"unknown target names the list", "edge", "beta", "edge, staging, production"},
+ } {
+ err := PipelinePromotionAllowed(p, tc.from, tc.to)
+ if tc.wantErr == "" && err != nil {
+ t.Errorf("%s: unexpected refusal %v", tc.name, err)
+ }
+ if tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)) {
+ t.Errorf("%s: err = %v, want mention of %q", tc.name, err, tc.wantErr)
+ }
+ }
+}
+
+func TestPipelineSingleChannelPromoteNamesSetup(t *testing.T) {
+ p := &Pipeline{Schema: 1, Channels: DefaultPipelineChannels()}
+ err := PipelinePromotionAllowed(p, "edge", "production")
+ if err == nil || !strings.Contains(err.Error(), "carlos pipeline") {
+ t.Fatalf("one-channel refusal = %v; want it to point at carlos pipeline setup (spec §5)", err)
+ }
+}
+
// pipelineTestHarness builds a fresh store and root key pair, copying
// exactly how production_test.go's tests build theirs (NewDirStore over a
// temp dir, testKeys for the signing pair) — there is no in-memory store in