Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions models/actions/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,13 @@ func CancelPreviousJobs(ctx context.Context, repoID int64, ref, workflowID strin

func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, error) {
cancelledJobs := make([]*ActionRunJob, 0, len(jobs))

// List of runIDs that have no cancelled jobs
runsToUpdate := map[int64]*ActionRunJob{}
for _, job := range jobs {
runsToUpdate[job.RunID] = job
}

// Iterate over each job and attempt to cancel it.
for _, job := range jobs {
// Skip jobs that are already in a terminal state (completed, cancelled, etc.).
Expand Down Expand Up @@ -304,6 +311,13 @@ func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, err
return cancelledJobs, fmt.Errorf("get job: %w", err)
}
cancelledJobs = append(cancelledJobs, updatedJob)
delete(runsToUpdate, job.RunID)
}

for runID, job := range runsToUpdate {
if err := UpdateRunStatus(ctx, job.RepoID, runID); err != nil {
return cancelledJobs, err
}
}

// Return nil to indicate successful cancellation of all running and waiting jobs.
Expand Down
47 changes: 26 additions & 21 deletions models/actions/run_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,30 @@ func GetRunJobsByRunID(ctx context.Context, runID int64) (ActionJobList, error)
return jobs, nil
}

func UpdateRunStatus(ctx context.Context, repoID, runID int64) error {
// Other goroutines may aggregate the status of the run and update it too.
// So we need load the run and its jobs before updating the run.
run, err := GetRunByRepoAndID(ctx, repoID, runID)
if err != nil {
return err
}
jobs, err := GetRunJobsByRunID(ctx, runID)
if err != nil {
return err
}
run.Status = AggregateJobStatus(jobs)
if run.Started.IsZero() && run.Status.IsRunning() {
run.Started = timeutil.TimeStampNow()
}
if run.Stopped.IsZero() && run.Status.IsDone() {
run.Stopped = timeutil.TimeStampNow()
}
if err := UpdateRun(ctx, run, "status", "started", "stopped"); err != nil {
return fmt.Errorf("update run %d: %w", run.ID, err)
}
return nil
}

func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, cols ...string) (int64, error) {
e := db.GetEngine(ctx)

Expand Down Expand Up @@ -173,27 +197,8 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col
}
}

{
// Other goroutines may aggregate the status of the run and update it too.
// So we need load the run and its jobs before updating the run.
run, err := GetRunByRepoAndID(ctx, job.RepoID, job.RunID)
if err != nil {
return 0, err
}
jobs, err := GetRunJobsByRunID(ctx, job.RunID)
if err != nil {
return 0, err
}
run.Status = AggregateJobStatus(jobs)
if run.Started.IsZero() && run.Status.IsRunning() {
run.Started = timeutil.TimeStampNow()
}
if run.Stopped.IsZero() && run.Status.IsDone() {
run.Stopped = timeutil.TimeStampNow()
}
if err := UpdateRun(ctx, run, "status", "started", "stopped"); err != nil {
return 0, fmt.Errorf("update run %d: %w", run.ID, err)
}
if err := UpdateRunStatus(ctx, job.RepoID, job.RunID); err != nil {
return 0, err
}

return affected, nil
Expand Down
38 changes: 38 additions & 0 deletions tests/integration/actions_cancel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package integration

import (
"fmt"
"net/http"
"net/url"
"testing"

actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"

"github.com/stretchr/testify/assert"
)

// This verifies that cancelling a run without running jobs (stuck in waiting) is updated to cancelled status
func TestActionsCancelStuckWaitingRun(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
session := loginUser(t, user5.Name)

// cancel the run by run index
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/cancel", user5.Name, "repo4", 191), map[string]string{
"_csrf": GetUserCSRFToken(t, session),
})

session.MakeRequest(t, req, http.StatusOK)

// check if the run is cancelled by id
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{
ID: 805,
})
assert.Equal(t, actions_model.StatusCancelled, run.Status)
})
}