release: pipeline promotion order — entry channel, predecessors, hotfix names
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+68
−0
internal/release/pipeline.go+35 −0internal/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 |