feat: execute post run cleanup when workflow is cancelled (#37275)

## Fixes #36983

## Summary
1. Add transitional `Cancelling` status (between `Running` and
`Cancelled`); cancel flow marks active tasks `Cancelling`, runner
finalizes to `Cancelled` on terminal result.
2. Taskless jobs cancel directly (no runner to finalize).
3. Runner-protocol responses map `Cancelling` → `RESULT_CANCELLED`.
4. Run/job aggregation treats `Cancelling` as active.
5. Status mapping/aggregation tests + en-US locale added.

**Problem**
When a workflow was cancelled from the UI, jobs were marked cancelled
immediately, which could skip post-run cleanup behavior.

## Solution
Use a transitional status path:
Running → Cancelling → Cancelled
This allows runner finalization and cleanup path execution before final
terminal state.

**Testing**

> 1. go test -tags "sqlite sqlite_unlock_notify" ./models/actions -run
"TestAggregateJobStatus|TestStatusAsResult|TestStatusFromResult"
> 2. go run
github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 run
./models/actions/... ./routers/api/actions/runner/...

## Related
- act_runner: https://gitea.com/gitea/act_runner/pulls/825 —
independent; this PR's capability gate keeps legacy runners on the
immediate-cancel path. The new flow activates only for runners that
advertise the `cancelling` capability.

Co-authored-by: Nicolas <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
Kalash Thakare ☯︎
2026-05-17 08:41:39 +02:00
committed by GitHub
co-authored by Nicolas silverwind Claude Zettat123 Giteabot
parent ae9b34897f
commit e7af84df72
31 changed files with 786 additions and 111 deletions
+36 -4
View File
@@ -193,7 +193,8 @@ func GetRunningTaskByToken(ctx context.Context, token string) (*ActionTask, erro
}
var tasks []*ActionTask
err := db.GetEngine(ctx).Where("token_last_eight = ? AND status = ?", lastEight, StatusRunning).Find(&tasks)
// Cancelling tasks are still authenticating — post-run cleanup steps need API access (artifact uploads, cache saves, etc.) before the runner finalizes the task.
err := db.GetEngine(ctx).Where("token_last_eight = ? AND status IN (?, ?)", lastEight, StatusRunning, StatusCancelling).Find(&tasks)
if err != nil {
return nil, err
} else if len(tasks) == 0 {
@@ -374,7 +375,12 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
// state.Result is not unspecified means the task is finished
if state.Result != runnerv1.Result_RESULT_UNSPECIFIED {
task.Status = Status(state.Result)
if task.Status == StatusCancelling {
// The runner may report SUCCESS/FAILURE for the cleanup phase; preserve user intent.
task.Status = StatusCancelled
} else {
task.Status = StatusFromResult(state.Result)
}
task.Stopped = timeutil.TimeStamp(state.StoppedAt.AsTime().Unix())
if err := UpdateTask(ctx, task, "status", "stopped"); err != nil {
return nil, err
@@ -409,7 +415,7 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
step.Stopped = convertTimestamp(v.StoppedAt)
}
if result != runnerv1.Result_RESULT_UNSPECIFIED {
step.Status = Status(result)
step.Status = StatusFromResult(result)
} else if step.Started != 0 {
step.Status = StatusRunning
}
@@ -423,7 +429,7 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
}
func StopTask(ctx context.Context, taskID int64, status Status) error {
if !status.IsDone() {
if !status.IsDone() && status != StatusCancelling {
return fmt.Errorf("cannot stop task with status %v", status)
}
e := db.GetEngine(ctx)
@@ -439,6 +445,32 @@ func StopTask(ctx context.Context, taskID int64, status Status) error {
}
now := timeutil.TimeStampNow()
if status == StatusCancelling {
runner, err := GetRunnerByID(ctx, task.RunnerID)
if err != nil {
if !errors.Is(err, util.ErrNotExist) {
return err
}
status = StatusCancelled
} else if !runner.HasCancellingSupport {
status = StatusCancelled
}
}
if status == StatusCancelling {
task.Status = StatusCancelling
if _, err := UpdateRunJob(ctx, &ActionRunJob{
ID: task.JobID,
RepoID: task.RepoID,
Status: StatusCancelling,
}, nil, "status"); err != nil {
return err
}
return UpdateTask(ctx, task, "status")
}
task.Status = status
task.Stopped = now
if _, err := UpdateRunJob(ctx, &ActionRunJob{