From 3c5f0dd129a2e5e194da5af8c9316800a954421e Mon Sep 17 00:00:00 2001 From: qwerty287 Date: Tue, 24 Feb 2026 10:47:19 +0100 Subject: [PATCH 1/2] Allow to cancel on failure --- docs/docs/20-usage/20-workflow-syntax.md | 2 + pipeline/backend/types/step.go | 2 +- .../frontend/yaml/linter/schema/schema.json | 2 +- server/model/pipeline.go | 1 + server/model/step.go | 4 +- server/pipeline/step_status.go | 45 ++++++++++++++++++- server/pipeline/step_status_test.go | 26 +++++------ server/rpc/rpc.go | 2 +- web/src/assets/locales/en.json | 3 +- web/src/lib/api/types/pipeline.ts | 1 + .../views/repo/pipeline/PipelineWrapper.vue | 3 ++ 11 files changed, 70 insertions(+), 21 deletions(-) diff --git a/docs/docs/20-usage/20-workflow-syntax.md b/docs/docs/20-usage/20-workflow-syntax.md index f97d87e4f10..6cd0467668a 100644 --- a/docs/docs/20-usage/20-workflow-syntax.md +++ b/docs/docs/20-usage/20-workflow-syntax.md @@ -196,6 +196,8 @@ Some of the steps may be allowed to fail without causing the whole workflow and + failure: ignore ``` +If you would like to cancel the full pipeline once the step fails, you can set `failure: cancel`. + ### `when` - Conditional Execution Woodpecker supports defining a list of conditions for a step by using a `when` block. If at least one of the conditions in the `when` block evaluate to true the step is executed, otherwise it is skipped. A condition is evaluated to true if _all_ sub-conditions are true. diff --git a/pipeline/backend/types/step.go b/pipeline/backend/types/step.go index 57adde71553..2b83dede979 100644 --- a/pipeline/backend/types/step.go +++ b/pipeline/backend/types/step.go @@ -40,7 +40,7 @@ type Step struct { OnFailure bool `json:"on_failure,omitempty"` OnSuccess bool `json:"on_success,omitempty"` Failure string `json:"failure,omitempty"` - AuthConfig Auth `json:"auth_config,omitempty"` + AuthConfig Auth `json:"auth_config"` NetworkMode string `json:"network_mode,omitempty"` Ports []Port `json:"ports,omitempty"` BackendOptions map[string]any `json:"backend_options,omitempty"` diff --git a/pipeline/frontend/yaml/linter/schema/schema.json b/pipeline/frontend/yaml/linter/schema/schema.json index 5b20b1ee024..ad0b36da61d 100644 --- a/pipeline/frontend/yaml/linter/schema/schema.json +++ b/pipeline/frontend/yaml/linter/schema/schema.json @@ -317,7 +317,7 @@ "failure": { "description": "How to handle the failure of this step. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#failure", "type": "string", - "enum": ["fail", "ignore"], + "enum": ["fail", "ignore", "cancel"], "default": "fail" }, "backend_options": { diff --git a/server/model/pipeline.go b/server/model/pipeline.go index 4de1c69bd36..1f67a1b0fa2 100644 --- a/server/model/pipeline.go +++ b/server/model/pipeline.go @@ -90,4 +90,5 @@ type PipelineOptions struct { type CancelInfo struct { CanceledByUser string `json:"canceled_by_user,omitempty"` SupersededBy int64 `json:"superseded_by,omitempty"` + CanceledByStep string `json:"canceled_by_step,omitempty"` } // @name CancelInfo diff --git a/server/model/step.go b/server/model/step.go index b0418c13a43..1b294bd2027 100644 --- a/server/model/step.go +++ b/server/model/step.go @@ -19,9 +19,7 @@ package model const ( FailureIgnore = "ignore" FailureFail = "fail" - //nolint:godot - // TODO: Not implemented yet. - // FailureCancel = "cancel" + FailureCancel = "cancel" ) // Step represents a process in the pipeline. diff --git a/server/pipeline/step_status.go b/server/pipeline/step_status.go index a45c1361601..c17be85502f 100644 --- a/server/pipeline/step_status.go +++ b/server/pipeline/step_status.go @@ -16,18 +16,20 @@ package pipeline import ( + "context" "fmt" "time" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/rpc" + "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) // UpdateStepStatus updates step status based on agent reports via RPC. -func UpdateStepStatus(store store.Store, step *model.Step, state rpc.StepState) error { +func UpdateStepStatus(ctx context.Context, store store.Store, step *model.Step, state rpc.StepState) error { log.Debug().Str("StepUUID", step.UUID).Msgf("Update step %#v state %#v", *step, state) switch step.State { @@ -54,6 +56,14 @@ func UpdateStepStatus(store store.Store, step *model.Step, state rpc.StepState) step.State = model.StatusSuccess } else { step.State = model.StatusFailure + + if step.Failure == model.FailureCancel { + // cancel the pipeline + err := cancelPipelineFromStep(ctx, store, step) + if err != nil { + return err + } + } } } @@ -71,6 +81,14 @@ func UpdateStepStatus(store store.Store, step *model.Step, state rpc.StepState) step.State = model.StatusSuccess } else { step.State = model.StatusFailure + + if step.Failure == model.FailureCancel { + // cancel the pipeline + err := cancelPipelineFromStep(ctx, store, step) + if err != nil { + return err + } + } } } @@ -89,6 +107,31 @@ func UpdateStepStatus(store store.Store, step *model.Step, state rpc.StepState) return store.StepUpdate(step) } +func cancelPipelineFromStep(ctx context.Context, store store.Store, step *model.Step) error { + pipeline, err := store.GetPipeline(step.PipelineID) + if err != nil { + return err + } + + repo, err := store.GetRepo(pipeline.RepoID) + if err != nil { + return err + } + + repoUser, err := store.GetUser(repo.UserID) + if err != nil { + return err + } + + _forge, err := server.Config.Services.Manager.ForgeFromRepo(repo) + if err != nil { + return err + } + return Cancel(ctx, _forge, store, repo, repoUser, pipeline, &model.CancelInfo{ + CanceledByStep: step.Name, + }) +} + func UpdateStepToStatusSkipped(store store.Store, step model.Step, finished int64) (*model.Step, error) { step.State = model.StatusSkipped if step.Started != 0 { diff --git a/server/pipeline/step_status_test.go b/server/pipeline/step_status_test.go index 2a00aef8797..9b9629b278b 100644 --- a/server/pipeline/step_status_test.go +++ b/server/pipeline/step_status_test.go @@ -47,7 +47,7 @@ func TestUpdateStepStatus(t *testing.T) { step := &model.Step{State: model.StatusPending} state := rpc.StepState{Started: 42, Finished: 0} - err := UpdateStepStatus(mockStoreStep(t), step, state) + err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusRunning, step.State) @@ -60,7 +60,7 @@ func TestUpdateStepStatus(t *testing.T) { step := &model.Step{State: model.StatusPending} state := rpc.StepState{Started: 0, Finished: 0} - err := UpdateStepStatus(mockStoreStep(t), step, state) + err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusRunning, step.State) @@ -76,7 +76,7 @@ func TestUpdateStepStatus(t *testing.T) { step := &model.Step{State: model.StatusPending} state := rpc.StepState{Started: 42, Exited: true, Finished: 100, ExitCode: 0, Error: ""} - err := UpdateStepStatus(mockStoreStep(t), step, state) + err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusSuccess, step.State) @@ -89,7 +89,7 @@ func TestUpdateStepStatus(t *testing.T) { step := &model.Step{State: model.StatusPending} state := rpc.StepState{Started: 42, Exited: true, Finished: 0, ExitCode: 0, Error: ""} - err := UpdateStepStatus(mockStoreStep(t), step, state) + err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusSuccess, step.State) @@ -105,7 +105,7 @@ func TestUpdateStepStatus(t *testing.T) { step := &model.Step{State: model.StatusPending} state := rpc.StepState{Started: 42, Exited: true, Finished: 34, ExitCode: 1, Error: "an error"} - err := UpdateStepStatus(mockStoreStep(t), step, state) + err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusFailure, step.State) @@ -126,7 +126,7 @@ func TestUpdateStepStatus(t *testing.T) { step := &model.Step{State: model.StatusRunning, Started: 42} state := rpc.StepState{Exited: true, Finished: 100, ExitCode: 0, Error: ""} - err := UpdateStepStatus(mockStoreStep(t), step, state) + err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusSuccess, step.State) @@ -138,7 +138,7 @@ func TestUpdateStepStatus(t *testing.T) { step := &model.Step{State: model.StatusRunning, Started: 42} state := rpc.StepState{Exited: true, Finished: 0, ExitCode: 0, Error: ""} - err := UpdateStepStatus(mockStoreStep(t), step, state) + err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusSuccess, step.State) @@ -154,7 +154,7 @@ func TestUpdateStepStatus(t *testing.T) { step := &model.Step{State: model.StatusRunning, Started: 42} state := rpc.StepState{Exited: true, Finished: 34, ExitCode: pipeline.ExitCodeKilled, Error: "an error"} - err := UpdateStepStatus(mockStoreStep(t), step, state) + err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusFailure, step.State) @@ -167,7 +167,7 @@ func TestUpdateStepStatus(t *testing.T) { step := &model.Step{State: model.StatusRunning, Started: 42} state := rpc.StepState{Exited: true, Finished: 34, ExitCode: 0, Error: "an error"} - err := UpdateStepStatus(mockStoreStep(t), step, state) + err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusFailure, step.State) @@ -180,7 +180,7 @@ func TestUpdateStepStatus(t *testing.T) { step := &model.Step{State: model.StatusRunning, Started: 42} state := rpc.StepState{Exited: false, Finished: 0} - err := UpdateStepStatus(mockStoreStep(t), step, state) + err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusRunning, step.State) @@ -196,7 +196,7 @@ func TestUpdateStepStatus(t *testing.T) { step := &model.Step{State: model.StatusRunning, Started: 42} state := rpc.StepState{Canceled: true} - err := UpdateStepStatus(mockStoreStep(t), step, state) + err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusKilled, step.State) @@ -208,7 +208,7 @@ func TestUpdateStepStatus(t *testing.T) { step := &model.Step{State: model.StatusRunning, Started: 42} state := rpc.StepState{Canceled: true, Exited: true, Finished: 100, ExitCode: 1, Error: "canceled"} - err := UpdateStepStatus(mockStoreStep(t), step, state) + err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusKilled, step.State) @@ -223,7 +223,7 @@ func TestUpdateStepStatus(t *testing.T) { step := &model.Step{State: model.StatusKilled, Started: 42, Finished: 64} state := rpc.StepState{Exited: false} - err := UpdateStepStatus(mocks.NewMockStore(t), step, state) + err := UpdateStepStatus(t.Context(), mocks.NewMockStore(t), step, state) assert.Error(t, err) assert.Contains(t, err.Error(), "does not expect rpc state updates") diff --git a/server/rpc/rpc.go b/server/rpc/rpc.go index 6fa97735d73..0d8208da076 100644 --- a/server/rpc/rpc.go +++ b/server/rpc/rpc.go @@ -195,7 +195,7 @@ func (s *RPC) Update(c context.Context, strWorkflowID string, state rpc.StepStat return err } - if err := pipeline.UpdateStepStatus(s.store, step, state); err != nil { + if err := pipeline.UpdateStepStatus(c, s.store, step, state); err != nil { log.Error().Err(err).Msg("rpc.update: cannot update step") } diff --git a/web/src/assets/locales/en.json b/web/src/assets/locales/en.json index 76d31055a64..47ded2e1de9 100644 --- a/web/src/assets/locales/en.json +++ b/web/src/assets/locales/en.json @@ -271,7 +271,8 @@ "created": "Created: {created}", "cancel_info": { "superseded_by": "Superseded by #{pipelineId}", - "canceled_by_user": "Canceled by {user}" + "canceled_by_user": "Canceled by {user}", + "canceled_by_step": "Canceled due to {step}" }, "debug": { "title": "Debug", diff --git a/web/src/lib/api/types/pipeline.ts b/web/src/lib/api/types/pipeline.ts index 079b4b82805..ed482a2da58 100644 --- a/web/src/lib/api/types/pipeline.ts +++ b/web/src/lib/api/types/pipeline.ts @@ -9,6 +9,7 @@ export interface PipelineError { export interface CancelInfo { canceled_by_user: string; + canceled_by_step: string; superseded_by: number; } diff --git a/web/src/views/repo/pipeline/PipelineWrapper.vue b/web/src/views/repo/pipeline/PipelineWrapper.vue index ca98a7243ad..5007b5e82d1 100644 --- a/web/src/views/repo/pipeline/PipelineWrapper.vue +++ b/web/src/views/repo/pipeline/PipelineWrapper.vue @@ -91,6 +91,9 @@ + From 697117fa829dd98a03e928f15fed10bf54e4ce6f Mon Sep 17 00:00:00 2001 From: qwerty287 Date: Sat, 28 Feb 2026 12:33:19 +0100 Subject: [PATCH 2/2] generate --- cmd/server/openapi/docs.go | 3 +++ rpc/proto/woodpecker_grpc.pb.go | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/server/openapi/docs.go b/cmd/server/openapi/docs.go index c5c78746bfd..d5ad5cf86dc 100644 --- a/cmd/server/openapi/docs.go +++ b/cmd/server/openapi/docs.go @@ -4681,6 +4681,9 @@ const docTemplate = `{ "CancelInfo": { "type": "object", "properties": { + "canceled_by_step": { + "type": "string" + }, "canceled_by_user": { "type": "string" }, diff --git a/rpc/proto/woodpecker_grpc.pb.go b/rpc/proto/woodpecker_grpc.pb.go index 88ca6e7a965..4dfd78a0efb 100644 --- a/rpc/proto/woodpecker_grpc.pb.go +++ b/rpc/proto/woodpecker_grpc.pb.go @@ -15,7 +15,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.0 +// - protoc-gen-go-grpc v1.6.1 // - protoc v6.33.1 // source: woodpecker.proto