Files
gitea/models/actions/run_list.go
Kalash Thakare ☯︎ e7af84df72 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>
2026-05-17 08:41:39 +02:00

146 lines
4.3 KiB
Go

// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/translation"
webhook_module "code.gitea.io/gitea/modules/webhook"
"xorm.io/builder"
)
type RunList []*ActionRun
func (runs RunList) LoadTriggerUser(ctx context.Context) error {
userIDs := container.FilterSlice(runs, func(run *ActionRun) (int64, bool) {
return run.TriggerUserID, run.TriggerUser == nil
})
users := make(map[int64]*user_model.User, len(userIDs))
if err := db.GetEngine(ctx).In("id", userIDs).Find(&users); err != nil {
return err
}
for _, run := range runs {
if run.TriggerUser != nil {
continue
}
run.TriggerUser = users[run.TriggerUserID]
if run.TriggerUserID < 0 {
run.TriggerUserID, run.TriggerUser, _ = user_model.GetPossibleUserByID(ctx, run.TriggerUserID)
} else if run.TriggerUser == nil {
run.TriggerUserID, run.TriggerUser, _ = user_model.GetPossibleUserByID(ctx, user_model.GhostUserID)
}
}
return nil
}
func (runs RunList) LoadRepos(ctx context.Context) error {
repoIDs := container.FilterSlice(runs, func(run *ActionRun) (int64, bool) {
return run.RepoID, run.Repo == nil
})
repos, err := repo_model.GetRepositoriesMapByIDs(ctx, repoIDs)
if err != nil {
return err
}
for _, run := range runs {
if run.Repo == nil {
run.Repo = repos[run.RepoID]
}
}
return nil
}
type FindRunOptions struct {
db.ListOptions
RepoID int64
OwnerID int64
WorkflowID string
Ref string // the commit/tag/… that caused this workflow
TriggerUserID int64
TriggerEvent webhook_module.HookEventType
Approved bool // not util.OptionalBool, it works only when it's true
Status []Status
ConcurrencyGroup string
CommitSHA string
}
func (opts FindRunOptions) ToConds() builder.Cond {
cond := builder.NewCond()
if opts.RepoID > 0 {
cond = cond.And(builder.Eq{"`action_run`.repo_id": opts.RepoID})
}
if opts.WorkflowID != "" {
cond = cond.And(builder.Eq{"`action_run`.workflow_id": opts.WorkflowID})
}
if opts.TriggerUserID > 0 {
cond = cond.And(builder.Eq{"`action_run`.trigger_user_id": opts.TriggerUserID})
}
if opts.Approved {
cond = cond.And(builder.Gt{"`action_run`.approved_by": 0})
}
if len(opts.Status) > 0 {
cond = cond.And(builder.In("`action_run`.status", opts.Status))
}
if opts.Ref != "" {
cond = cond.And(builder.Eq{"`action_run`.ref": opts.Ref})
}
if opts.TriggerEvent != "" {
cond = cond.And(builder.Eq{"`action_run`.trigger_event": opts.TriggerEvent})
}
if opts.CommitSHA != "" {
cond = cond.And(builder.Eq{"`action_run`.commit_sha": opts.CommitSHA})
}
return cond
}
func (opts FindRunOptions) ToJoins() []db.JoinFunc {
if opts.OwnerID > 0 {
return []db.JoinFunc{func(sess db.Engine) error {
sess.Join("INNER", "repository", "repository.id = repo_id AND repository.owner_id = ?", opts.OwnerID)
return nil
}}
}
return nil
}
func (opts FindRunOptions) ToOrders() string {
return "`action_run`.`id` DESC"
}
type StatusInfo struct {
Status int
DisplayedStatus string
}
// GetStatusInfoList returns a slice of StatusInfo
func GetStatusInfoList(ctx context.Context, lang translation.Locale) []StatusInfo {
// same as those in aggregateJobStatus
allStatus := []Status{StatusSuccess, StatusFailure, StatusWaiting, StatusRunning, StatusCancelling}
statusInfoList := make([]StatusInfo, 0, len(allStatus))
for _, s := range allStatus {
statusInfoList = append(statusInfoList, StatusInfo{
Status: int(s),
DisplayedStatus: s.LocaleString(lang),
})
}
return statusInfoList
}
// GetActors returns a slice of Actors
func GetActors(ctx context.Context, repoID int64) ([]*user_model.User, error) {
actors := make([]*user_model.User, 0, 10)
return actors, db.GetEngine(ctx).Where(builder.In("id", builder.Select("`action_run`.trigger_user_id").From("`action_run`").
GroupBy("`action_run`.trigger_user_id").
Where(builder.Eq{"`action_run`.repo_id": repoID}))).
Cols("id", "name", "full_name", "avatar", "avatar_email", "use_custom_avatar").
OrderBy(user_model.GetOrderByName()).
Find(&actors)
}