Allow to cancel on failure (#6158)

This commit is contained in:
qwerty287
2026-03-19 13:41:44 +01:00
committed by GitHub
parent ab73370e93
commit 17dcc86942
12 changed files with 73 additions and 21 deletions

View File

@@ -4681,6 +4681,9 @@ const docTemplate = `{
"CancelInfo": {
"type": "object",
"properties": {
"canceled_by_step": {
"type": "string"
},
"canceled_by_user": {
"type": "string"
},

View File

@@ -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.

View File

@@ -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"`

View File

@@ -335,7 +335,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": {

View File

@@ -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

View File

@@ -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.

View File

@@ -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, status model.StatusValue) (*model.Step, error) {
step.State = status
if step.Started != 0 {

View File

@@ -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")

View File

@@ -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")
}

View File

@@ -272,7 +272,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",

View File

@@ -9,6 +9,7 @@ export interface PipelineError<D = unknown> {
export interface CancelInfo {
canceled_by_user: string;
canceled_by_step: string;
superseded_by: number;
}

View File

@@ -91,6 +91,9 @@
<template v-else-if="pipeline.cancel_info.canceled_by_user">
{{ $t('repo.pipeline.cancel_info.canceled_by_user', { user: pipeline.cancel_info.canceled_by_user }) }}
</template>
<template v-else-if="pipeline.cancel_info.canceled_by_step">
{{ $t('repo.pipeline.cancel_info.canceled_by_step', { user: pipeline.cancel_info.canceled_by_step }) }}
</template>
</span>
</div>
</div>