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
+15 -6
View File
@@ -235,7 +235,10 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col
return 0, err
}
if affected == 0 || (!slices.Contains(cols, "status") && job.Status == 0) {
// xorm's Update writes only non-zero fields when cols is empty, so a zero job.Status
// with empty cols means status isn't actually being persisted — skip aggregation.
statusUpdated := slices.Contains(cols, "status") || (len(cols) == 0 && job.Status != 0)
if affected == 0 || !statusUpdated {
return affected, nil
}
@@ -308,12 +311,13 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col
func AggregateJobStatus(jobs []*ActionRunJob) Status {
allSuccessOrSkipped := len(jobs) != 0
allSkipped := len(jobs) != 0
var hasFailure, hasCancelled, hasWaiting, hasRunning, hasBlocked bool
var hasFailure, hasCancelled, hasCancelling, hasWaiting, hasRunning, hasBlocked bool
for _, job := range jobs {
allSuccessOrSkipped = allSuccessOrSkipped && (job.Status == StatusSuccess || job.Status == StatusSkipped)
allSkipped = allSkipped && job.Status == StatusSkipped
hasFailure = hasFailure || job.Status == StatusFailure
hasCancelled = hasCancelled || job.Status == StatusCancelled
hasCancelling = hasCancelling || job.Status == StatusCancelling
hasWaiting = hasWaiting || job.Status == StatusWaiting
hasRunning = hasRunning || job.Status == StatusRunning
hasBlocked = hasBlocked || job.Status == StatusBlocked
@@ -323,16 +327,20 @@ func AggregateJobStatus(jobs []*ActionRunJob) Status {
return StatusSkipped
case allSuccessOrSkipped:
return StatusSuccess
case hasCancelled:
return StatusCancelled
case hasCancelling:
return StatusCancelling
case hasRunning:
return StatusRunning
case hasWaiting:
return StatusWaiting
case hasBlocked:
// Blocked is still a pending state, so it should outrank terminal
// statuses like cancelled/failure when no job is waiting or running.
return StatusBlocked
case hasCancelled:
return StatusCancelled
case hasFailure:
return StatusFailure
case hasBlocked:
return StatusBlocked
default:
return StatusUnknown // it shouldn't happen
}
@@ -352,6 +360,7 @@ func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob)
statusFindOption := []Status{StatusWaiting, StatusBlocked}
if job.ConcurrencyCancel {
statusFindOption = append(statusFindOption, StatusRunning)
statusFindOption = append(statusFindOption, StatusCancelling)
}
attempts, jobs, err := GetConcurrentRunAttemptsAndJobs(ctx, job.RepoID, job.ConcurrencyGroup, statusFindOption)
if err != nil {