mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-23 05:42:33 +09:00
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:
committed by
GitHub
parent
ae9b34897f
commit
e7af84df72
@@ -15,6 +15,7 @@ func TestMain(m *testing.M) {
|
||||
"action_runner_token.yml",
|
||||
"action_run.yml",
|
||||
"repository.yml",
|
||||
"user.yml",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ func CancelPreviousJobs(ctx context.Context, repoID int64, ref, workflowID strin
|
||||
Ref: ref,
|
||||
WorkflowID: workflowID,
|
||||
TriggerEvent: event,
|
||||
Status: []Status{StatusRunning, StatusWaiting, StatusBlocked},
|
||||
Status: []Status{StatusRunning, StatusWaiting, StatusBlocked, StatusCancelling},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -329,7 +329,7 @@ func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, err
|
||||
}
|
||||
|
||||
// If the job has an associated task, try to stop the task, effectively cancelling the job.
|
||||
if err := StopTask(ctx, job.TaskID, StatusCancelled); err != nil {
|
||||
if err := StopTask(ctx, job.TaskID, StatusCancelling); err != nil {
|
||||
return cancelledJobs, err
|
||||
}
|
||||
updatedJob, err := GetRunJobByRunAndID(ctx, job.RunID, job.ID)
|
||||
@@ -452,6 +452,7 @@ func CancelPreviousJobsByRunConcurrency(ctx context.Context, attempt *ActionRunA
|
||||
statusFindOption := []Status{StatusWaiting, StatusBlocked}
|
||||
if attempt.ConcurrencyCancel {
|
||||
statusFindOption = append(statusFindOption, StatusRunning)
|
||||
statusFindOption = append(statusFindOption, StatusCancelling)
|
||||
}
|
||||
attempts, jobs, err := GetConcurrentRunAttemptsAndJobs(ctx, attempt.RepoID, attempt.ConcurrencyGroup, statusFindOption)
|
||||
if err != nil {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -36,6 +36,7 @@ func TestAggregateJobStatus(t *testing.T) {
|
||||
{[]Status{StatusUnknown, StatusSkipped}, StatusUnknown},
|
||||
{[]Status{StatusUnknown, StatusFailure}, StatusFailure},
|
||||
{[]Status{StatusUnknown, StatusCancelled}, StatusCancelled},
|
||||
{[]Status{StatusUnknown, StatusCancelling}, StatusCancelling},
|
||||
{[]Status{StatusUnknown, StatusWaiting}, StatusWaiting},
|
||||
{[]Status{StatusUnknown, StatusRunning}, StatusRunning},
|
||||
{[]Status{StatusUnknown, StatusBlocked}, StatusBlocked},
|
||||
@@ -45,6 +46,7 @@ func TestAggregateJobStatus(t *testing.T) {
|
||||
{[]Status{StatusSuccess, StatusSkipped}, StatusSuccess}, // skipped doesn't affect success
|
||||
{[]Status{StatusSuccess, StatusFailure}, StatusFailure},
|
||||
{[]Status{StatusSuccess, StatusCancelled}, StatusCancelled},
|
||||
{[]Status{StatusSuccess, StatusCancelling}, StatusCancelling},
|
||||
{[]Status{StatusSuccess, StatusWaiting}, StatusWaiting},
|
||||
{[]Status{StatusSuccess, StatusRunning}, StatusRunning},
|
||||
{[]Status{StatusSuccess, StatusBlocked}, StatusBlocked},
|
||||
@@ -54,9 +56,16 @@ func TestAggregateJobStatus(t *testing.T) {
|
||||
{[]Status{StatusCancelled, StatusSuccess}, StatusCancelled},
|
||||
{[]Status{StatusCancelled, StatusSkipped}, StatusCancelled},
|
||||
{[]Status{StatusCancelled, StatusFailure}, StatusCancelled},
|
||||
{[]Status{StatusCancelled, StatusWaiting}, StatusCancelled},
|
||||
{[]Status{StatusCancelled, StatusRunning}, StatusCancelled},
|
||||
{[]Status{StatusCancelled, StatusBlocked}, StatusCancelled},
|
||||
{[]Status{StatusCancelled, StatusCancelling}, StatusCancelling},
|
||||
{[]Status{StatusCancelled, StatusWaiting}, StatusWaiting},
|
||||
{[]Status{StatusCancelled, StatusRunning}, StatusRunning},
|
||||
{[]Status{StatusCancelled, StatusBlocked}, StatusBlocked},
|
||||
|
||||
{[]Status{StatusCancelling}, StatusCancelling},
|
||||
{[]Status{StatusCancelling, StatusRunning}, StatusCancelling},
|
||||
{[]Status{StatusCancelling, StatusWaiting}, StatusCancelling},
|
||||
{[]Status{StatusCancelling, StatusFailure}, StatusCancelling},
|
||||
{[]Status{StatusCancelling, StatusSkipped}, StatusCancelling},
|
||||
|
||||
// failure with other status, usually fail fast, but "running" wins to match GitHub's behavior
|
||||
// another reason that we can't make "failure" wins over "running": it would cause a weird behavior that user cannot cancel a workflow or get current running workflows correctly by filter after a job fail.
|
||||
@@ -64,9 +73,10 @@ func TestAggregateJobStatus(t *testing.T) {
|
||||
{[]Status{StatusFailure, StatusSuccess}, StatusFailure},
|
||||
{[]Status{StatusFailure, StatusSkipped}, StatusFailure},
|
||||
{[]Status{StatusFailure, StatusCancelled}, StatusCancelled},
|
||||
{[]Status{StatusFailure, StatusCancelling}, StatusCancelling},
|
||||
{[]Status{StatusFailure, StatusWaiting}, StatusWaiting},
|
||||
{[]Status{StatusFailure, StatusRunning}, StatusRunning},
|
||||
{[]Status{StatusFailure, StatusBlocked}, StatusFailure},
|
||||
{[]Status{StatusFailure, StatusBlocked}, StatusBlocked},
|
||||
|
||||
// skipped with other status
|
||||
// "all skipped" is also considered as "mergeable" by "services/actions.toCommitStatus", the same as GitHub
|
||||
@@ -74,6 +84,7 @@ func TestAggregateJobStatus(t *testing.T) {
|
||||
{[]Status{StatusSkipped, StatusSuccess}, StatusSuccess},
|
||||
{[]Status{StatusSkipped, StatusFailure}, StatusFailure},
|
||||
{[]Status{StatusSkipped, StatusCancelled}, StatusCancelled},
|
||||
{[]Status{StatusSkipped, StatusCancelling}, StatusCancelling},
|
||||
{[]Status{StatusSkipped, StatusWaiting}, StatusWaiting},
|
||||
{[]Status{StatusSkipped, StatusRunning}, StatusRunning},
|
||||
{[]Status{StatusSkipped, StatusBlocked}, StatusBlocked},
|
||||
|
||||
@@ -121,8 +121,8 @@ type StatusInfo struct {
|
||||
// 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}
|
||||
statusInfoList := make([]StatusInfo, 0, 4)
|
||||
allStatus := []Status{StatusSuccess, StatusFailure, StatusWaiting, StatusRunning, StatusCancelling}
|
||||
statusInfoList := make([]StatusInfo, 0, len(allStatus))
|
||||
for _, s := range allStatus {
|
||||
statusInfoList = append(statusInfoList, StatusInfo{
|
||||
Status: int(s),
|
||||
|
||||
@@ -64,6 +64,8 @@ type ActionRunner struct {
|
||||
Ephemeral bool `xorm:"ephemeral NOT NULL DEFAULT false"`
|
||||
// Store if this runner is disabled and should not pick up new jobs
|
||||
IsDisabled bool `xorm:"is_disabled NOT NULL DEFAULT false"`
|
||||
// Store if this runner supports the StatusCancelling flow
|
||||
HasCancellingSupport bool `xorm:"has_cancelling_support NOT NULL DEFAULT false"`
|
||||
|
||||
Created timeutil.TimeStamp `xorm:"created"`
|
||||
Updated timeutil.TimeStamp `xorm:"updated"`
|
||||
|
||||
@@ -15,25 +15,27 @@ import (
|
||||
type Status int
|
||||
|
||||
const (
|
||||
StatusUnknown Status = iota // 0, consistent with runnerv1.Result_RESULT_UNSPECIFIED
|
||||
StatusSuccess // 1, consistent with runnerv1.Result_RESULT_SUCCESS
|
||||
StatusFailure // 2, consistent with runnerv1.Result_RESULT_FAILURE
|
||||
StatusCancelled // 3, consistent with runnerv1.Result_RESULT_CANCELLED
|
||||
StatusSkipped // 4, consistent with runnerv1.Result_RESULT_SKIPPED
|
||||
StatusWaiting // 5, isn't a runnerv1.Result
|
||||
StatusRunning // 6, isn't a runnerv1.Result
|
||||
StatusBlocked // 7, isn't a runnerv1.Result
|
||||
StatusUnknown Status = iota // 0, consistent with runnerv1.Result_RESULT_UNSPECIFIED
|
||||
StatusSuccess // 1, consistent with runnerv1.Result_RESULT_SUCCESS
|
||||
StatusFailure // 2, consistent with runnerv1.Result_RESULT_FAILURE
|
||||
StatusCancelled // 3, consistent with runnerv1.Result_RESULT_CANCELLED
|
||||
StatusSkipped // 4, consistent with runnerv1.Result_RESULT_SKIPPED
|
||||
StatusWaiting // 5, isn't a runnerv1.Result
|
||||
StatusRunning // 6, isn't a runnerv1.Result
|
||||
StatusBlocked // 7, isn't a runnerv1.Result
|
||||
StatusCancelling // 8, isn't a runnerv1.Result
|
||||
)
|
||||
|
||||
var statusNames = map[Status]string{
|
||||
StatusUnknown: "unknown",
|
||||
StatusWaiting: "waiting",
|
||||
StatusRunning: "running",
|
||||
StatusSuccess: "success",
|
||||
StatusFailure: "failure",
|
||||
StatusCancelled: "cancelled",
|
||||
StatusSkipped: "skipped",
|
||||
StatusBlocked: "blocked",
|
||||
StatusUnknown: "unknown",
|
||||
StatusWaiting: "waiting",
|
||||
StatusRunning: "running",
|
||||
StatusSuccess: "success",
|
||||
StatusFailure: "failure",
|
||||
StatusCancelled: "cancelled",
|
||||
StatusCancelling: "cancelling",
|
||||
StatusSkipped: "skipped",
|
||||
StatusBlocked: "blocked",
|
||||
}
|
||||
|
||||
// String returns the string name of the Status
|
||||
@@ -88,14 +90,41 @@ func (s Status) IsBlocked() bool {
|
||||
return s == StatusBlocked
|
||||
}
|
||||
|
||||
func (s Status) IsCancelling() bool {
|
||||
return s == StatusCancelling
|
||||
}
|
||||
|
||||
// In returns whether s is one of the given statuses
|
||||
func (s Status) In(statuses ...Status) bool {
|
||||
return slices.Contains(statuses, s)
|
||||
}
|
||||
|
||||
func (s Status) AsResult() runnerv1.Result {
|
||||
if s.IsDone() {
|
||||
return runnerv1.Result(s)
|
||||
switch s {
|
||||
case StatusSuccess:
|
||||
return runnerv1.Result_RESULT_SUCCESS
|
||||
case StatusFailure:
|
||||
return runnerv1.Result_RESULT_FAILURE
|
||||
case StatusCancelled, StatusCancelling:
|
||||
return runnerv1.Result_RESULT_CANCELLED
|
||||
case StatusSkipped:
|
||||
return runnerv1.Result_RESULT_SKIPPED
|
||||
default:
|
||||
return runnerv1.Result_RESULT_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func StatusFromResult(r runnerv1.Result) Status {
|
||||
switch r {
|
||||
case runnerv1.Result_RESULT_SUCCESS:
|
||||
return StatusSuccess
|
||||
case runnerv1.Result_RESULT_FAILURE:
|
||||
return StatusFailure
|
||||
case runnerv1.Result_RESULT_CANCELLED:
|
||||
return StatusCancelled
|
||||
case runnerv1.Result_RESULT_SKIPPED:
|
||||
return StatusSkipped
|
||||
default:
|
||||
return StatusUnknown
|
||||
}
|
||||
return runnerv1.Result_RESULT_UNSPECIFIED
|
||||
}
|
||||
|
||||
49
models/actions/status_test.go
Normal file
49
models/actions/status_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestStatusAsResult(t *testing.T) {
|
||||
cases := []struct {
|
||||
status Status
|
||||
want runnerv1.Result
|
||||
}{
|
||||
{StatusUnknown, runnerv1.Result_RESULT_UNSPECIFIED},
|
||||
{StatusWaiting, runnerv1.Result_RESULT_UNSPECIFIED},
|
||||
{StatusRunning, runnerv1.Result_RESULT_UNSPECIFIED},
|
||||
{StatusBlocked, runnerv1.Result_RESULT_UNSPECIFIED},
|
||||
{StatusSuccess, runnerv1.Result_RESULT_SUCCESS},
|
||||
{StatusFailure, runnerv1.Result_RESULT_FAILURE},
|
||||
{StatusCancelled, runnerv1.Result_RESULT_CANCELLED},
|
||||
{StatusCancelling, runnerv1.Result_RESULT_CANCELLED},
|
||||
{StatusSkipped, runnerv1.Result_RESULT_SKIPPED},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
assert.Equal(t, tt.want, tt.status.AsResult(), "status=%s", tt.status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusFromResult(t *testing.T) {
|
||||
cases := []struct {
|
||||
result runnerv1.Result
|
||||
want Status
|
||||
}{
|
||||
{runnerv1.Result_RESULT_UNSPECIFIED, StatusUnknown},
|
||||
{runnerv1.Result_RESULT_SUCCESS, StatusSuccess},
|
||||
{runnerv1.Result_RESULT_FAILURE, StatusFailure},
|
||||
{runnerv1.Result_RESULT_CANCELLED, StatusCancelled},
|
||||
{runnerv1.Result_RESULT_SKIPPED, StatusSkipped},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
assert.Equal(t, tt.want, StatusFromResult(tt.result), "result=%s", tt.result)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -7,9 +7,15 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
"code.gitea.io/gitea/modules/actions/jobparser"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func TestMakeTaskStepDisplayName(t *testing.T) {
|
||||
@@ -75,3 +81,228 @@ func TestMakeTaskStepDisplayName(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskCancellingFinalizesToCancelled(t *testing.T) {
|
||||
newRunningTask := func(t *testing.T) (*ActionTask, *ActionRunJob) {
|
||||
t.Helper()
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "cancelling-test-run",
|
||||
RepoID: 1,
|
||||
OwnerID: 2,
|
||||
WorkflowID: "test.yaml",
|
||||
Index: 999,
|
||||
TriggerUserID: 2,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
job := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "cancelling-finalization-job",
|
||||
Attempt: 1,
|
||||
JobID: "cancelling-finalization-job",
|
||||
Status: StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
|
||||
runner := &ActionRunner{
|
||||
UUID: "runner-cancelling-supported",
|
||||
Name: "runner-cancelling-supported",
|
||||
HasCancellingSupport: true,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), runner))
|
||||
|
||||
task := &ActionTask{
|
||||
JobID: job.ID,
|
||||
Attempt: 1,
|
||||
RunnerID: runner.ID,
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), task))
|
||||
|
||||
job.TaskID = task.ID
|
||||
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
|
||||
require.NoError(t, err)
|
||||
|
||||
return task, job
|
||||
}
|
||||
|
||||
testResult := func(t *testing.T, result runnerv1.Result) {
|
||||
t.Helper()
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
task, job := newRunningTask(t)
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
|
||||
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
|
||||
assert.Equal(t, StatusCancelling, taskAfterStop.Status)
|
||||
|
||||
updatedTask, err := UpdateTaskByState(t.Context(), task.RunnerID, &runnerv1.TaskState{
|
||||
Id: task.ID,
|
||||
Result: result,
|
||||
StoppedAt: timestamppb.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, StatusCancelled, updatedTask.Status)
|
||||
|
||||
taskAfterUpdate := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
|
||||
assert.Equal(t, StatusCancelled, taskAfterUpdate.Status)
|
||||
|
||||
jobAfterUpdate := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, StatusCancelled, jobAfterUpdate.Status)
|
||||
}
|
||||
|
||||
t.Run("runner reports success", func(t *testing.T) {
|
||||
testResult(t, runnerv1.Result_RESULT_SUCCESS)
|
||||
})
|
||||
|
||||
t.Run("runner reports failure", func(t *testing.T) {
|
||||
testResult(t, runnerv1.Result_RESULT_FAILURE)
|
||||
})
|
||||
}
|
||||
|
||||
func TestStopTaskCancellingFallsBackForLegacyRunner(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "cancelling-test-run",
|
||||
RepoID: 1,
|
||||
OwnerID: 2,
|
||||
WorkflowID: "test.yaml",
|
||||
Index: 999,
|
||||
TriggerUserID: 2,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
job := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "legacy-cancelling-job",
|
||||
Attempt: 1,
|
||||
JobID: "legacy-cancelling-job",
|
||||
Status: StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
|
||||
runner := &ActionRunner{
|
||||
UUID: "runner-legacy-no-cancelling",
|
||||
Name: "runner-legacy-no-cancelling",
|
||||
HasCancellingSupport: false,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), runner))
|
||||
|
||||
task := &ActionTask{
|
||||
JobID: job.ID,
|
||||
Attempt: 1,
|
||||
RunnerID: runner.ID,
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), task))
|
||||
|
||||
job.TaskID = task.ID
|
||||
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
|
||||
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
|
||||
assert.Equal(t, StatusCancelled, taskAfterStop.Status)
|
||||
assert.NotZero(t, taskAfterStop.Stopped)
|
||||
|
||||
jobAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, StatusCancelled, jobAfterStop.Status)
|
||||
assert.NotZero(t, jobAfterStop.Stopped)
|
||||
}
|
||||
|
||||
func TestStopTaskCancellingFallsBackForMissingRunner(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "cancelling-test-run",
|
||||
RepoID: 1,
|
||||
OwnerID: 2,
|
||||
WorkflowID: "test.yaml",
|
||||
Index: 999,
|
||||
TriggerUserID: 2,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
job := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "missing-runner-cancelling-job",
|
||||
Attempt: 1,
|
||||
JobID: "missing-runner-cancelling-job",
|
||||
Status: StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
|
||||
runner := &ActionRunner{
|
||||
UUID: "runner-cleaned-up-before-cancel",
|
||||
Name: "runner-cleaned-up-before-cancel",
|
||||
HasCancellingSupport: true,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), runner))
|
||||
|
||||
task := &ActionTask{
|
||||
JobID: job.ID,
|
||||
Attempt: 1,
|
||||
RunnerID: runner.ID,
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), task))
|
||||
|
||||
job.TaskID = task.ID
|
||||
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.DeleteByID[ActionRunner](t.Context(), runner.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
|
||||
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
|
||||
assert.Equal(t, StatusCancelled, taskAfterStop.Status)
|
||||
assert.NotZero(t, taskAfterStop.Stopped)
|
||||
|
||||
jobAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, StatusCancelled, jobAfterStop.Status)
|
||||
assert.NotZero(t, jobAfterStop.Stopped)
|
||||
}
|
||||
|
||||
@@ -411,6 +411,7 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel),
|
||||
newMigration(332, "Add last_sync_unix to mirror", v1_27.AddLastSyncUnixToMirror),
|
||||
newMigration(333, "Add bypass allowlist to branch protection", v1_27.AddBranchProtectionBypassAllowlist),
|
||||
newMigration(334, "Add cancelling support to action runners", v1_27.AddCancellingSupportToActionRunner),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
17
models/migrations/v1_27/v334.go
Normal file
17
models/migrations/v1_27/v334.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_27
|
||||
|
||||
import "xorm.io/xorm"
|
||||
|
||||
func AddCancellingSupportToActionRunner(x *xorm.Engine) error {
|
||||
type ActionRunner struct {
|
||||
HasCancellingSupport bool `xorm:"has_cancelling_support NOT NULL DEFAULT false"`
|
||||
}
|
||||
|
||||
_, err := x.SyncWithOptions(xorm.SyncOptions{
|
||||
IgnoreDropIndices: true,
|
||||
}, new(ActionRunner))
|
||||
return err
|
||||
}
|
||||
36
models/migrations/v1_27/v334_test.go
Normal file
36
models/migrations/v1_27/v334_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/migrations/migrationtest"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAddCancellingSupportToActionRunner(t *testing.T) {
|
||||
type ActionRunner struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
Name string
|
||||
}
|
||||
|
||||
x, deferable := migrationtest.PrepareTestEnv(t, 0, new(ActionRunner))
|
||||
defer deferable()
|
||||
if x == nil || t.Failed() {
|
||||
return
|
||||
}
|
||||
|
||||
_, err := x.Insert(&ActionRunner{Name: "runner"})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, AddCancellingSupportToActionRunner(x))
|
||||
|
||||
var hasCancellingSupport bool
|
||||
has, err := x.SQL("SELECT has_cancelling_support FROM action_runner WHERE id = ?", 1).Get(&hasCancellingSupport)
|
||||
require.NoError(t, err)
|
||||
require.True(t, has)
|
||||
require.False(t, hasCancellingSupport)
|
||||
}
|
||||
Reference in New Issue
Block a user