This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// This artifact server is inspired by https://github.com/nektos/act/blob/master/pkg/artifacts/server.go.
|
||||
// This artifact server is inspired by the Gitea runner artifact server implementation.
|
||||
// It updates url setting and uses ObjectStore to handle artifacts persistence.
|
||||
|
||||
package actions
|
||||
@@ -9,11 +9,12 @@ package actions
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
@@ -61,7 +62,8 @@ const (
|
||||
// ActionArtifact is a file that is stored in the artifact storage.
|
||||
type ActionArtifact struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RunID int64 `xorm:"index unique(runid_name_path)"` // The run id of the artifact
|
||||
RunID int64 `xorm:"index unique(runid_attempt_name_path)"` // The run id of the artifact
|
||||
RunAttemptID int64 `xorm:"index unique(runid_attempt_name_path) NOT NULL DEFAULT 0"`
|
||||
RunnerID int64
|
||||
RepoID int64 `xorm:"index"`
|
||||
OwnerID int64
|
||||
@@ -80,9 +82,9 @@ type ActionArtifact struct {
|
||||
// * "application/pdf", "text/html", etc.: real content type of the artifact
|
||||
ContentEncodingOrType string `xorm:"content_encoding"`
|
||||
|
||||
ArtifactPath string `xorm:"index unique(runid_name_path)"` // The path to the artifact when runner uploads it
|
||||
ArtifactName string `xorm:"index unique(runid_name_path)"` // The name of the artifact when runner uploads it
|
||||
Status ArtifactStatus `xorm:"index"` // The status of the artifact, uploading, expired or need-delete
|
||||
ArtifactPath string `xorm:"index unique(runid_attempt_name_path)"` // The path to the artifact when runner uploads it
|
||||
ArtifactName string `xorm:"index unique(runid_attempt_name_path)"` // The name of the artifact when runner uploads it
|
||||
Status ArtifactStatus `xorm:"index"` // The status of the artifact, uploading, expired or need-delete
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated index"`
|
||||
ExpiredUnix timeutil.TimeStamp `xorm:"index"` // The time when the artifact will be expired
|
||||
@@ -92,12 +94,13 @@ func CreateArtifact(ctx context.Context, t *ActionTask, artifactName, artifactPa
|
||||
if err := t.LoadJob(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
artifact, err := getArtifactByNameAndPath(ctx, t.Job.RunID, artifactName, artifactPath)
|
||||
artifact, err := getArtifactByNameAndPath(ctx, t.Job.RunID, t.Job.RunAttemptID, artifactName, artifactPath)
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
artifact := &ActionArtifact{
|
||||
ArtifactName: artifactName,
|
||||
ArtifactPath: artifactPath,
|
||||
RunID: t.Job.RunID,
|
||||
RunAttemptID: t.Job.RunAttemptID,
|
||||
RunnerID: t.RunnerID,
|
||||
RepoID: t.RepoID,
|
||||
OwnerID: t.OwnerID,
|
||||
@@ -122,9 +125,9 @@ func CreateArtifact(ctx context.Context, t *ActionTask, artifactName, artifactPa
|
||||
return artifact, nil
|
||||
}
|
||||
|
||||
func getArtifactByNameAndPath(ctx context.Context, runID int64, name, fpath string) (*ActionArtifact, error) {
|
||||
func getArtifactByNameAndPath(ctx context.Context, runID, runAttemptID int64, name, fpath string) (*ActionArtifact, error) {
|
||||
var art ActionArtifact
|
||||
has, err := db.GetEngine(ctx).Where("run_id = ? AND artifact_name = ? AND artifact_path = ?", runID, name, fpath).Get(&art)
|
||||
has, err := db.GetEngine(ctx).Where("run_id = ? AND run_attempt_id = ? AND artifact_name = ? AND artifact_path = ?", runID, runAttemptID, name, fpath).Get(&art)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
@@ -144,6 +147,7 @@ type FindArtifactsOptions struct {
|
||||
db.ListOptions
|
||||
RepoID int64
|
||||
RunID int64
|
||||
RunAttemptIDs []int64 // empty means every attempt; pass 0 to target legacy artifacts, which have run_attempt_id=0
|
||||
ArtifactName string
|
||||
Status int
|
||||
FinalizedArtifactsV4 bool
|
||||
@@ -163,6 +167,9 @@ func (opts FindArtifactsOptions) ToConds() builder.Cond {
|
||||
if opts.RunID > 0 {
|
||||
cond = cond.And(builder.Eq{"run_id": opts.RunID})
|
||||
}
|
||||
if len(opts.RunAttemptIDs) > 0 {
|
||||
cond = cond.And(builder.In("run_attempt_id", opts.RunAttemptIDs))
|
||||
}
|
||||
if opts.ArtifactName != "" {
|
||||
cond = cond.And(builder.Eq{"artifact_name": opts.ArtifactName})
|
||||
}
|
||||
@@ -178,20 +185,43 @@ func (opts FindArtifactsOptions) ToConds() builder.Cond {
|
||||
return cond
|
||||
}
|
||||
|
||||
// FindReadableArtifacts returns the artifacts of opts.RunAttemptIDs, only keeps the ones from a newer attempt.
|
||||
func FindReadableArtifacts(ctx context.Context, opts FindArtifactsOptions) ([]*ActionArtifact, error) {
|
||||
arts, err := db.Find[ActionArtifact](ctx, opts)
|
||||
if err != nil || len(opts.RunAttemptIDs) <= 1 {
|
||||
return arts, err
|
||||
}
|
||||
return keepLatestAttemptArtifacts(arts), nil
|
||||
}
|
||||
|
||||
// keepLatestAttemptArtifacts keeps, per name, only the artifacts of the newest attempt that has it.
|
||||
// A v3 artifact is one row per uploaded file, so the whole group of the winning attempt is kept.
|
||||
func keepLatestAttemptArtifacts(arts []*ActionArtifact) []*ActionArtifact {
|
||||
latest := make(map[string]int64)
|
||||
for _, art := range arts {
|
||||
latest[art.ArtifactName] = max(latest[art.ArtifactName], art.RunAttemptID)
|
||||
}
|
||||
return slices.DeleteFunc(arts, func(art *ActionArtifact) bool {
|
||||
return art.RunAttemptID != latest[art.ArtifactName]
|
||||
})
|
||||
}
|
||||
|
||||
// ActionArtifactMeta is the meta-data of an artifact
|
||||
type ActionArtifactMeta struct {
|
||||
ArtifactName string
|
||||
FileSize int64
|
||||
Status ArtifactStatus
|
||||
ExpiredUnix timeutil.TimeStamp
|
||||
}
|
||||
|
||||
// ListUploadedArtifactsMeta returns all uploaded artifacts meta of a run
|
||||
func ListUploadedArtifactsMeta(ctx context.Context, repoID, runID int64) ([]*ActionArtifactMeta, error) {
|
||||
// ListUploadedArtifactsMetaByRunAttempt returns uploaded artifacts meta scoped to a specific run and attempt.
|
||||
// Pass runAttemptID=0 to target legacy artifacts (pre-v331) belonging to the run.
|
||||
func ListUploadedArtifactsMetaByRunAttempt(ctx context.Context, repoID, runID, runAttemptID int64) ([]*ActionArtifactMeta, error) {
|
||||
arts := make([]*ActionArtifactMeta, 0, 10)
|
||||
return arts, db.GetEngine(ctx).Table("action_artifact").
|
||||
Where("repo_id=? AND run_id=? AND (status=? OR status=?)", repoID, runID, ArtifactStatusUploadConfirmed, ArtifactStatusExpired).
|
||||
Where("repo_id=? AND run_id=? AND run_attempt_id=? AND (status=? OR status=?)", repoID, runID, runAttemptID, ArtifactStatusUploadConfirmed, ArtifactStatusExpired).
|
||||
GroupBy("artifact_name").
|
||||
Select("artifact_name, sum(file_size) as file_size, max(status) as status").
|
||||
Select("artifact_name, sum(file_size) as file_size, max(status) as status, max(expired_unix) as expired_unix").
|
||||
Find(&arts)
|
||||
}
|
||||
|
||||
@@ -216,12 +246,29 @@ func SetArtifactExpired(ctx context.Context, artifactID int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// SetArtifactNeedDelete sets an artifact to need-delete, cron job will delete it
|
||||
func SetArtifactNeedDelete(ctx context.Context, runID int64, name string) error {
|
||||
_, err := db.GetEngine(ctx).Where("run_id=? AND artifact_name=? AND status = ?", runID, name, ArtifactStatusUploadConfirmed).Cols("status").Update(&ActionArtifact{Status: ArtifactStatusPendingDeletion})
|
||||
// SetArtifactNeedDeleteByID sets an artifact to need-delete by ID, cron job will delete it.
|
||||
func SetArtifactNeedDeleteByID(ctx context.Context, artifactID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("id=? AND status = ?", artifactID, ArtifactStatusUploadConfirmed).Cols("status").Update(&ActionArtifact{Status: ArtifactStatusPendingDeletion})
|
||||
return err
|
||||
}
|
||||
|
||||
// SetArtifactNeedDeleteByRunAttempt sets an artifact to need-delete in a run attempt, cron job will delete it.
|
||||
// runAttemptID may be 0 for legacy artifacts created before ActionRunAttempt existed.
|
||||
func SetArtifactNeedDeleteByRunAttempt(ctx context.Context, runID, runAttemptID int64, name string) error {
|
||||
_, err := db.GetEngine(ctx).Where("run_id=? AND run_attempt_id=? AND artifact_name=? AND status = ?", runID, runAttemptID, name, ArtifactStatusUploadConfirmed).Cols("status").Update(&ActionArtifact{Status: ArtifactStatusPendingDeletion})
|
||||
return err
|
||||
}
|
||||
|
||||
// GetArtifactsByRunAttemptAndName returns all artifacts with the given name in the specified run attempt.
|
||||
// This supports both attempt-scoped data and legacy artifacts with run_attempt_id=0.
|
||||
func GetArtifactsByRunAttemptAndName(ctx context.Context, runID, runAttemptID int64, artifactName string) ([]*ActionArtifact, error) {
|
||||
arts := make([]*ActionArtifact, 0)
|
||||
return arts, db.GetEngine(ctx).
|
||||
Where("run_id = ? AND run_attempt_id = ? AND artifact_name = ?", runID, runAttemptID, artifactName).
|
||||
OrderBy("id").
|
||||
Find(&arts)
|
||||
}
|
||||
|
||||
// SetArtifactDeleted sets an artifact to deleted
|
||||
func SetArtifactDeleted(ctx context.Context, artifactID int64) error {
|
||||
_, err := db.GetEngine(ctx).ID(artifactID).Cols("status").Update(&ActionArtifact{Status: ArtifactStatusDeleted})
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestKeepLatestAttemptArtifacts(t *testing.T) {
|
||||
arts := []*ActionArtifact{
|
||||
{ID: 1, RunAttemptID: 1, ArtifactName: "inherited"},
|
||||
{ID: 2, RunAttemptID: 1, ArtifactName: "shadowed", ArtifactPath: "a.txt"},
|
||||
{ID: 3, RunAttemptID: 1, ArtifactName: "shadowed", ArtifactPath: "b.txt"},
|
||||
{ID: 4, RunAttemptID: 2, ArtifactName: "shadowed", ArtifactPath: "c.txt"},
|
||||
{ID: 5, RunAttemptID: 2, ArtifactName: "own"},
|
||||
}
|
||||
|
||||
// the whole "shadowed" group of attempt 1 is dropped, its multi-file rows must not mix with attempt 2
|
||||
var ids []int64
|
||||
for _, art := range keepLatestAttemptArtifacts(arts) {
|
||||
ids = append(ids, art.ID)
|
||||
}
|
||||
assert.Equal(t, []int64{1, 4, 5}, ids)
|
||||
}
|
||||
@@ -6,11 +6,11 @@ package actions
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/models/perm"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/xorm/convert"
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ package actions
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
"gitea.dev/models/unittest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
@@ -15,6 +15,7 @@ func TestMain(m *testing.M) {
|
||||
"action_runner_token.yml",
|
||||
"action_run.yml",
|
||||
"repository.yml",
|
||||
"user.yml",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,20 +7,21 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/git"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
webhook_module "code.gitea.io/gitea/modules/webhook"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
@@ -29,7 +30,7 @@ import (
|
||||
type ActionRun struct {
|
||||
ID int64
|
||||
Title string
|
||||
RepoID int64 `xorm:"unique(repo_index) index(repo_concurrency)"`
|
||||
RepoID int64 `xorm:"unique(repo_index)"`
|
||||
Repo *repo_model.Repository `xorm:"-"`
|
||||
OwnerID int64 `xorm:"index"`
|
||||
WorkflowID string `xorm:"index"` // the name of workflow file
|
||||
@@ -49,15 +50,27 @@ type ActionRun struct {
|
||||
Status Status `xorm:"index"`
|
||||
Version int `xorm:"version default 0"` // Status could be updated concomitantly, so an optimistic lock is needed
|
||||
RawConcurrency string // raw concurrency
|
||||
ConcurrencyGroup string `xorm:"index(repo_concurrency) NOT NULL DEFAULT ''"`
|
||||
ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"`
|
||||
// Started and Stopped is used for recording last run time, if rerun happened, they will be reset to 0
|
||||
|
||||
// WorkflowRepoID/WorkflowCommitSHA record the (repo, commit) the run's workflow file content came from.
|
||||
// Always filled (repo-level run = the repo itself; scoped run = the source repo).
|
||||
WorkflowRepoID int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
WorkflowCommitSHA string `xorm:"VARCHAR(64) NOT NULL DEFAULT ''"`
|
||||
|
||||
IsScopedRun bool `xorm:"NOT NULL DEFAULT false"` // IsScopedRun explicitly classifies scoped runs.
|
||||
|
||||
// Started and Stopped are identical to the latest attempt after ActionRunAttempt was introduced.
|
||||
// When a rerun creates a new latest attempt, they are reset until the new attempt starts and stops.
|
||||
Started timeutil.TimeStamp
|
||||
Stopped timeutil.TimeStamp
|
||||
// PreviousDuration is used for recording previous duration
|
||||
|
||||
// PreviousDuration is kept only for legacy runs created before ActionRunAttempt existed.
|
||||
// New runs and reruns no longer update this field and use attempt-scoped durations instead.
|
||||
PreviousDuration time.Duration
|
||||
Created timeutil.TimeStamp `xorm:"created"`
|
||||
Updated timeutil.TimeStamp `xorm:"updated"`
|
||||
|
||||
LatestAttemptID int64 `xorm:"index NOT NULL DEFAULT 0"`
|
||||
|
||||
Created timeutil.TimeStamp `xorm:"created"`
|
||||
Updated timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -65,11 +78,11 @@ func init() {
|
||||
db.RegisterModel(new(ActionRunIndex))
|
||||
}
|
||||
|
||||
func (run *ActionRun) HTMLURL() string {
|
||||
func (run *ActionRun) HTMLURL(ctxOpt ...context.Context) string {
|
||||
if run.Repo == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s/actions/runs/%d", run.Repo.HTMLURL(), run.ID)
|
||||
return fmt.Sprintf("%s/actions/runs/%d", run.Repo.HTMLURL(ctxOpt...), run.ID)
|
||||
}
|
||||
|
||||
func (run *ActionRun) Link() string {
|
||||
@@ -83,7 +96,11 @@ func (run *ActionRun) WorkflowLink() string {
|
||||
if run.Repo == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s/actions/?workflow=%s", run.Repo.Link(), run.WorkflowID)
|
||||
// A scoped run's workflow is disambiguated by its source repo, so carry scoped_workflow_source_repo_id back to the run list
|
||||
if run.IsScopedRun {
|
||||
return fmt.Sprintf("%s/actions/?workflow=%s&scoped_workflow_source_repo_id=%d", run.Repo.Link(), url.QueryEscape(run.WorkflowID), run.WorkflowRepoID)
|
||||
}
|
||||
return fmt.Sprintf("%s/actions/?workflow=%s", run.Repo.Link(), url.QueryEscape(run.WorkflowID))
|
||||
}
|
||||
|
||||
// RefLink return the url of run's ref
|
||||
@@ -104,7 +121,7 @@ func (run *ActionRun) PrettyRef() string {
|
||||
return refName.ShortName()
|
||||
}
|
||||
|
||||
// RefTooltip return a tooltop of run's ref. For pull request, it's the title of the PR, otherwise it's the ShortName.
|
||||
// RefTooltip return a tooltip of run's ref. For pull request, it's the title of the PR, otherwise it's the ShortName.
|
||||
func (run *ActionRun) RefTooltip() string {
|
||||
payload, err := run.GetPullRequestEventPayload()
|
||||
if err == nil && payload != nil && payload.PullRequest != nil {
|
||||
@@ -114,11 +131,7 @@ func (run *ActionRun) RefTooltip() string {
|
||||
}
|
||||
|
||||
// LoadAttributes load Repo TriggerUser if not loaded
|
||||
func (run *ActionRun) LoadAttributes(ctx context.Context) (err error) {
|
||||
if run == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (run *ActionRun) LoadAttributes(ctx context.Context) error {
|
||||
if err := run.LoadRepo(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -127,18 +140,19 @@ func (run *ActionRun) LoadAttributes(ctx context.Context) (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
if run.TriggerUser == nil {
|
||||
run.TriggerUserID, run.TriggerUser, err = user_model.GetPossibleUserByID(ctx, run.TriggerUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return run.LoadTriggerUser(ctx)
|
||||
}
|
||||
|
||||
return nil
|
||||
func (run *ActionRun) LoadTriggerUser(ctx context.Context) (err error) {
|
||||
if run.TriggerUser != nil {
|
||||
return nil
|
||||
}
|
||||
run.TriggerUserID, run.TriggerUser, err = user_model.GetPossibleUserByID(ctx, run.TriggerUserID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (run *ActionRun) LoadRepo(ctx context.Context) error {
|
||||
if run == nil || run.Repo != nil {
|
||||
if run.Repo != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -158,6 +172,31 @@ func (run *ActionRun) Duration() time.Duration {
|
||||
return d
|
||||
}
|
||||
|
||||
// GetLatestAttempt returns
|
||||
// - the latest attempt of the run
|
||||
// - (nil, false, nil) for legacy runs that have no attempt records
|
||||
func (run *ActionRun) GetLatestAttempt(ctx context.Context) (*ActionRunAttempt, bool, error) {
|
||||
if run.LatestAttemptID == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
attempt, err := GetRunAttemptByRepoAndID(ctx, run.RepoID, run.LatestAttemptID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return attempt, true, nil
|
||||
}
|
||||
|
||||
func (run *ActionRun) GetEffectiveConcurrency(ctx context.Context) (string, bool, error) {
|
||||
attempt, has, err := run.GetLatestAttempt(ctx)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if has {
|
||||
return attempt.ConcurrencyGroup, attempt.ConcurrencyCancel, nil
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func (run *ActionRun) GetPushEventPayload() (*api.PushPayload, error) {
|
||||
if run.Event == webhook_module.HookEventPush {
|
||||
var payload api.PushPayload
|
||||
@@ -226,96 +265,6 @@ func UpdateRepoRunsNumbers(ctx context.Context, repoID int64) {
|
||||
}
|
||||
}
|
||||
|
||||
// CancelPreviousJobs cancels all previous jobs of the same repository, reference, workflow, and event.
|
||||
// It's useful when a new run is triggered, and all previous runs needn't be continued anymore.
|
||||
func CancelPreviousJobs(ctx context.Context, repoID int64, ref, workflowID string, event webhook_module.HookEventType) ([]*ActionRunJob, error) {
|
||||
// Find all runs in the specified repository, reference, and workflow with non-final status
|
||||
runs, total, err := db.FindAndCount[ActionRun](ctx, FindRunOptions{
|
||||
RepoID: repoID,
|
||||
Ref: ref,
|
||||
WorkflowID: workflowID,
|
||||
TriggerEvent: event,
|
||||
Status: []Status{StatusRunning, StatusWaiting, StatusBlocked},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If there are no runs found, there's no need to proceed with cancellation, so return nil.
|
||||
if total == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
cancelledJobs := make([]*ActionRunJob, 0, total)
|
||||
|
||||
// Iterate over each found run and cancel its associated jobs.
|
||||
for _, run := range runs {
|
||||
// Find all jobs associated with the current run.
|
||||
jobs, err := db.Find[ActionRunJob](ctx, FindRunJobOptions{
|
||||
RunID: run.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return cancelledJobs, err
|
||||
}
|
||||
|
||||
cjs, err := CancelJobs(ctx, jobs)
|
||||
if err != nil {
|
||||
return cancelledJobs, err
|
||||
}
|
||||
cancelledJobs = append(cancelledJobs, cjs...)
|
||||
}
|
||||
|
||||
// Return nil to indicate successful cancellation of all running and waiting jobs.
|
||||
return cancelledJobs, nil
|
||||
}
|
||||
|
||||
func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, error) {
|
||||
cancelledJobs := make([]*ActionRunJob, 0, len(jobs))
|
||||
// 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.).
|
||||
status := job.Status
|
||||
if status.IsDone() {
|
||||
continue
|
||||
}
|
||||
|
||||
// If the job has no associated task (probably an error), set its status to 'Cancelled' and stop it.
|
||||
if job.TaskID == 0 {
|
||||
job.Status = StatusCancelled
|
||||
job.Stopped = timeutil.TimeStampNow()
|
||||
|
||||
// Update the job's status and stopped time in the database.
|
||||
n, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}, "status", "stopped")
|
||||
if err != nil {
|
||||
return cancelledJobs, err
|
||||
}
|
||||
|
||||
// If the update affected 0 rows, it means the job has changed in the meantime
|
||||
if n == 0 {
|
||||
log.Error("Failed to cancel job %d because it has changed", job.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
cancelledJobs = append(cancelledJobs, job)
|
||||
// Continue with the next job.
|
||||
continue
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return cancelledJobs, err
|
||||
}
|
||||
updatedJob, err := GetRunJobByRunAndID(ctx, job.RunID, job.ID)
|
||||
if err != nil {
|
||||
return cancelledJobs, fmt.Errorf("get job: %w", err)
|
||||
}
|
||||
cancelledJobs = append(cancelledJobs, updatedJob)
|
||||
}
|
||||
|
||||
// Return nil to indicate successful cancellation of all running and waiting jobs.
|
||||
return cancelledJobs, nil
|
||||
}
|
||||
|
||||
func GetRunByRepoAndID(ctx context.Context, repoID, runID int64) (*ActionRun, error) {
|
||||
var run ActionRun
|
||||
has, err := db.GetEngine(ctx).Where("id=? AND repo_id=?", runID, repoID).Get(&run)
|
||||
@@ -329,11 +278,7 @@ func GetRunByRepoAndID(ctx context.Context, repoID, runID int64) (*ActionRun, er
|
||||
}
|
||||
|
||||
func GetRunByRepoAndIndex(ctx context.Context, repoID, runIndex int64) (*ActionRun, error) {
|
||||
run := &ActionRun{
|
||||
RepoID: repoID,
|
||||
Index: runIndex,
|
||||
}
|
||||
has, err := db.GetEngine(ctx).Get(run)
|
||||
run, has, err := db.Get[ActionRun](ctx, builder.Eq{"repo_id": repoID, "`index`": runIndex})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
@@ -344,9 +289,7 @@ func GetRunByRepoAndIndex(ctx context.Context, repoID, runIndex int64) (*ActionR
|
||||
}
|
||||
|
||||
func GetLatestRun(ctx context.Context, repoID int64) (*ActionRun, error) {
|
||||
run := &ActionRun{
|
||||
RepoID: repoID,
|
||||
}
|
||||
run := &ActionRun{}
|
||||
has, err := db.GetEngine(ctx).Where("repo_id=?", repoID).Desc("index").Get(run)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -360,7 +303,10 @@ func GetWorkflowLatestRun(ctx context.Context, repoID int64, workflowFile, branc
|
||||
var run ActionRun
|
||||
q := db.GetEngine(ctx).Where("repo_id=?", repoID).
|
||||
And("ref = ?", branch).
|
||||
And("workflow_id = ?", workflowFile)
|
||||
And("workflow_id = ?", workflowFile).
|
||||
// TODO: the badge only reflects the repo's own (repo-level) runs; a same-named scoped run must not leak in.
|
||||
// Support a scoped-workflow badge later by making this source-aware.
|
||||
And("is_scoped_run = ?", false)
|
||||
if event != "" {
|
||||
q.And("event = ?", event)
|
||||
}
|
||||
@@ -396,14 +342,11 @@ func UpdateRun(ctx context.Context, run *ActionRun, cols ...string) error {
|
||||
|
||||
type ActionRunIndex db.ResourceIndex
|
||||
|
||||
func GetConcurrentRunsAndJobs(ctx context.Context, repoID int64, concurrencyGroup string, status []Status) ([]*ActionRun, []*ActionRunJob, error) {
|
||||
runs, err := db.Find[ActionRun](ctx, &FindRunOptions{
|
||||
RepoID: repoID,
|
||||
ConcurrencyGroup: concurrencyGroup,
|
||||
Status: status,
|
||||
})
|
||||
// GetConcurrentRunAttemptsAndJobs returns run attempts and jobs in the same concurrency group by statuses.
|
||||
func GetConcurrentRunAttemptsAndJobs(ctx context.Context, repoID int64, concurrencyGroup string, status []Status) ([]*ActionRunAttempt, []*ActionRunJob, error) {
|
||||
attempts, err := FindConcurrentRunAttempts(ctx, repoID, concurrencyGroup, status)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("find runs: %w", err)
|
||||
return nil, nil, fmt.Errorf("find run attempts: %w", err)
|
||||
}
|
||||
|
||||
jobs, err := db.Find[ActionRunJob](ctx, &FindRunJobOptions{
|
||||
@@ -415,36 +358,35 @@ func GetConcurrentRunsAndJobs(ctx context.Context, repoID int64, concurrencyGrou
|
||||
return nil, nil, fmt.Errorf("find jobs: %w", err)
|
||||
}
|
||||
|
||||
return runs, jobs, nil
|
||||
return attempts, jobs, nil
|
||||
}
|
||||
|
||||
func CancelPreviousJobsByRunConcurrency(ctx context.Context, actionRun *ActionRun) ([]*ActionRunJob, error) {
|
||||
if actionRun.ConcurrencyGroup == "" {
|
||||
func CancelPreviousJobsByRunConcurrency(ctx context.Context, attempt *ActionRunAttempt) ([]*ActionRunJob, error) {
|
||||
if attempt.ConcurrencyGroup == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var jobsToCancel []*ActionRunJob
|
||||
|
||||
statusFindOption := []Status{StatusWaiting, StatusBlocked}
|
||||
if actionRun.ConcurrencyCancel {
|
||||
if attempt.ConcurrencyCancel {
|
||||
statusFindOption = append(statusFindOption, StatusRunning)
|
||||
statusFindOption = append(statusFindOption, StatusCancelling)
|
||||
}
|
||||
runs, jobs, err := GetConcurrentRunsAndJobs(ctx, actionRun.RepoID, actionRun.ConcurrencyGroup, statusFindOption)
|
||||
attempts, jobs, err := GetConcurrentRunAttemptsAndJobs(ctx, attempt.RepoID, attempt.ConcurrencyGroup, statusFindOption)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find concurrent runs and jobs: %w", err)
|
||||
}
|
||||
jobsToCancel = append(jobsToCancel, jobs...)
|
||||
|
||||
// cancel runs in the same concurrency group
|
||||
for _, run := range runs {
|
||||
if run.ID == actionRun.ID {
|
||||
for _, concurrentAttempt := range attempts {
|
||||
if concurrentAttempt.RunID == attempt.RunID {
|
||||
continue
|
||||
}
|
||||
jobs, err := db.Find[ActionRunJob](ctx, FindRunJobOptions{
|
||||
RunID: run.ID,
|
||||
})
|
||||
jobs, err := GetRunJobsByRunAndAttemptID(ctx, concurrentAttempt.RunID, concurrentAttempt.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find run %d jobs: %w", run.ID, err)
|
||||
return nil, fmt.Errorf("find run %d attempt %d jobs: %w", concurrentAttempt.RunID, concurrentAttempt.ID, err)
|
||||
}
|
||||
jobsToCancel = append(jobsToCancel, jobs...)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// ActionRunAttempt represents a single execution attempt of an ActionRun.
|
||||
type ActionRunAttempt struct {
|
||||
ID int64
|
||||
RepoID int64 `xorm:"index(repo_concurrency_status)"`
|
||||
RunID int64 `xorm:"UNIQUE(run_attempt)"`
|
||||
Run *ActionRun `xorm:"-"`
|
||||
Attempt int64 `xorm:"UNIQUE(run_attempt)"`
|
||||
|
||||
TriggerUserID int64
|
||||
TriggerUser *user_model.User `xorm:"-"`
|
||||
|
||||
ConcurrencyGroup string `xorm:"index(repo_concurrency_status) NOT NULL DEFAULT ''"`
|
||||
ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"`
|
||||
|
||||
Status Status `xorm:"index(repo_concurrency_status)"`
|
||||
Started timeutil.TimeStamp
|
||||
Stopped timeutil.TimeStamp
|
||||
|
||||
Created timeutil.TimeStamp `xorm:"created"`
|
||||
Updated timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
func (*ActionRunAttempt) TableName() string {
|
||||
return "action_run_attempt"
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(ActionRunAttempt))
|
||||
}
|
||||
|
||||
func (attempt *ActionRunAttempt) Duration() time.Duration {
|
||||
return calculateDuration(attempt.Started, attempt.Stopped, attempt.Status, attempt.Updated)
|
||||
}
|
||||
|
||||
func (attempt *ActionRunAttempt) LoadAttributes(ctx context.Context) (err error) {
|
||||
if attempt.Run == nil {
|
||||
run, err := GetRunByRepoAndID(ctx, attempt.RepoID, attempt.RunID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := run.LoadAttributes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
attempt.Run = run
|
||||
}
|
||||
|
||||
return attempt.LoadTriggerUser(ctx)
|
||||
}
|
||||
|
||||
// LoadTriggerUser loads the attempt's trigger user if not already loaded.
|
||||
func (attempt *ActionRunAttempt) LoadTriggerUser(ctx context.Context) (err error) {
|
||||
if attempt.TriggerUser != nil {
|
||||
return nil
|
||||
}
|
||||
attempt.TriggerUserID, attempt.TriggerUser, err = user_model.GetPossibleUserByID(ctx, attempt.TriggerUserID)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetRunAttemptByRepoAndID(ctx context.Context, repoID, attemptID int64) (*ActionRunAttempt, error) {
|
||||
var attempt ActionRunAttempt
|
||||
has, err := db.GetEngine(ctx).Where("repo_id=? AND id=?", repoID, attemptID).Get(&attempt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, fmt.Errorf("run attempt %d in repo %d: %w", attemptID, repoID, util.ErrNotExist)
|
||||
}
|
||||
return &attempt, nil
|
||||
}
|
||||
|
||||
func GetRunAttemptByRunIDAndAttemptNum(ctx context.Context, runID, attemptNum int64) (*ActionRunAttempt, error) {
|
||||
var attempt ActionRunAttempt
|
||||
has, err := db.GetEngine(ctx).Where("run_id=? AND attempt=?", runID, attemptNum).Get(&attempt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, fmt.Errorf("run attempt %d for run %d: %w", attemptNum, runID, util.ErrNotExist)
|
||||
}
|
||||
return &attempt, nil
|
||||
}
|
||||
|
||||
// GetArtifactAttemptIDs returns the IDs of the attempts whose artifacts the job may read, newest first,
|
||||
// always including the job's own attempt.
|
||||
// An attempt that re-ran only some of the run's jobs keeps the artifacts of the attempt it re-ran from,
|
||||
// because the jobs it passed through never upload them again; a rerun of the whole run starts over.
|
||||
func GetArtifactAttemptIDs(ctx context.Context, job *ActionRunJob) ([]int64, error) {
|
||||
if job.Attempt <= 1 || job.RunAttemptID == 0 {
|
||||
return []int64{job.RunAttemptID}, nil
|
||||
}
|
||||
|
||||
attempts, err := ListRunAttemptsByRunID(ctx, job.RunID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// a newer attempt is never readable, and attempt 1 has nothing older to continue into
|
||||
candidateIDs := container.FilterSlice(attempts, func(a *ActionRunAttempt) (int64, bool) {
|
||||
return a.ID, a.Attempt > 1 && a.Attempt <= job.Attempt
|
||||
})
|
||||
passThroughAttemptIDs, err := findPassThroughAttemptIDs(ctx, candidateIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]int64, 0, len(attempts))
|
||||
for _, attempt := range attempts {
|
||||
if attempt.Attempt > job.Attempt {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, attempt.ID)
|
||||
if !slices.Contains(passThroughAttemptIDs, attempt.ID) {
|
||||
// stops at the first attempt that passed no job through
|
||||
break
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// findPassThroughAttemptIDs narrows the given attempts to those that were a rerun of selected jobs:
|
||||
// only such a rerun clones jobs carrying a source task.
|
||||
// TODO: best-effort. Needs a better way to distinguish between "partial re-run" and "full re-run".
|
||||
func findPassThroughAttemptIDs(ctx context.Context, attemptIDs []int64) ([]int64, error) {
|
||||
passThroughAttemptIDs := make([]int64, 0, len(attemptIDs))
|
||||
return passThroughAttemptIDs, db.GetEngine(ctx).
|
||||
Table("action_run_job").
|
||||
Cols("run_attempt_id").
|
||||
In("run_attempt_id", attemptIDs).
|
||||
Where("source_task_id <> 0").
|
||||
Distinct("run_attempt_id").
|
||||
Find(&passThroughAttemptIDs)
|
||||
}
|
||||
|
||||
// FindConcurrentRunAttempts returns attempts in the given concurrency group and status set.
|
||||
// Results are unordered; callers must not depend on any particular row order.
|
||||
func FindConcurrentRunAttempts(ctx context.Context, repoID int64, concurrencyGroup string, statuses []Status) ([]*ActionRunAttempt, error) {
|
||||
attempts := make([]*ActionRunAttempt, 0)
|
||||
sess := db.GetEngine(ctx).Where("repo_id=? AND concurrency_group=?", repoID, concurrencyGroup)
|
||||
if len(statuses) > 0 {
|
||||
sess = sess.In("status", statuses)
|
||||
}
|
||||
return attempts, sess.Find(&attempts)
|
||||
}
|
||||
|
||||
func UpdateRunAttempt(ctx context.Context, attempt *ActionRunAttempt, cols ...string) error {
|
||||
if slices.Contains(cols, "status") && attempt.Started.IsZero() && attempt.Status.IsRunning() {
|
||||
attempt.Started = timeutil.TimeStampNow()
|
||||
cols = append(cols, "started")
|
||||
}
|
||||
|
||||
sess := db.GetEngine(ctx).ID(attempt.ID)
|
||||
if len(cols) > 0 {
|
||||
sess.Cols(cols...)
|
||||
}
|
||||
if _, err := sess.Update(attempt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Only status/timing changes on an attempt need to update the latest run.
|
||||
if len(cols) > 0 && !slices.Contains(cols, "status") && !slices.Contains(cols, "started") && !slices.Contains(cols, "stopped") {
|
||||
return nil
|
||||
}
|
||||
|
||||
run, err := GetRunByRepoAndID(ctx, attempt.RepoID, attempt.RunID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if run.LatestAttemptID != attempt.ID {
|
||||
log.Warn("run %d cannot be updated by an old attempt %d", run.LatestAttemptID, attempt.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
run.Status = attempt.Status
|
||||
run.Started = attempt.Started
|
||||
run.Stopped = attempt.Stopped
|
||||
return UpdateRun(ctx, run, "status", "started", "stopped")
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
)
|
||||
|
||||
type ActionRunAttemptList []*ActionRunAttempt
|
||||
|
||||
// GetUserIDs returns a slice of user's id
|
||||
func (attempts ActionRunAttemptList) GetUserIDs() []int64 {
|
||||
return container.FilterSlice(attempts, func(attempt *ActionRunAttempt) (int64, bool) {
|
||||
return attempt.TriggerUserID, true
|
||||
})
|
||||
}
|
||||
|
||||
func (attempts ActionRunAttemptList) LoadTriggerUser(ctx context.Context) error {
|
||||
userIDs := attempts.GetUserIDs()
|
||||
users := make(map[int64]*user_model.User, len(userIDs))
|
||||
if err := db.GetEngine(ctx).In("id", userIDs).Find(&users); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, attempt := range attempts {
|
||||
if attempt.TriggerUserID == user_model.ActionsUserID {
|
||||
attempt.TriggerUser = user_model.NewActionsUser()
|
||||
} else {
|
||||
attempt.TriggerUser = users[attempt.TriggerUserID]
|
||||
if attempt.TriggerUser == nil {
|
||||
attempt.TriggerUser = user_model.NewGhostUser()
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListRunAttemptsByRunID returns all attempts of a run, ordered by attempt number DESC (newest first).
|
||||
func ListRunAttemptsByRunID(ctx context.Context, runID int64) (ActionRunAttemptList, error) {
|
||||
var attempts ActionRunAttemptList
|
||||
return attempts, db.GetEngine(ctx).Where("run_id=?", runID).OrderBy("attempt DESC").Find(&attempts)
|
||||
}
|
||||
@@ -4,16 +4,19 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/modules/actions/jobparser"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
@@ -34,7 +37,10 @@ type ActionRunJob struct {
|
||||
CommitSHA string `xorm:"index"`
|
||||
IsForkPullRequest bool
|
||||
Name string `xorm:"VARCHAR(255)"`
|
||||
Attempt int64
|
||||
|
||||
// for legacy jobs, this counts how many times the job has run;
|
||||
// otherwise it matches the Attempt of the ActionRunAttempt identified by job.RunAttemptID
|
||||
Attempt int64
|
||||
|
||||
// WorkflowPayload is act/jobparser.SingleWorkflow for act/jobparser.Parse
|
||||
// it should contain exactly one job with global workflow fields for this model
|
||||
@@ -43,8 +49,11 @@ type ActionRunJob struct {
|
||||
JobID string `xorm:"VARCHAR(255)"` // job id in workflow, not job's id
|
||||
Needs []string `xorm:"JSON TEXT"`
|
||||
RunsOn []string `xorm:"JSON TEXT"`
|
||||
TaskID int64 // the latest task of the job
|
||||
Status Status `xorm:"index"`
|
||||
|
||||
TaskID int64 // the task created by this job in its own attempt
|
||||
SourceTaskID int64 `xorm:"NOT NULL DEFAULT 0"` // SourceTaskID points to a historical task when this job reuses an earlier attempt's result.
|
||||
|
||||
Status Status `xorm:"index"`
|
||||
|
||||
RawConcurrency string // raw concurrency from job YAML's "concurrency" section
|
||||
|
||||
@@ -61,20 +70,80 @@ type ActionRunJob struct {
|
||||
// It is JSON-encoded repo_model.ActionsTokenPermissions and may be empty if not specified.
|
||||
TokenPermissions *repo_model.ActionsTokenPermissions `xorm:"JSON TEXT"`
|
||||
|
||||
// RunAttemptID identifies the ActionRunAttempt this job belongs to.
|
||||
// A value of 0 indicates a legacy job created before ActionRunAttempt existed.
|
||||
RunAttemptID int64 `xorm:"index NOT NULL DEFAULT 0"`
|
||||
// AttemptJobID is unique within a single attempt.
|
||||
// For jobs created after ActionRunAttempt was introduced, the same logical job is expected to keep the same AttemptJobID across attempts.
|
||||
// A value of 0 indicates a legacy job created before ActionRunAttempt existed.
|
||||
AttemptJobID int64 `xorm:"index NOT NULL DEFAULT 0"`
|
||||
|
||||
// WorkflowSourceRepoID + WorkflowSourceCommitSHA record the (repo, commit) this job's containing workflow file came from.
|
||||
WorkflowSourceRepoID int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
WorkflowSourceCommitSHA string `xorm:"VARCHAR(64) NOT NULL DEFAULT ''"`
|
||||
|
||||
// IsReusableCaller marks this job as a reusable workflow caller.
|
||||
// Caller jobs do not run on a runner; their status is derived from their child jobs.
|
||||
IsReusableCaller bool `xorm:"index NOT NULL DEFAULT FALSE"`
|
||||
// IsExpanded reports whether this job's lazy expansion (children-row insertion) is complete.
|
||||
// For a reusable workflow caller, true means children rows exist and CallPayload is populated.
|
||||
IsExpanded bool `xorm:"NOT NULL DEFAULT FALSE"`
|
||||
// CallUses stores the raw "uses:" string of a reusable workflow caller job.
|
||||
// Only set when IsReusableCaller is true.
|
||||
CallUses string `xorm:"VARCHAR(512) NOT NULL DEFAULT ''"`
|
||||
// ReusableWorkflowContent is the content of the reusable workflow specified by "uses:".
|
||||
// Only set when IsReusableCaller is true.
|
||||
ReusableWorkflowContent []byte `xorm:"LONGBLOB"`
|
||||
// CallSecrets encodes the reusable workflow caller's "secrets:" section:
|
||||
// - "" : no "secrets:" section (children only see auto-generated tokens).
|
||||
// - "inherit" : the caller wrote "secrets: inherit".
|
||||
// - JSON object : explicit mapping {alias: source_name}; names only, no values.
|
||||
// Only set when IsReusableCaller is true.
|
||||
CallSecrets string `xorm:"LONGTEXT"`
|
||||
// CallPayload is the JSON-encoded WorkflowCallPayload exposed to children as gitea.event.
|
||||
// Populated atomically with IsExpanded at the end of expandReusableWorkflowCaller.
|
||||
// Only set when IsReusableCaller is true.
|
||||
CallPayload string `xorm:"LONGTEXT"`
|
||||
|
||||
// ParentJobID scopes `Needs` resolution: name lookups happen only among rows sharing the same ParentJobID. 0 for top-level rows.
|
||||
ParentJobID int64 `xorm:"index NOT NULL DEFAULT 0"`
|
||||
|
||||
// ContinueOnError mirrors the job-level continue-on-error field from the workflow YAML.
|
||||
// When true, a failure of this job does not fail the overall workflow run.
|
||||
ContinueOnError bool `xorm:"NOT NULL DEFAULT FALSE"`
|
||||
|
||||
Started timeutil.TimeStamp
|
||||
Stopped timeutil.TimeStamp
|
||||
Created timeutil.TimeStamp `xorm:"created"`
|
||||
Updated timeutil.TimeStamp `xorm:"updated index"`
|
||||
}
|
||||
|
||||
// ActionRunAttemptJobIDIndex backs the run-wide AttemptJobID counter, keyed by ActionRun.ID.
|
||||
// Use GetNextAttemptJobID to allocate the next ID for a run.
|
||||
type ActionRunAttemptJobIDIndex db.ResourceIndex
|
||||
|
||||
// GetNextAttemptJobID atomically allocates the next AttemptJobID for a job in the given run.
|
||||
// AttemptJobIDs are unique within a single attempt and stable across attempts for the same logical job
|
||||
func GetNextAttemptJobID(ctx context.Context, runID int64) (int64, error) {
|
||||
return db.GetNextResourceIndex(ctx, "action_run_attempt_job_id_index", runID)
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(ActionRunJob))
|
||||
db.RegisterModel(new(ActionRunAttemptJobIDIndex))
|
||||
}
|
||||
|
||||
func (job *ActionRunJob) Duration() time.Duration {
|
||||
return calculateDuration(job.Started, job.Stopped, job.Status, job.Updated)
|
||||
}
|
||||
|
||||
func (job *ActionRunJob) EffectiveTaskID() int64 {
|
||||
if job.TaskID > 0 {
|
||||
return job.TaskID
|
||||
}
|
||||
return job.SourceTaskID
|
||||
}
|
||||
|
||||
func (job *ActionRunJob) LoadRun(ctx context.Context) error {
|
||||
if job.Run == nil {
|
||||
run, err := GetRunByRepoAndID(ctx, job.RepoID, job.RunID)
|
||||
@@ -99,10 +168,6 @@ func (job *ActionRunJob) LoadRepo(ctx context.Context) error {
|
||||
|
||||
// LoadAttributes load Run if not loaded
|
||||
func (job *ActionRunJob) LoadAttributes(ctx context.Context) error {
|
||||
if job == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := job.LoadRun(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -152,14 +217,166 @@ func GetRunJobByRunAndID(ctx context.Context, runID, jobID int64) (*ActionRunJob
|
||||
return &job, nil
|
||||
}
|
||||
|
||||
func GetRunJobsByRunID(ctx context.Context, runID int64) (ActionJobList, error) {
|
||||
func GetRunJobByAttemptJobID(ctx context.Context, runID, attemptID, attemptJobID int64) (*ActionRunJob, error) {
|
||||
var job ActionRunJob
|
||||
has, err := db.GetEngine(ctx).Where("run_id=? AND run_attempt_id=? AND attempt_job_id=?", runID, attemptID, attemptJobID).Get(&job)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, fmt.Errorf("run job with attempt_job_id %d in run %d attempt %d: %w", attemptJobID, runID, attemptID, util.ErrNotExist)
|
||||
}
|
||||
|
||||
return &job, nil
|
||||
}
|
||||
|
||||
// GetLatestAttemptJobsByRepoAndRunID returns the jobs of the latest attempt for a run.
|
||||
// It prefers the latest attempt when one exists, and falls back to legacy jobs with run_attempt_id=0 for runs created before ActionRunAttempt existed.
|
||||
func GetLatestAttemptJobsByRepoAndRunID(ctx context.Context, repoID, runID int64) (ActionJobList, error) {
|
||||
run, err := GetRunByRepoAndID(ctx, repoID, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if run.LatestAttemptID > 0 {
|
||||
return GetRunJobsByRunAndAttemptID(ctx, runID, run.LatestAttemptID)
|
||||
}
|
||||
|
||||
var jobs []*ActionRunJob
|
||||
if err := db.GetEngine(ctx).Where("run_id=?", runID).OrderBy("id").Find(&jobs); err != nil {
|
||||
if err := db.GetEngine(ctx).Where("repo_id=? AND run_id=? AND run_attempt_id=0", repoID, runID).OrderBy("id").Find(&jobs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
// GetAllRunJobsByRepoAndRunID returns all jobs for a run across all attempts.
|
||||
func GetAllRunJobsByRepoAndRunID(ctx context.Context, repoID, runID int64) (ActionJobList, error) {
|
||||
var jobs []*ActionRunJob
|
||||
if err := db.GetEngine(ctx).Where("repo_id=? AND run_id=?", repoID, runID).OrderBy("id").Find(&jobs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
// GetRunJobsByRunAndAttemptID returns jobs for a run within a specific attempt.
|
||||
// runAttemptID may be 0 to address legacy jobs that were created before ActionRunAttempt existed and therefore have no attempt association.
|
||||
func GetRunJobsByRunAndAttemptID(ctx context.Context, runID, runAttemptID int64) (ActionJobList, error) {
|
||||
var jobs []*ActionRunJob
|
||||
if err := db.GetEngine(ctx).Where("run_id=? AND run_attempt_id=?", runID, runAttemptID).OrderBy("id").Find(&jobs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
// GetPriorAttemptChildrenByParent returns the children of the most recent prior attempt where
|
||||
// the parent (identified by parentAttemptJobID) actually had children, indexed by child JobID then child Name.
|
||||
// Returns (nil, nil) when no such attempt exists.
|
||||
// The (JobID, Name) key disambiguates both reusable-workflow subtrees and matrix-expanded instances (whose Name carries the matrix suffix).
|
||||
func GetPriorAttemptChildrenByParent(ctx context.Context, runID, currentAttemptID, parentAttemptJobID int64) (map[string]map[string]*ActionRunJob, error) {
|
||||
// query every prior caller row sharing this AttemptJobID, newest first.
|
||||
var priorCallers []*ActionRunJob
|
||||
if err := db.GetEngine(ctx).
|
||||
Where("run_id = ? AND attempt_job_id = ? AND run_attempt_id < ?", runID, parentAttemptJobID, currentAttemptID).
|
||||
Desc("run_attempt_id").
|
||||
Find(&priorCallers); err != nil {
|
||||
return nil, fmt.Errorf("find prior callers: %w", err)
|
||||
}
|
||||
if len(priorCallers) == 0 {
|
||||
return nil, nil //nolint:nilnil // caller is brand new in this attempt
|
||||
}
|
||||
|
||||
// query for every child of every prior caller
|
||||
callerIDs := make([]int64, len(priorCallers))
|
||||
for i, c := range priorCallers {
|
||||
callerIDs[i] = c.ID
|
||||
}
|
||||
var allChildren []*ActionRunJob
|
||||
if err := db.GetEngine(ctx).
|
||||
Where("run_id = ?", runID).
|
||||
In("parent_job_id", callerIDs).
|
||||
Find(&allChildren); err != nil {
|
||||
return nil, fmt.Errorf("find prior children: %w", err)
|
||||
}
|
||||
|
||||
childrenByCallerID := make(map[int64][]*ActionRunJob, len(callerIDs))
|
||||
for _, c := range allChildren {
|
||||
childrenByCallerID[c.ParentJobID] = append(childrenByCallerID[c.ParentJobID], c)
|
||||
}
|
||||
|
||||
// Walk priorCallers in run_attempt_id-desc order and return the children of the first caller that actually had any.
|
||||
// Skipped attempts (caller exists but no children) are bypassed.
|
||||
for _, caller := range priorCallers {
|
||||
children := childrenByCallerID[caller.ID]
|
||||
if len(children) == 0 {
|
||||
continue
|
||||
}
|
||||
out := make(map[string]map[string]*ActionRunJob)
|
||||
for _, c := range children {
|
||||
if out[c.JobID] == nil {
|
||||
out[c.JobID] = make(map[string]*ActionRunJob)
|
||||
}
|
||||
out[c.JobID][c.Name] = c
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
return nil, nil //nolint:nilnil // every prior attempt skipped this caller
|
||||
}
|
||||
|
||||
// GetDirectChildJobsByParent returns the direct child jobs of a parent job (e.g. a reusable workflow caller).
|
||||
func GetDirectChildJobsByParent(ctx context.Context, parentJob *ActionRunJob) (ActionJobList, error) {
|
||||
var jobs []*ActionRunJob
|
||||
if err := db.GetEngine(ctx).
|
||||
Where("run_id=? AND parent_job_id=?", parentJob.RunID, parentJob.ID).
|
||||
OrderBy("id").
|
||||
Find(&jobs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
// DeleteDirectChildJobsByParent deletes the direct child jobs of a parent job.
|
||||
func DeleteDirectChildJobsByParent(ctx context.Context, parentJob *ActionRunJob) error {
|
||||
_, err := db.GetEngine(ctx).
|
||||
Where("run_id=? AND parent_job_id=?", parentJob.RunID, parentJob.ID).
|
||||
Delete(new(ActionRunJob))
|
||||
return err
|
||||
}
|
||||
|
||||
// CollectAllDescendantJobs returns every job in `allJobs` that lives under parent's subtree (recursively), excluding `parent` itself
|
||||
func CollectAllDescendantJobs(parent *ActionRunJob, allJobs []*ActionRunJob) []*ActionRunJob {
|
||||
parents := map[int64]bool{parent.ID: true}
|
||||
for {
|
||||
grew := false
|
||||
for _, j := range allJobs {
|
||||
if j.ParentJobID == 0 {
|
||||
continue
|
||||
}
|
||||
if parents[j.ParentJobID] && !parents[j.ID] {
|
||||
parents[j.ID] = true
|
||||
grew = true
|
||||
}
|
||||
}
|
||||
if !grew {
|
||||
break
|
||||
}
|
||||
}
|
||||
out := make([]*ActionRunJob, 0)
|
||||
for _, j := range allJobs {
|
||||
if j.ID == parent.ID || !parents[j.ID] {
|
||||
continue
|
||||
}
|
||||
out = append(out, j)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hasWaitingJobsToPick reports whether any waiting, unclaimed, non-reusable job
|
||||
// remains in the repo, i.e. work that an idle runner could still pick up.
|
||||
func hasWaitingJobsToPick(ctx context.Context, repoID int64) (bool, error) {
|
||||
return db.GetEngine(ctx).
|
||||
Where("repo_id = ? AND task_id = ? AND status = ? AND is_reusable_caller = ?", repoID, 0, StatusWaiting, false).
|
||||
Exist(&ActionRunJob{})
|
||||
}
|
||||
|
||||
func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, cols ...string) (int64, error) {
|
||||
e := db.GetEngine(ctx)
|
||||
|
||||
@@ -177,17 +394,13 @@ 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
|
||||
}
|
||||
|
||||
if slices.Contains(cols, "status") && job.Status.IsWaiting() {
|
||||
// if the status of job changes to waiting again, increase tasks version.
|
||||
if err := IncreaseTaskVersion(ctx, job.OwnerID, job.RepoID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
if job.RunID == 0 {
|
||||
var err error
|
||||
if job, err = GetRunJobByRepoAndID(ctx, job.RepoID, job.ID); err != nil {
|
||||
@@ -195,41 +408,165 @@ 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)
|
||||
// Reusable workflow caller jobs are never picked up by runners, so they don't need a task-version bump.
|
||||
if statusUpdated && !job.IsReusableCaller {
|
||||
switch {
|
||||
case job.Status.IsWaiting():
|
||||
// A job returning to the waiting queue is work a runner can pick up, so bump the
|
||||
// version to wake idle runners whose tasksVersion already equals latestVersion.
|
||||
if err := IncreaseTaskVersion(ctx, job.OwnerID, job.RepoID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
case job.Status.IsDone():
|
||||
// When a job finishes, bump the version so that idle runners — whose
|
||||
// tasksVersion already equals the current latestVersion — learn that
|
||||
// remaining waiting jobs are still available and attempt PickTask again.
|
||||
// Without this bump, runners that completed their tasks would see
|
||||
// tasksVersion==latestVersion and skip PickTask, leaving the other jobs
|
||||
// permanently unassigned until the version changes for another reason.
|
||||
// Only bump when waiting work actually remains for this repo, otherwise
|
||||
// every job completion would needlessly bump the global version and wake
|
||||
// every idle runner instance-wide for nothing.
|
||||
hasWaiting, err := hasWaitingJobsToPick(ctx, job.RepoID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if hasWaiting {
|
||||
if err := IncreaseTaskVersion(ctx, job.OwnerID, job.RepoID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if statusUpdated && job.ParentJobID > 0 {
|
||||
// Reusable workflow caller's children cascade their status changes upward to the parent caller.
|
||||
parent, err := GetRunJobByRunAndID(ctx, job.RunID, job.ParentJobID)
|
||||
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)
|
||||
return affected, fmt.Errorf("load parent caller %d: %w", job.ParentJobID, err)
|
||||
}
|
||||
return affected, RefreshReusableCallerStatus(ctx, parent)
|
||||
}
|
||||
|
||||
if err := refreshRunStatus(ctx, job.RepoID, job.RunID, job.RunAttemptID, StatusUnknown); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
// refreshRunStatus recomputes the status of an attempt from the jobs currently stored and persists it.
|
||||
// The latest attempt propagates its status to its run, an older one only updates itself.
|
||||
// noJobsStatus settles an attempt without any job, which AggregateJobStatus cannot conclude on its own.
|
||||
func refreshRunStatus(ctx context.Context, repoID, runID, runAttemptID int64, noJobsStatus Status) error {
|
||||
// Other goroutines may aggregate the status of the attempt/run and update it too.
|
||||
// So we need to load the current jobs before updating the aggregate state.
|
||||
if runAttemptID > 0 {
|
||||
attempt, err := GetRunAttemptByRepoAndID(ctx, repoID, runAttemptID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jobs, err := GetRunJobsByRunAndAttemptID(ctx, runID, runAttemptID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attempt.Status = AggregateJobStatus(jobs)
|
||||
if len(jobs) == 0 {
|
||||
attempt.Status = noJobsStatus
|
||||
}
|
||||
if attempt.Started.IsZero() && attempt.Status.IsRunning() {
|
||||
attempt.Started = timeutil.TimeStampNow()
|
||||
}
|
||||
if attempt.Stopped.IsZero() && attempt.Status.IsDone() {
|
||||
attempt.Stopped = timeutil.TimeStampNow()
|
||||
}
|
||||
if err := UpdateRunAttempt(ctx, attempt, "status", "started", "stopped"); err != nil {
|
||||
return fmt.Errorf("update run attempt %d: %w", attempt.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: Remove this fallback in the future.
|
||||
// Legacy fallback: jobs created before migration v331 have RunAttemptID=0 and are NOT backfilled.
|
||||
// This path keeps those runs' status consistent when their jobs finish, including:
|
||||
// - jobs created before migration v331 and complete on the new version starts
|
||||
// - zombie/abandoned cleanup cron tasks that call UpdateRunJob on legacy jobs
|
||||
// - cancelling a legacy run whose jobs are all already done
|
||||
run, err := GetRunByRepoAndID(ctx, repoID, runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jobs, err := GetLatestAttemptJobsByRepoAndRunID(ctx, repoID, runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
run.Status = AggregateJobStatus(jobs)
|
||||
if len(jobs) == 0 {
|
||||
run.Status = noJobsStatus
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// RefreshReusableCallerStatus recomputes a reusable workflow caller's Status, Started and Stopped from its current direct children and persists the change.
|
||||
// No-op if caller is not a reusable caller.
|
||||
//
|
||||
// Concurrency: two sibling children finishing at roughly the same time can each invoke this for the same parent caller.
|
||||
// No row-level lock is taken because AggregateJobStatus is a pure function of the children's statuses (order-independent), so racing callers arrive at the same Status.
|
||||
func RefreshReusableCallerStatus(ctx context.Context, caller *ActionRunJob) error {
|
||||
if !caller.IsReusableCaller {
|
||||
return nil
|
||||
}
|
||||
children, err := GetDirectChildJobsByParent(ctx, caller)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newStatus := AggregateJobStatus(children)
|
||||
cols := make([]string, 0, 3)
|
||||
if caller.Status != newStatus {
|
||||
caller.Status = newStatus
|
||||
cols = append(cols, "status")
|
||||
}
|
||||
if newStatus != StatusSkipped {
|
||||
now := timeutil.TimeStampNow()
|
||||
if caller.Started.IsZero() && newStatus == StatusRunning {
|
||||
caller.Started = now
|
||||
cols = append(cols, "started")
|
||||
}
|
||||
if caller.Stopped.IsZero() && newStatus.IsDone() {
|
||||
caller.Stopped = now
|
||||
cols = append(cols, "stopped")
|
||||
}
|
||||
}
|
||||
if len(cols) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err = UpdateRunJob(ctx, caller, nil, cols...)
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
// A failed job with continue-on-error:true does not fail the workflow run.
|
||||
// It counts as a "continued failure" and is treated like success for aggregation.
|
||||
isContinuedFailure := job.ContinueOnError && job.Status == StatusFailure
|
||||
allSuccessOrSkipped = allSuccessOrSkipped && (job.Status == StatusSuccess || job.Status == StatusSkipped || isContinuedFailure)
|
||||
allSkipped = allSkipped && job.Status == StatusSkipped
|
||||
hasFailure = hasFailure || job.Status == StatusFailure
|
||||
hasFailure = hasFailure || (job.Status == StatusFailure && !job.ContinueOnError)
|
||||
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
|
||||
@@ -239,21 +576,68 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
// CancelPreviousJobs cancels all previous jobs of the same repository, reference, workflow, and event.
|
||||
// It's useful when a new run is triggered, and all previous runs needn't be continued anymore.
|
||||
func CancelPreviousJobs(ctx context.Context, repoID int64, ref, workflowID string, event webhook_module.HookEventType) ([]*ActionRunJob, error) {
|
||||
// Find all runs in the specified repository, reference, and workflow with non-final status
|
||||
runs, total, err := db.FindAndCount[ActionRun](ctx, FindRunOptions{
|
||||
RepoID: repoID,
|
||||
Ref: ref,
|
||||
WorkflowID: workflowID,
|
||||
TriggerEvent: event,
|
||||
Status: []Status{StatusRunning, StatusWaiting, StatusBlocked, StatusCancelling},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If there are no runs found, there's no need to proceed with cancellation, so return nil.
|
||||
if total == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
cancelledJobs := make([]*ActionRunJob, 0, total)
|
||||
|
||||
// Iterate over each found run and cancel its associated jobs.
|
||||
for _, run := range runs {
|
||||
// Find all jobs associated with the current run.
|
||||
jobs, err := db.Find[ActionRunJob](ctx, FindRunJobOptions{
|
||||
RunID: run.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return cancelledJobs, err
|
||||
}
|
||||
|
||||
cjs, err := CancelJobs(ctx, jobs)
|
||||
if err != nil {
|
||||
return cancelledJobs, err
|
||||
}
|
||||
cancelledJobs = append(cancelledJobs, cjs...)
|
||||
}
|
||||
|
||||
// Return nil to indicate successful cancellation of all running and waiting jobs.
|
||||
return cancelledJobs, nil
|
||||
}
|
||||
|
||||
func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob) (jobsToCancel []*ActionRunJob, _ error) {
|
||||
if job.RawConcurrency == "" {
|
||||
return nil, nil
|
||||
@@ -268,8 +652,9 @@ func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob)
|
||||
statusFindOption := []Status{StatusWaiting, StatusBlocked}
|
||||
if job.ConcurrencyCancel {
|
||||
statusFindOption = append(statusFindOption, StatusRunning)
|
||||
statusFindOption = append(statusFindOption, StatusCancelling)
|
||||
}
|
||||
runs, jobs, err := GetConcurrentRunsAndJobs(ctx, job.RepoID, job.ConcurrencyGroup, statusFindOption)
|
||||
attempts, jobs, err := GetConcurrentRunAttemptsAndJobs(ctx, job.RepoID, job.ConcurrencyGroup, statusFindOption)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find concurrent runs and jobs: %w", err)
|
||||
}
|
||||
@@ -277,15 +662,115 @@ func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob)
|
||||
jobsToCancel = append(jobsToCancel, jobs...)
|
||||
|
||||
// cancel runs in the same concurrency group
|
||||
for _, run := range runs {
|
||||
jobs, err := db.Find[ActionRunJob](ctx, FindRunJobOptions{
|
||||
RunID: run.ID,
|
||||
})
|
||||
for _, attempt := range attempts {
|
||||
if attempt.ID == job.RunAttemptID {
|
||||
continue
|
||||
}
|
||||
jobs, err := GetRunJobsByRunAndAttemptID(ctx, attempt.RunID, attempt.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find run %d jobs: %w", run.ID, err)
|
||||
return nil, fmt.Errorf("find run %d attempt %d jobs: %w", attempt.RunID, attempt.ID, err)
|
||||
}
|
||||
jobsToCancel = append(jobsToCancel, jobs...)
|
||||
}
|
||||
|
||||
return CancelJobs(ctx, jobsToCancel)
|
||||
}
|
||||
|
||||
// CancelJobs cancels every cancellable job it is given. It leaves the status of a run it
|
||||
// cancelled nothing in untouched, SettleRunAfterCancel is what gives such a run a final one.
|
||||
func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, error) {
|
||||
cancelledJobs := make([]*ActionRunJob, 0, len(jobs))
|
||||
|
||||
for _, job := range jobs {
|
||||
if job.IsReusableCaller {
|
||||
sub, err := cancelReusableCaller(ctx, job)
|
||||
if err != nil {
|
||||
return cancelledJobs, err
|
||||
}
|
||||
cancelledJobs = append(cancelledJobs, sub...)
|
||||
continue
|
||||
}
|
||||
|
||||
c, err := cancelOneJob(ctx, job)
|
||||
if err != nil {
|
||||
return cancelledJobs, err
|
||||
}
|
||||
if c != nil {
|
||||
cancelledJobs = append(cancelledJobs, c)
|
||||
}
|
||||
}
|
||||
return cancelledJobs, nil
|
||||
}
|
||||
|
||||
// SettleRunAfterCancel gives a run a final status when cancelling it updated no job at all.
|
||||
// A run's status is otherwise only ever written as a side effect of a job update, so a run whose
|
||||
// jobs are all done already, or that has no job at all, would stay unfinished forever.
|
||||
func SettleRunAfterCancel(ctx context.Context, run *ActionRun) error {
|
||||
if run.Status.IsDone() {
|
||||
return nil
|
||||
}
|
||||
return refreshRunStatus(ctx, run.RepoID, run.ID, run.LatestAttemptID, StatusCancelled)
|
||||
}
|
||||
|
||||
// cancelOneJob cancels a single job and returns the post-cancel row
|
||||
func cancelOneJob(ctx context.Context, job *ActionRunJob) (*ActionRunJob, error) {
|
||||
if job.Status.IsDone() {
|
||||
return nil, nil //nolint:nilnil // signal "nothing to cancel; not an error"
|
||||
}
|
||||
// No associated task: mark Cancelled directly. This includes reusable callers and jobs that never reached PickTask.
|
||||
if job.TaskID == 0 {
|
||||
job.Status = StatusCancelled
|
||||
job.Stopped = timeutil.TimeStampNow()
|
||||
n, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}, "status", "stopped")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == 0 {
|
||||
log.Error("Failed to cancel job %d because it has changed", job.ID)
|
||||
return nil, nil //nolint:nilnil // signal "nothing to cancel; not an error"
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
// Has a task: stop the task and re-read the row.
|
||||
if err := StopTask(ctx, job.TaskID, StatusCancelling); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated, err := GetRunJobByRunAndID(ctx, job.RunID, job.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get job: %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// cancelReusableCaller cancels `caller` and all its child jobs
|
||||
func cancelReusableCaller(ctx context.Context, caller *ActionRunJob) ([]*ActionRunJob, error) {
|
||||
cancelledJobs := make([]*ActionRunJob, 0)
|
||||
|
||||
attemptJobs, err := GetRunJobsByRunAndAttemptID(ctx, caller.RunID, caller.RunAttemptID)
|
||||
if err != nil {
|
||||
return cancelledJobs, err
|
||||
}
|
||||
|
||||
// Cancel descendants deepest-first, then the caller: a caller's status is aggregated from its children,
|
||||
// so each child must reach its final state before its parent caller is re-aggregated.
|
||||
// A child's ID always exceeds its parent's, so descending ID is a valid deepest-first order.
|
||||
descendants := CollectAllDescendantJobs(caller, attemptJobs)
|
||||
slices.SortFunc(descendants, func(a, b *ActionRunJob) int { return cmp.Compare(b.ID, a.ID) })
|
||||
|
||||
for _, c := range descendants {
|
||||
cancelled, err := cancelOneJob(ctx, c)
|
||||
if err != nil {
|
||||
return cancelledJobs, err
|
||||
}
|
||||
if cancelled != nil {
|
||||
cancelledJobs = append(cancelledJobs, cancelled)
|
||||
}
|
||||
}
|
||||
|
||||
if c, err := cancelOneJob(ctx, caller); err != nil {
|
||||
return cancelledJobs, err
|
||||
} else if c != nil {
|
||||
cancelledJobs = append(cancelledJobs, c)
|
||||
}
|
||||
return cancelledJobs, nil
|
||||
}
|
||||
|
||||
@@ -5,11 +5,14 @@ package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
@@ -22,6 +25,22 @@ func (jobs ActionJobList) GetRunIDs() []int64 {
|
||||
})
|
||||
}
|
||||
|
||||
// SortMatrixGroupsByName natural-sorts each contiguous run of jobs that share a JobID
|
||||
// so matrix expansions (e.g. "test (1)", "test (2)", "test (10)") appear in human order.
|
||||
// Input is expected to be in DB id order so JobID groups are contiguous; cross-group order is preserved.
|
||||
func (jobs ActionJobList) SortMatrixGroupsByName() {
|
||||
for i := 0; i < len(jobs); {
|
||||
j := i + 1
|
||||
for j < len(jobs) && jobs[j].JobID == jobs[i].JobID {
|
||||
j++
|
||||
}
|
||||
slices.SortFunc(jobs[i:j], func(a, b *ActionRunJob) int {
|
||||
return base.NaturalSortCompare(a.Name, b.Name)
|
||||
})
|
||||
i = j
|
||||
}
|
||||
}
|
||||
|
||||
func (jobs ActionJobList) LoadRepos(ctx context.Context) error {
|
||||
repoIDs := container.FilterSlice(jobs, func(j *ActionRunJob) (int64, bool) {
|
||||
return j.RepoID, j.RepoID != 0 && j.Repo == nil
|
||||
@@ -55,8 +74,10 @@ func (jobs ActionJobList) LoadRuns(ctx context.Context, withRepo bool) error {
|
||||
return err
|
||||
}
|
||||
for _, j := range jobs {
|
||||
if j.RunID > 0 && j.Run == nil {
|
||||
if j.Run == nil {
|
||||
j.Run = runs[j.RunID]
|
||||
}
|
||||
if j.Run != nil {
|
||||
j.Run.Repo = j.Repo
|
||||
}
|
||||
}
|
||||
@@ -70,12 +91,23 @@ func (jobs ActionJobList) LoadAttributes(ctx context.Context, withRepo bool) err
|
||||
type FindRunJobOptions struct {
|
||||
db.ListOptions
|
||||
RunID int64
|
||||
RunAttemptID optional.Option[int64] // use optional to allow filtering by zero (legacy jobs have run_attempt_id=0)
|
||||
RepoID int64
|
||||
OwnerID int64
|
||||
CommitSHA string
|
||||
Statuses []Status
|
||||
UpdatedBefore timeutil.TimeStamp
|
||||
ConcurrencyGroup string
|
||||
OrderBy db.SearchOrderBy
|
||||
// AccessibleRepoIDsSubQuery, when non-nil, restricts results to the repo IDs selected by the
|
||||
// subquery (the caller's accessible repos). A nil value means no restriction. Using a subquery
|
||||
// instead of a materialized ID slice avoids exceeding DB parameter limits for large owners.
|
||||
AccessibleRepoIDsSubQuery *builder.Builder
|
||||
}
|
||||
|
||||
var JobOrderByMap = map[string]map[string]db.SearchOrderBy{
|
||||
"asc": {"id": "`action_run_job`.id ASC"},
|
||||
"desc": {"id": "`action_run_job`.id DESC"},
|
||||
}
|
||||
|
||||
func (opts FindRunJobOptions) ToConds() builder.Cond {
|
||||
@@ -83,6 +115,9 @@ func (opts FindRunJobOptions) ToConds() builder.Cond {
|
||||
if opts.RunID > 0 {
|
||||
cond = cond.And(builder.Eq{"`action_run_job`.run_id": opts.RunID})
|
||||
}
|
||||
if opts.RunAttemptID.Has() {
|
||||
cond = cond.And(builder.Eq{"`action_run_job`.run_attempt_id": opts.RunAttemptID.Value()})
|
||||
}
|
||||
if opts.RepoID > 0 {
|
||||
cond = cond.And(builder.Eq{"`action_run_job`.repo_id": opts.RepoID})
|
||||
}
|
||||
@@ -101,6 +136,9 @@ func (opts FindRunJobOptions) ToConds() builder.Cond {
|
||||
}
|
||||
cond = cond.And(builder.Eq{"`action_run_job`.concurrency_group": opts.ConcurrencyGroup})
|
||||
}
|
||||
if opts.AccessibleRepoIDsSubQuery != nil {
|
||||
cond = cond.And(builder.In("`action_run_job`.repo_id", opts.AccessibleRepoIDsSubQuery))
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
@@ -115,3 +153,18 @@ func (opts FindRunJobOptions) ToJoins() []db.JoinFunc {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (opts FindRunJobOptions) ToOrders() string {
|
||||
return string(opts.OrderBy)
|
||||
}
|
||||
|
||||
var _ db.FindOptionsOrder = FindRunJobOptions{}
|
||||
|
||||
// CountRunJobsByRunAndAttemptID counts the jobs belonging to the given run attempt.
|
||||
// It is used to enforce MaxJobNumPerRun when reusable-workflow expansion inserts new jobs.
|
||||
func CountRunJobsByRunAndAttemptID(ctx context.Context, runID, runAttemptID int64) (int64, error) {
|
||||
return db.Count[ActionRunJob](ctx, FindRunJobOptions{
|
||||
RunID: runID,
|
||||
RunAttemptID: optional.Some(runAttemptID),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestActionJobList_SortMatrixGroupsByName(t *testing.T) {
|
||||
mk := func(jobID, name string) *ActionRunJob {
|
||||
return &ActionRunJob{JobID: jobID, Name: name}
|
||||
}
|
||||
names := func(jobs ActionJobList) []string {
|
||||
out := make([]string, len(jobs))
|
||||
for i, j := range jobs {
|
||||
out[i] = j.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
t.Run("matrix group sorted naturally", func(t *testing.T) {
|
||||
jobs := ActionJobList{
|
||||
mk("build", "build"),
|
||||
mk("test", "test (10)"),
|
||||
mk("test", "test (2)"),
|
||||
mk("test", "test (1)"),
|
||||
mk("deploy", "deploy"),
|
||||
}
|
||||
jobs.SortMatrixGroupsByName()
|
||||
assert.Equal(t, []string{"build", "test (1)", "test (2)", "test (10)", "deploy"}, names(jobs))
|
||||
})
|
||||
|
||||
t.Run("non-adjacent same JobID stays in input order", func(t *testing.T) {
|
||||
jobs := ActionJobList{
|
||||
mk("test", "test (10)"),
|
||||
mk("build", "build"),
|
||||
mk("test", "test (1)"),
|
||||
}
|
||||
jobs.SortMatrixGroupsByName()
|
||||
assert.Equal(t, []string{"test (10)", "build", "test (1)"}, names(jobs))
|
||||
})
|
||||
|
||||
t.Run("groups stay in input order", func(t *testing.T) {
|
||||
jobs := ActionJobList{
|
||||
mk("z", "z"),
|
||||
mk("a", "a"),
|
||||
}
|
||||
jobs.SortMatrixGroupsByName()
|
||||
assert.Equal(t, []string{"z", "a"}, names(jobs))
|
||||
})
|
||||
|
||||
t.Run("empty and singleton", func(t *testing.T) {
|
||||
ActionJobList(nil).SortMatrixGroupsByName()
|
||||
jobs := ActionJobList{mk("only", "only")}
|
||||
jobs.SortMatrixGroupsByName()
|
||||
assert.Equal(t, []string{"only"}, names(jobs))
|
||||
})
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
const (
|
||||
// JobSummaryCapability is the runner-declare capability string for job summaries.
|
||||
JobSummaryCapability = "job-summary"
|
||||
|
||||
// JobSummaryContentTypeMarkdown is the only accepted content type for job summaries.
|
||||
JobSummaryContentTypeMarkdown = "text/markdown"
|
||||
|
||||
// MaxJobSummarySize is the maximum accepted per-step summary payload size in bytes.
|
||||
MaxJobSummarySize = 1024 * 1024 // 1 MiB
|
||||
|
||||
// MaxJobSummaryAggregateSize is the maximum aggregate size of all step summaries within
|
||||
// a single job attempt. Matches GitHub's documented per-job summary cap of 1 MiB.
|
||||
MaxJobSummaryAggregateSize = 1024 * 1024 // 1 MiB
|
||||
)
|
||||
|
||||
// RunnerCapabilities returns the value advertised in the X-Gitea-Actions-Capabilities header.
|
||||
// When more capabilities are added, return them comma-separated so runners can split on ", ".
|
||||
func RunnerCapabilities() string {
|
||||
return JobSummaryCapability
|
||||
}
|
||||
|
||||
type ActionRunJobSummary struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
|
||||
RepoID int64 `xorm:"UNIQUE(summary_key)"`
|
||||
RunID int64 `xorm:"UNIQUE(summary_key)"`
|
||||
RunAttemptID int64 `xorm:"UNIQUE(summary_key) NOT NULL DEFAULT 0"`
|
||||
JobID int64 `xorm:"UNIQUE(summary_key)"`
|
||||
StepIndex int64 `xorm:"UNIQUE(summary_key)"`
|
||||
|
||||
Content string `xorm:"LONGTEXT"`
|
||||
ContentType string `xorm:"VARCHAR(255) NOT NULL DEFAULT 'text/markdown'"`
|
||||
// ContentSize is the byte length of Content. Stored explicitly because LENGTH()
|
||||
// counts characters (not bytes) on PostgreSQL, SQLite and MSSQL, which would let
|
||||
// multibyte UTF-8 content bypass the aggregate cap.
|
||||
ContentSize int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
|
||||
Created timeutil.TimeStamp `xorm:"created"`
|
||||
Updated timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(ActionRunJobSummary))
|
||||
}
|
||||
|
||||
func GetActionRunJobSummary(ctx context.Context, repoID, runID, runAttemptID, jobID, stepIndex int64) (*ActionRunJobSummary, error) {
|
||||
var s ActionRunJobSummary
|
||||
has, err := db.GetEngine(ctx).
|
||||
Where("repo_id=? AND run_id=? AND run_attempt_id=? AND job_id=? AND step_index=?", repoID, runID, runAttemptID, jobID, stepIndex).
|
||||
Get(&s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, util.ErrNotExist
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// ErrJobSummaryAggregateExceeded is returned when a step summary upload would push the
|
||||
// aggregate size of summaries for a single job attempt over MaxJobSummaryAggregateSize.
|
||||
var ErrJobSummaryAggregateExceeded = util.NewInvalidArgumentErrorf("job summary aggregate size exceeded")
|
||||
|
||||
func UpsertActionRunJobSummary(ctx context.Context, repoID, runID, runAttemptID, jobID, stepIndex int64, contentType string, content []byte) error {
|
||||
if runID <= 0 || jobID <= 0 || repoID <= 0 || stepIndex < 0 {
|
||||
return util.ErrInvalidArgument
|
||||
}
|
||||
if len(content) == 0 {
|
||||
// Treat empty summaries as no-op; runner may create SUMMARY.md but never write to it.
|
||||
return nil
|
||||
}
|
||||
if len(content) > MaxJobSummarySize {
|
||||
return util.ErrInvalidArgument
|
||||
}
|
||||
if contentType != JobSummaryContentTypeMarkdown {
|
||||
return util.ErrInvalidArgument
|
||||
}
|
||||
|
||||
// The aggregate check is best-effort: a tx wouldn't actually serialize concurrent
|
||||
// step uploads (no row-level lock on the parent job), so wrapping these two
|
||||
// statements only adds round-trip cost without changing the race semantics.
|
||||
// The current step is excluded because the upsert below replaces its size with len(content).
|
||||
otherSize, err := sumOtherJobSummarySizes(ctx, repoID, runID, runAttemptID, jobID, stepIndex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if otherSize+int64(len(content)) > MaxJobSummaryAggregateSize {
|
||||
return ErrJobSummaryAggregateExceeded
|
||||
}
|
||||
|
||||
now := timeutil.TimeStampNow()
|
||||
return upsertActionRunJobSummary(ctx, &ActionRunJobSummary{
|
||||
RepoID: repoID,
|
||||
RunID: runID,
|
||||
RunAttemptID: runAttemptID,
|
||||
JobID: jobID,
|
||||
StepIndex: stepIndex,
|
||||
Content: string(content),
|
||||
ContentSize: int64(len(content)),
|
||||
ContentType: contentType,
|
||||
Created: now,
|
||||
Updated: now,
|
||||
})
|
||||
}
|
||||
|
||||
// sumOtherJobSummarySizes returns the total stored size of all step summaries for a job
|
||||
// except excludeStepIndex, computed in the database to avoid loading every row.
|
||||
func sumOtherJobSummarySizes(ctx context.Context, repoID, runID, runAttemptID, jobID, excludeStepIndex int64) (int64, error) {
|
||||
return db.GetEngine(ctx).
|
||||
Where("repo_id=? AND run_id=? AND run_attempt_id=? AND job_id=? AND step_index<>?", repoID, runID, runAttemptID, jobID, excludeStepIndex).
|
||||
SumInt(new(ActionRunJobSummary), "content_size")
|
||||
}
|
||||
|
||||
// DeleteActionRunJobSummary removes the stored summary for a specific step. Used when
|
||||
// a runner PUTs an empty body to clear a previously-uploaded step summary.
|
||||
func DeleteActionRunJobSummary(ctx context.Context, repoID, runID, runAttemptID, jobID, stepIndex int64) error {
|
||||
_, err := db.GetEngine(ctx).
|
||||
Where("repo_id=? AND run_id=? AND run_attempt_id=? AND job_id=? AND step_index=?", repoID, runID, runAttemptID, jobID, stepIndex).
|
||||
Delete(new(ActionRunJobSummary))
|
||||
return err
|
||||
}
|
||||
|
||||
func upsertActionRunJobSummary(ctx context.Context, summary *ActionRunJobSummary) error {
|
||||
engine := db.GetEngine(ctx)
|
||||
columns := "`repo_id`, `run_id`, `run_attempt_id`, `job_id`, `step_index`, `content`, `content_type`, `content_size`, `created`, `updated`"
|
||||
values := []any{
|
||||
summary.RepoID,
|
||||
summary.RunID,
|
||||
summary.RunAttemptID,
|
||||
summary.JobID,
|
||||
summary.StepIndex,
|
||||
summary.Content,
|
||||
summary.ContentType,
|
||||
summary.ContentSize,
|
||||
summary.Created,
|
||||
summary.Updated,
|
||||
}
|
||||
|
||||
if setting.Database.Type.IsPostgreSQL() || setting.Database.Type.IsSQLite3() {
|
||||
args := append([]any{"INSERT INTO `action_run_job_summary` (" + columns + ") VALUES (?,?,?,?,?,?,?,?,?,?) " +
|
||||
"ON CONFLICT (`repo_id`, `run_id`, `run_attempt_id`, `job_id`, `step_index`) DO UPDATE SET " +
|
||||
"`content` = excluded.`content`, `content_type` = excluded.`content_type`, `content_size` = excluded.`content_size`, `updated` = excluded.`updated`"}, values...)
|
||||
_, err := engine.Exec(args...)
|
||||
return err
|
||||
}
|
||||
|
||||
if setting.Database.Type.IsMySQL() {
|
||||
args := append([]any{
|
||||
"INSERT INTO `action_run_job_summary` (" + columns + ") VALUES (?,?,?,?,?,?,?,?,?,?) " +
|
||||
"ON DUPLICATE KEY UPDATE `content` = VALUES(`content`), `content_type` = VALUES(`content_type`), `content_size` = VALUES(`content_size`), `updated` = VALUES(`updated`)",
|
||||
}, values...)
|
||||
_, err := engine.Exec(args...)
|
||||
return err
|
||||
}
|
||||
|
||||
if setting.Database.Type.IsMSSQL() {
|
||||
_, err := engine.Exec(`
|
||||
MERGE INTO action_run_job_summary WITH (HOLDLOCK) AS target
|
||||
USING (SELECT ? AS repo_id, ? AS run_id, ? AS run_attempt_id, ? AS job_id, ? AS step_index) AS source
|
||||
ON target.repo_id = source.repo_id
|
||||
AND target.run_id = source.run_id
|
||||
AND target.run_attempt_id = source.run_attempt_id
|
||||
AND target.job_id = source.job_id
|
||||
AND target.step_index = source.step_index
|
||||
WHEN MATCHED THEN
|
||||
UPDATE SET content = ?, content_type = ?, content_size = ?, updated = ?
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (repo_id, run_id, run_attempt_id, job_id, step_index, content, content_type, content_size, created, updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
`,
|
||||
summary.RepoID, summary.RunID, summary.RunAttemptID, summary.JobID, summary.StepIndex,
|
||||
summary.Content, summary.ContentType, summary.ContentSize, summary.Updated,
|
||||
summary.RepoID, summary.RunID, summary.RunAttemptID, summary.JobID, summary.StepIndex, summary.Content, summary.ContentType, summary.ContentSize, summary.Created, summary.Updated)
|
||||
return err
|
||||
}
|
||||
|
||||
return util.ErrInvalidArgument
|
||||
}
|
||||
|
||||
// ListActionRunJobSummaries lists the stored summaries for a run attempt, ordered by job
|
||||
// then step. A positive jobID scopes the lookup to that single job, used by the job view to
|
||||
// avoid rendering every job's summary on each poll; jobID<=0 returns all jobs in the attempt.
|
||||
func ListActionRunJobSummaries(ctx context.Context, repoID, runID, runAttemptID, jobID int64) ([]*ActionRunJobSummary, error) {
|
||||
sess := db.GetEngine(ctx).Where("repo_id=? AND run_id=? AND run_attempt_id=?", repoID, runID, runAttemptID)
|
||||
if jobID > 0 {
|
||||
sess = sess.And("job_id=?", jobID)
|
||||
}
|
||||
var summaries []*ActionRunJobSummary
|
||||
if err := sess.OrderBy("job_id ASC, step_index ASC").Find(&summaries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetPriorAttemptChildrenByParent(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
// 3 attempts of one run:
|
||||
// 1: caller expanded with 3 matrix instances of "work" + non-matrix sibling "summary".
|
||||
// 2: caller skipped, no children rows.
|
||||
// 3: placeholder "current" attempt for the walkback subtest.
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "prior-children-test",
|
||||
RepoID: 4,
|
||||
Index: 9501,
|
||||
OwnerID: 1,
|
||||
WorkflowID: "matrix.yaml",
|
||||
TriggerUserID: 1,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
EventPayload: "{}",
|
||||
Status: StatusSuccess,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, run))
|
||||
|
||||
const callerAttemptJobID int64 = 9001
|
||||
insertAttempt := func(t *testing.T, num int64, status Status) *ActionRunAttempt {
|
||||
t.Helper()
|
||||
a := &ActionRunAttempt{
|
||||
RepoID: run.RepoID,
|
||||
RunID: run.ID,
|
||||
Attempt: num,
|
||||
TriggerUserID: 1,
|
||||
Status: status,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, a))
|
||||
return a
|
||||
}
|
||||
insertCaller := func(t *testing.T, attemptID int64, status Status, expanded bool) *ActionRunJob {
|
||||
t.Helper()
|
||||
caller := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RunAttemptID: attemptID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "caller",
|
||||
JobID: "caller",
|
||||
Attempt: 1,
|
||||
Status: status,
|
||||
AttemptJobID: callerAttemptJobID,
|
||||
IsReusableCaller: true,
|
||||
IsExpanded: expanded,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, caller))
|
||||
return caller
|
||||
}
|
||||
insertChild := func(t *testing.T, attemptID, parentID, attemptJobID int64, name, jobID string) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Insert(ctx, &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RunAttemptID: attemptID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: name,
|
||||
JobID: jobID,
|
||||
Attempt: 1,
|
||||
Status: StatusSuccess,
|
||||
AttemptJobID: attemptJobID,
|
||||
ParentJobID: parentID,
|
||||
}))
|
||||
}
|
||||
|
||||
attempt1 := insertAttempt(t, 1, StatusSuccess)
|
||||
caller1 := insertCaller(t, attempt1.ID, StatusSuccess, true)
|
||||
insertChild(t, attempt1.ID, caller1.ID, 101, "work (alpha)", "work")
|
||||
insertChild(t, attempt1.ID, caller1.ID, 102, "work (beta)", "work")
|
||||
insertChild(t, attempt1.ID, caller1.ID, 103, "work (gamma)", "work")
|
||||
insertChild(t, attempt1.ID, caller1.ID, 104, "summary", "summary")
|
||||
|
||||
attempt2 := insertAttempt(t, 2, StatusSkipped)
|
||||
insertCaller(t, attempt2.ID, StatusSkipped, false) // no children intentionally
|
||||
|
||||
// both subtests expect attempt 1's expansion, differing only in the "current" attempt id
|
||||
assertAttempt1Children := func(t *testing.T, out map[string]map[string]*ActionRunJob) {
|
||||
t.Helper()
|
||||
// outer map keyed by JobID: "work" has 3 matrix instances, "summary" 1
|
||||
assert.Len(t, out, 2)
|
||||
assert.Len(t, out["work"], 3, "matrix instances must each get their own inner-map entry")
|
||||
assert.Len(t, out["summary"], 1)
|
||||
|
||||
require.NotNil(t, out["work"]["work (alpha)"])
|
||||
require.NotNil(t, out["work"]["work (beta)"])
|
||||
require.NotNil(t, out["work"]["work (gamma)"])
|
||||
require.NotNil(t, out["summary"]["summary"])
|
||||
|
||||
assert.Equal(t, int64(101), out["work"]["work (alpha)"].AttemptJobID)
|
||||
assert.Equal(t, int64(102), out["work"]["work (beta)"].AttemptJobID)
|
||||
assert.Equal(t, int64(103), out["work"]["work (gamma)"].AttemptJobID)
|
||||
assert.Equal(t, int64(104), out["summary"]["summary"].AttemptJobID)
|
||||
}
|
||||
|
||||
t.Run("matrix instances and non-matrix sibling are indexed by (JobID, Name)", func(t *testing.T) {
|
||||
// "current" = attempt 2; prior = attempt 1, which is the immediately preceding attempt.
|
||||
out, err := GetPriorAttemptChildrenByParent(ctx, run.ID, attempt2.ID, callerAttemptJobID)
|
||||
require.NoError(t, err)
|
||||
assertAttempt1Children(t, out)
|
||||
})
|
||||
|
||||
t.Run("walkback past an attempt where the caller had no children", func(t *testing.T) {
|
||||
attempt3 := insertAttempt(t, 3, StatusRunning)
|
||||
// "current" = attempt 3; the immediately preceding attempt 2 has no children, so the lookup must walk further back to attempt 1.
|
||||
out, err := GetPriorAttemptChildrenByParent(ctx, run.ID, attempt3.ID, callerAttemptJobID)
|
||||
require.NoError(t, err)
|
||||
assertAttempt1Children(t, out)
|
||||
})
|
||||
}
|
||||
|
||||
// A reusable caller subtree with a Blocked descendant (e.g. a nested caller stuck on an invalid `uses:`) must aggregate to Cancelled, when the run is cancelled.
|
||||
func TestCancelJobs_NestedBlockedReusableCaller(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "cancel-nested-caller",
|
||||
RepoID: 4,
|
||||
Index: 9701,
|
||||
OwnerID: 1,
|
||||
WorkflowID: "caller.yaml",
|
||||
TriggerUserID: 1,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
EventPayload: "{}",
|
||||
Status: StatusBlocked,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, run))
|
||||
|
||||
attempt := &ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: 1, Status: StatusBlocked}
|
||||
require.NoError(t, db.Insert(ctx, attempt))
|
||||
run.LatestAttemptID = attempt.ID
|
||||
require.NoError(t, UpdateRun(ctx, run, "latest_attempt_id"))
|
||||
|
||||
newJob := func(name string, attemptJobID, parentID int64, callUses string) *ActionRunJob {
|
||||
job := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RunAttemptID: attempt.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: name,
|
||||
JobID: name,
|
||||
Attempt: 1,
|
||||
Status: StatusBlocked,
|
||||
AttemptJobID: attemptJobID,
|
||||
IsReusableCaller: true,
|
||||
CallUses: callUses,
|
||||
ParentJobID: parentID,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, job))
|
||||
return job
|
||||
}
|
||||
|
||||
// outer: a valid top-level caller that expanded; inner: a nested caller stuck Blocked (invalid uses, never expands).
|
||||
outer := newJob("outer", 1, 0, "./.gitea/workflows/lib.yml")
|
||||
inner := newJob("inner", 2, outer.ID, "https://other.example.com/o/r/.gitea/workflows/ci.yml@v1")
|
||||
|
||||
// Cancel all jobs of the attempt, ordered by id (parent before child).
|
||||
jobs, err := GetRunJobsByRunAndAttemptID(ctx, run.ID, attempt.ID)
|
||||
require.NoError(t, err)
|
||||
_, err = CancelJobs(ctx, jobs)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, j := range []*ActionRunJob{outer, inner} {
|
||||
got := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: j.ID})
|
||||
assert.Equal(t, StatusCancelled, got.Status, "job %q should be cancelled", j.JobID)
|
||||
}
|
||||
gotAttempt := unittest.AssertExistsAndLoadBean(t, &ActionRunAttempt{ID: attempt.ID})
|
||||
assert.Equal(t, StatusCancelled, gotAttempt.Status, "attempt must aggregate to Cancelled")
|
||||
gotRun := unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID})
|
||||
assert.Equal(t, StatusCancelled, gotRun.Status, "run must aggregate to Cancelled, not stay Blocked")
|
||||
}
|
||||
|
||||
func TestSettleRunAfterCancel(t *testing.T) {
|
||||
// A run that cancelling updates no job in, because its jobs all reached a final status already
|
||||
// or because it has none at all. Its own row has to be settled explicitly, or the run can never
|
||||
// finish and can never be deleted either.
|
||||
|
||||
newStuckRun := func(t *testing.T, withAttempt, withJob bool) (*ActionRun, []*ActionRunJob) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "stuck-waiting",
|
||||
RepoID: 4,
|
||||
Index: 9801,
|
||||
OwnerID: 1,
|
||||
WorkflowID: "test.yaml",
|
||||
TriggerUserID: 1,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
EventPayload: "{}",
|
||||
Status: StatusWaiting,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, run))
|
||||
|
||||
var runAttemptID int64
|
||||
if withAttempt {
|
||||
attempt := &ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: 1, Status: StatusWaiting}
|
||||
require.NoError(t, db.Insert(ctx, attempt))
|
||||
run.LatestAttemptID = attempt.ID
|
||||
require.NoError(t, UpdateRun(ctx, run, "latest_attempt_id"))
|
||||
runAttemptID = attempt.ID
|
||||
}
|
||||
|
||||
if !withJob {
|
||||
return run, nil
|
||||
}
|
||||
job := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RunAttemptID: runAttemptID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "job1",
|
||||
JobID: "job1",
|
||||
Attempt: 1,
|
||||
Status: StatusSuccess,
|
||||
Stopped: timeutil.TimeStampNow(),
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, job))
|
||||
return run, []*ActionRunJob{job}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
withAttempt bool
|
||||
withJob bool
|
||||
want Status
|
||||
}{
|
||||
{"done job", true, true, StatusSuccess},
|
||||
// Runs created before migration v331 have no attempt, their status lives on the run row itself.
|
||||
{"done job on a legacy run without attempt", false, true, StatusSuccess},
|
||||
// Aggregation cannot reach a final status without any job, so cancelling has to end the run itself.
|
||||
{"no job at all", true, false, StatusCancelled},
|
||||
{"no job at all on a legacy run without attempt", false, false, StatusCancelled},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
run, jobs := newStuckRun(t, tc.withAttempt, tc.withJob)
|
||||
|
||||
// mirrors what the CancelRun service does
|
||||
cancelled, err := CancelJobs(t.Context(), jobs)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, cancelled, "nothing is cancellable, so the run row has to be settled explicitly")
|
||||
require.NoError(t, SettleRunAfterCancel(t.Context(), run))
|
||||
|
||||
if tc.withAttempt {
|
||||
gotAttempt := unittest.AssertExistsAndLoadBean(t, &ActionRunAttempt{ID: run.LatestAttemptID})
|
||||
assert.Equal(t, tc.want, gotAttempt.Status)
|
||||
}
|
||||
gotRun := unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID})
|
||||
assert.Equal(t, tc.want, gotRun.Status)
|
||||
assert.NotZero(t, gotRun.Stopped)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,58 +6,53 @@ 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"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/translation"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
type RunList []*ActionRun
|
||||
|
||||
// GetUserIDs returns a slice of user's id
|
||||
func (runs RunList) GetUserIDs() []int64 {
|
||||
return container.FilterSlice(runs, func(run *ActionRun) (int64, bool) {
|
||||
return run.TriggerUserID, true
|
||||
})
|
||||
}
|
||||
|
||||
func (runs RunList) GetRepoIDs() []int64 {
|
||||
return container.FilterSlice(runs, func(run *ActionRun) (int64, bool) {
|
||||
return run.RepoID, true
|
||||
})
|
||||
}
|
||||
|
||||
func (runs RunList) LoadTriggerUser(ctx context.Context) error {
|
||||
userIDs := runs.GetUserIDs()
|
||||
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.TriggerUserID == user_model.ActionsUserID {
|
||||
run.TriggerUser = user_model.NewActionsUser()
|
||||
} else {
|
||||
run.TriggerUser = users[run.TriggerUserID]
|
||||
if run.TriggerUser == nil {
|
||||
run.TriggerUser = user_model.NewGhostUser()
|
||||
}
|
||||
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 := runs.GetRepoIDs()
|
||||
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 {
|
||||
run.Repo = repos[run.RepoID]
|
||||
if run.Repo == nil {
|
||||
run.Repo = repos[run.RepoID]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -67,13 +62,18 @@ type FindRunOptions struct {
|
||||
RepoID int64
|
||||
OwnerID int64
|
||||
WorkflowID string
|
||||
Ref string // the commit/tag/… that caused this workflow
|
||||
WorkflowRepoID int64 // source-aware filter: the repo a run's workflow content came from (0 = any)
|
||||
IsScopedRun optional.Option[bool] // is the run from a scoped workflow
|
||||
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
|
||||
// AccessibleRepoIDsSubQuery, when non-nil, restricts results to the repo IDs selected by the
|
||||
// subquery (the caller's accessible repos). A nil value means no restriction. Using a subquery
|
||||
// instead of a materialized ID slice avoids exceeding DB parameter limits for large owners.
|
||||
AccessibleRepoIDsSubQuery *builder.Builder
|
||||
}
|
||||
|
||||
func (opts FindRunOptions) ToConds() builder.Cond {
|
||||
@@ -84,12 +84,15 @@ func (opts FindRunOptions) ToConds() builder.Cond {
|
||||
if opts.WorkflowID != "" {
|
||||
cond = cond.And(builder.Eq{"`action_run`.workflow_id": opts.WorkflowID})
|
||||
}
|
||||
if opts.WorkflowRepoID > 0 {
|
||||
cond = cond.And(builder.Eq{"`action_run`.workflow_repo_id": opts.WorkflowRepoID})
|
||||
}
|
||||
if opts.IsScopedRun.Has() {
|
||||
cond = cond.And(builder.Eq{"`action_run`.is_scoped_run": opts.IsScopedRun.Value()})
|
||||
}
|
||||
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))
|
||||
}
|
||||
@@ -102,11 +105,8 @@ func (opts FindRunOptions) ToConds() builder.Cond {
|
||||
if opts.CommitSHA != "" {
|
||||
cond = cond.And(builder.Eq{"`action_run`.commit_sha": opts.CommitSHA})
|
||||
}
|
||||
if len(opts.ConcurrencyGroup) > 0 {
|
||||
if opts.RepoID == 0 {
|
||||
panic("Invalid FindRunOptions: repo_id is required")
|
||||
}
|
||||
cond = cond.And(builder.Eq{"`action_run`.concurrency_group": opts.ConcurrencyGroup})
|
||||
if opts.AccessibleRepoIDsSubQuery != nil {
|
||||
cond = cond.And(builder.In("`action_run`.repo_id", opts.AccessibleRepoIDsSubQuery))
|
||||
}
|
||||
return cond
|
||||
}
|
||||
@@ -122,28 +122,76 @@ func (opts FindRunOptions) ToJoins() []db.JoinFunc {
|
||||
}
|
||||
|
||||
func (opts FindRunOptions) ToOrders() string {
|
||||
// When scoped to a repo, sort by `index`: it reuses the unique
|
||||
// `repo_index` (repo_id, index) index, so the query seeks repo_id and
|
||||
// walks index descending instead of filesorting all matching rows.
|
||||
// Within a repo `index` is co-monotonic with `id`, so the order is the same.
|
||||
if opts.RepoID > 0 {
|
||||
return "`action_run`.`index` DESC"
|
||||
}
|
||||
// `index` is scoped per repo, so it is meaningless across repos. With no
|
||||
// RepoID, sort by the global, PK-indexed `id` for a deterministic order.
|
||||
return "`action_run`.`id` DESC"
|
||||
}
|
||||
|
||||
type StatusInfo struct {
|
||||
Status int
|
||||
StatusName string
|
||||
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}
|
||||
statusInfoList := make([]StatusInfo, 0, 4)
|
||||
// same as those in aggregateJobStatus (StatusUnknown excluded; it's the "shouldn't happen" fallback)
|
||||
allStatus := []Status{StatusSuccess, StatusFailure, StatusCancelled, StatusSkipped, StatusWaiting, StatusRunning, StatusBlocked, StatusCancelling}
|
||||
statusInfoList := make([]StatusInfo, 0, len(allStatus))
|
||||
for _, s := range allStatus {
|
||||
statusInfoList = append(statusInfoList, StatusInfo{
|
||||
Status: int(s),
|
||||
StatusName: s.String(),
|
||||
DisplayedStatus: s.LocaleString(lang),
|
||||
})
|
||||
}
|
||||
return statusInfoList
|
||||
}
|
||||
|
||||
// GetRunBranches returns branch names for the run-list "Branch" filter.
|
||||
// Sourced from the `branch` table (indexed by repo_id) rather than DISTINCT-ing
|
||||
// `action_run.ref`, which is wildcard-matched and slow on large repos; as a side
|
||||
// effect the list reflects existing branches, not only ones that produced a run.
|
||||
func GetRunBranches(ctx context.Context, repoID int64) ([]string, error) {
|
||||
branches := make([]string, 0, 10)
|
||||
return branches, db.GetEngine(ctx).Table("branch").
|
||||
Where("repo_id = ?", repoID).
|
||||
And("is_deleted = ?", false).
|
||||
Cols("name").
|
||||
OrderBy("name ASC").
|
||||
Find(&branches)
|
||||
}
|
||||
|
||||
// GetRunWorkflowIDs returns all distinct WorkflowIDs that have at least
|
||||
// one ActionRun in the given repo.
|
||||
func GetRunWorkflowIDs(ctx context.Context, repoID int64) ([]string, error) {
|
||||
return getRunWorkflowIDs(ctx, repoID, builder.NewCond())
|
||||
}
|
||||
|
||||
// GetRepoRunWorkflowIDs returns all distinct WorkflowIDs that have at least
|
||||
// one repo-level ActionRun in the given repo.
|
||||
func GetRepoRunWorkflowIDs(ctx context.Context, repoID int64) ([]string, error) {
|
||||
return getRunWorkflowIDs(ctx, repoID, builder.Eq{"is_scoped_run": false})
|
||||
}
|
||||
|
||||
func getRunWorkflowIDs(ctx context.Context, repoID int64, extraCond builder.Cond) ([]string, error) {
|
||||
ids := make([]string, 0, 10)
|
||||
cond := builder.Eq{"repo_id": repoID}
|
||||
return ids, db.GetEngine(ctx).Table("action_run").
|
||||
Where(cond.And(extraCond)).
|
||||
Distinct("workflow_id").
|
||||
Cols("workflow_id").
|
||||
Asc("workflow_id").
|
||||
Find(&ids)
|
||||
}
|
||||
|
||||
// GetActors returns a slice of Actors
|
||||
func GetActors(ctx context.Context, repoID int64) ([]*user_model.User, error) {
|
||||
actors := make([]*user_model.User, 0, 10)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/translation"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetRunWorkflowIDs(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
ids, err := GetRunWorkflowIDs(t.Context(), 4)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"artifact.yaml", "test.yaml"}, ids)
|
||||
|
||||
ids, err = GetRunWorkflowIDs(t.Context(), 999999)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, ids)
|
||||
}
|
||||
|
||||
func TestGetRepoRunWorkflowIDs(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
const (
|
||||
repoID = int64(4)
|
||||
repoWorkflowID = "repo-orphan.yaml"
|
||||
scopedWorkflowID = "scoped-only.yaml"
|
||||
sharedWorkflowID = "shared-name.yaml"
|
||||
scopedWorkflowRepo = int64(111)
|
||||
)
|
||||
for _, spec := range []struct {
|
||||
id int64
|
||||
workflowID string
|
||||
workflowRepoID int64
|
||||
isScopedRun bool
|
||||
}{
|
||||
{99811, repoWorkflowID, repoID, false},
|
||||
{99812, scopedWorkflowID, scopedWorkflowRepo, true},
|
||||
{99813, sharedWorkflowID, repoID, false},
|
||||
{99814, sharedWorkflowID, scopedWorkflowRepo, true},
|
||||
} {
|
||||
require.NoError(t, db.Insert(t.Context(), &ActionRun{
|
||||
ID: spec.id,
|
||||
Index: spec.id,
|
||||
RepoID: repoID,
|
||||
OwnerID: 1,
|
||||
TriggerUserID: 1,
|
||||
WorkflowID: spec.workflowID,
|
||||
WorkflowRepoID: spec.workflowRepoID,
|
||||
IsScopedRun: spec.isScopedRun,
|
||||
}))
|
||||
}
|
||||
|
||||
ids, err := GetRepoRunWorkflowIDs(t.Context(), repoID)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, ids, repoWorkflowID)
|
||||
assert.Contains(t, ids, sharedWorkflowID)
|
||||
assert.NotContains(t, ids, scopedWorkflowID)
|
||||
}
|
||||
|
||||
func TestGetStatusInfoList(t *testing.T) {
|
||||
statusInfoList := GetStatusInfoList(t.Context(), translation.MockLocale{})
|
||||
|
||||
assert.Equal(t, []StatusInfo{
|
||||
{Status: int(StatusSuccess), StatusName: StatusSuccess.String(), DisplayedStatus: "actions.status.success"},
|
||||
{Status: int(StatusFailure), StatusName: StatusFailure.String(), DisplayedStatus: "actions.status.failure"},
|
||||
{Status: int(StatusCancelled), StatusName: StatusCancelled.String(), DisplayedStatus: "actions.status.cancelled"},
|
||||
{Status: int(StatusSkipped), StatusName: StatusSkipped.String(), DisplayedStatus: "actions.status.skipped"},
|
||||
{Status: int(StatusWaiting), StatusName: StatusWaiting.String(), DisplayedStatus: "actions.status.waiting"},
|
||||
{Status: int(StatusRunning), StatusName: StatusRunning.String(), DisplayedStatus: "actions.status.running"},
|
||||
{Status: int(StatusBlocked), StatusName: StatusBlocked.String(), DisplayedStatus: "actions.status.blocked"},
|
||||
{Status: int(StatusCancelling), StatusName: StatusCancelling.String(), DisplayedStatus: "actions.status.cancelling"},
|
||||
}, statusInfoList)
|
||||
}
|
||||
|
||||
// TestFindRunOptions_WorkflowRepoID: two runs share the bare WorkflowID but come from different content-source repos;
|
||||
// the source-aware WorkflowRepoID filter must separate them.
|
||||
func TestFindRunOptions_WorkflowRepoID(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
const (
|
||||
repoID = int64(4)
|
||||
sourceA = int64(111)
|
||||
sourceB = int64(222)
|
||||
workflowID = "u3-shared.yaml"
|
||||
)
|
||||
for _, spec := range []struct{ id, workflowRepoID int64 }{
|
||||
{99801, sourceA},
|
||||
{99802, sourceB},
|
||||
} {
|
||||
require.NoError(t, db.Insert(t.Context(), &ActionRun{
|
||||
ID: spec.id,
|
||||
Index: spec.id,
|
||||
RepoID: repoID,
|
||||
OwnerID: 1,
|
||||
TriggerUserID: 1,
|
||||
WorkflowID: workflowID,
|
||||
WorkflowRepoID: spec.workflowRepoID,
|
||||
IsScopedRun: true,
|
||||
}))
|
||||
}
|
||||
|
||||
// no source filter -> both
|
||||
all, err := db.Find[ActionRun](t.Context(), FindRunOptions{RepoID: repoID, WorkflowID: workflowID})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, all, 2)
|
||||
|
||||
// filter by source A -> only the run whose content came from A
|
||||
onlyA, err := db.Find[ActionRun](t.Context(), FindRunOptions{RepoID: repoID, WorkflowID: workflowID, WorkflowRepoID: sourceA})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, onlyA, 1)
|
||||
assert.EqualValues(t, 99801, onlyA[0].ID)
|
||||
|
||||
// filter by source B -> only the run whose content came from B
|
||||
onlyB, err := db.Find[ActionRun](t.Context(), FindRunOptions{RepoID: repoID, WorkflowID: workflowID, WorkflowRepoID: sourceB})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, onlyB, 1)
|
||||
assert.EqualValues(t, 99802, onlyB[0].ID)
|
||||
}
|
||||
|
||||
func TestFindRunOptions_IsScopedRun(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
const (
|
||||
repoID = int64(4)
|
||||
workflowID = "scoped-flag.yaml"
|
||||
)
|
||||
for _, spec := range []struct {
|
||||
id int64
|
||||
scoped bool
|
||||
}{
|
||||
{99821, false},
|
||||
{99822, true},
|
||||
} {
|
||||
require.NoError(t, db.Insert(t.Context(), &ActionRun{
|
||||
ID: spec.id,
|
||||
Index: spec.id,
|
||||
RepoID: repoID,
|
||||
OwnerID: 1,
|
||||
TriggerUserID: 1,
|
||||
WorkflowID: workflowID,
|
||||
WorkflowRepoID: repoID,
|
||||
IsScopedRun: spec.scoped,
|
||||
}))
|
||||
}
|
||||
|
||||
repoLevel, err := db.Find[ActionRun](t.Context(), FindRunOptions{RepoID: repoID, WorkflowID: workflowID, IsScopedRun: optional.Some(false)})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, repoLevel, 1)
|
||||
assert.EqualValues(t, 99821, repoLevel[0].ID)
|
||||
|
||||
scoped, err := db.Find[ActionRun](t.Context(), FindRunOptions{RepoID: repoID, WorkflowID: workflowID, IsScopedRun: optional.Some(true)})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, scoped, 1)
|
||||
assert.EqualValues(t, 99822, scoped[0].ID)
|
||||
}
|
||||
@@ -7,12 +7,13 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUpdateRepoRunsNumbers(t *testing.T) {
|
||||
@@ -31,7 +32,7 @@ func TestUpdateRepoRunsNumbers(t *testing.T) {
|
||||
// now update will correct them, only num_actionr_runs and num_closed_action_runs should be updated
|
||||
UpdateRepoRunsNumbers(t.Context(), repo.ID)
|
||||
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
assert.Equal(t, 5, repo.NumActionRuns)
|
||||
assert.Equal(t, 4, repo.NumActionRuns)
|
||||
assert.Equal(t, 3, repo.NumClosedActionRuns)
|
||||
}
|
||||
|
||||
@@ -44,3 +45,57 @@ func TestActionRun_Duration_NonNegative(t *testing.T) {
|
||||
}
|
||||
assert.Equal(t, time.Duration(0), run.Duration())
|
||||
}
|
||||
|
||||
func TestActionRun_WorkflowLink(t *testing.T) {
|
||||
repo := &repo_model.Repository{OwnerName: "org", Name: "consumer"}
|
||||
|
||||
// a repo-level run links by file name only
|
||||
repoLevel := &ActionRun{Repo: repo, WorkflowID: "ci.yaml", WorkflowRepoID: repo.ID}
|
||||
assert.Equal(t, repo.Link()+"/actions/?workflow=ci.yaml", repoLevel.WorkflowLink())
|
||||
|
||||
// a scoped run carries its source repo id back, so the list stays filtered to that source
|
||||
scoped := &ActionRun{Repo: repo, WorkflowID: "ci.yaml", WorkflowRepoID: 42, IsScopedRun: true}
|
||||
assert.Equal(t, repo.Link()+"/actions/?workflow=ci.yaml&scoped_workflow_source_repo_id=42", scoped.WorkflowLink())
|
||||
}
|
||||
|
||||
func TestGetWorkflowLatestRun_RepoLevelOnly(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
const (
|
||||
repoID = int64(4)
|
||||
workflowID = "badge-source-aware.yaml"
|
||||
ref = "refs/heads/main"
|
||||
)
|
||||
require.NoError(t, db.Insert(t.Context(), &ActionRun{
|
||||
ID: 99811,
|
||||
Index: 99811,
|
||||
RepoID: repoID,
|
||||
OwnerID: 1,
|
||||
TriggerUserID: 1,
|
||||
WorkflowID: workflowID,
|
||||
Ref: ref,
|
||||
Event: "push",
|
||||
Status: StatusSuccess,
|
||||
WorkflowRepoID: repoID,
|
||||
WorkflowCommitSHA: "repo-level-sha",
|
||||
}))
|
||||
require.NoError(t, db.Insert(t.Context(), &ActionRun{
|
||||
ID: 99812,
|
||||
Index: 99812,
|
||||
RepoID: repoID,
|
||||
OwnerID: 1,
|
||||
TriggerUserID: 1,
|
||||
WorkflowID: workflowID,
|
||||
Ref: ref,
|
||||
Event: "push",
|
||||
Status: StatusFailure,
|
||||
WorkflowRepoID: 111,
|
||||
WorkflowCommitSHA: "scoped-sha",
|
||||
IsScopedRun: true,
|
||||
}))
|
||||
|
||||
run, err := GetWorkflowLatestRun(t.Context(), repoID, workflowID, ref, "push")
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 99811, run.ID)
|
||||
assert.False(t, run.IsScopedRun)
|
||||
}
|
||||
|
||||
@@ -10,18 +10,18 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/shared/types"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/shared/types"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
@@ -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"`
|
||||
@@ -73,8 +75,28 @@ type ActionRunner struct {
|
||||
const (
|
||||
RunnerOfflineTime = time.Minute
|
||||
RunnerIdleTime = 10 * time.Second
|
||||
// RunnerHeartbeatInterval is how often last_online is persisted on poll.
|
||||
// Must stay well below RunnerOfflineTime so runners don't flap to offline.
|
||||
RunnerHeartbeatInterval = 30 * time.Second
|
||||
// RunnerActiveInterval is how often last_active is persisted while a runner
|
||||
// streams task updates and logs. Must stay well below RunnerIdleTime so a
|
||||
// busy runner keeps showing ACTIVE without a DB write on every RPC.
|
||||
RunnerActiveInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
// ShouldPersistLastOnline reports whether last_online is stale enough to be
|
||||
// worth writing back. Avoids a DB write on every runner poll.
|
||||
func ShouldPersistLastOnline(last timeutil.TimeStamp, now time.Time) bool {
|
||||
return now.Sub(last.AsTime()) >= RunnerHeartbeatInterval
|
||||
}
|
||||
|
||||
// ShouldPersistLastActive reports whether last_active is stale enough to be
|
||||
// worth writing back. Avoids a DB write on every UpdateTask/UpdateLog RPC while
|
||||
// a runner is actively streaming logs.
|
||||
func ShouldPersistLastActive(last timeutil.TimeStamp, now time.Time) bool {
|
||||
return now.Sub(last.AsTime()) >= RunnerActiveInterval
|
||||
}
|
||||
|
||||
// BelongsToOwnerName before calling, should guarantee that all attributes are loaded
|
||||
func (r *ActionRunner) BelongsToOwnerName() string {
|
||||
if r.RepoID != 0 {
|
||||
@@ -171,9 +193,8 @@ func (r *ActionRunner) LoadAttributes(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ActionRunner) GenerateToken() (err error) {
|
||||
r.Token, r.TokenSalt, r.TokenHash, _, err = generateSaltedToken()
|
||||
return err
|
||||
func (r *ActionRunner) GenerateAndFillToken() {
|
||||
r.Token, r.TokenSalt, r.TokenHash, _ = generateSaltedToken()
|
||||
}
|
||||
|
||||
// CanMatchLabels checks whether the runner's labels can match a job's "runs-on"
|
||||
@@ -249,22 +270,41 @@ func (opts FindRunnerOptions) ToConds() builder.Cond {
|
||||
return cond
|
||||
}
|
||||
|
||||
// runnerStatusOrderExpr builds an ORDER BY fragment that ranks runners by their
|
||||
// computed status (see ActionRunner.Status): active (0), idle (1), offline (2).
|
||||
// The thresholds are evaluated against the current time, mirroring ToConds, so
|
||||
// sorting by status groups active and idle runners instead of interleaving them
|
||||
// by raw last_online.
|
||||
func runnerStatusOrderExpr() string {
|
||||
now := time.Now()
|
||||
offlineThreshold := now.Add(-RunnerOfflineTime).Unix()
|
||||
idleThreshold := now.Add(-RunnerIdleTime).Unix()
|
||||
return fmt.Sprintf("CASE WHEN last_online <= %d THEN 2 WHEN last_active <= %d THEN 1 ELSE 0 END", offlineThreshold, idleThreshold)
|
||||
}
|
||||
|
||||
func (opts FindRunnerOptions) ToOrders() string {
|
||||
// A unique tiebreaker (id) is appended so that runners sharing the same
|
||||
// status, last_online or name keep a deterministic order across paginated
|
||||
// queries, otherwise the same runner may appear on more than one page.
|
||||
statusRank := runnerStatusOrderExpr()
|
||||
switch opts.Sort {
|
||||
case "online":
|
||||
return "last_online DESC"
|
||||
// Rank by computed status first so idle runners are not interleaved with
|
||||
// active ones; disabled runners sink to the bottom of their status group
|
||||
// (is_disabled ASC), then last_online breaks ties within a group.
|
||||
return statusRank + " ASC, is_disabled ASC, last_online DESC, id ASC"
|
||||
case "offline":
|
||||
return "last_online ASC"
|
||||
return statusRank + " DESC, is_disabled ASC, last_online ASC, id ASC"
|
||||
case "alphabetically":
|
||||
return "name ASC"
|
||||
return "name ASC, id ASC"
|
||||
case "reversealphabetically":
|
||||
return "name DESC"
|
||||
return "name DESC, id ASC"
|
||||
case "newest":
|
||||
return "id DESC"
|
||||
case "oldest":
|
||||
return "id ASC"
|
||||
}
|
||||
return "last_online DESC"
|
||||
return statusRank + " ASC, is_disabled ASC, last_online DESC, id ASC"
|
||||
}
|
||||
|
||||
// GetRunnerByUUID returns a runner via uuid
|
||||
|
||||
@@ -6,10 +6,10 @@ 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"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
)
|
||||
|
||||
type RunnerList []*ActionRunner
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestShouldPersistLastOnline(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
last timeutil.TimeStamp
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "fresh, skip write",
|
||||
last: timeutil.TimeStamp(now.Add(-5 * time.Second).Unix()),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "exactly at interval, write",
|
||||
last: timeutil.TimeStamp(now.Add(-RunnerHeartbeatInterval).Unix()),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "stale, write",
|
||||
last: timeutil.TimeStamp(now.Add(-2 * RunnerHeartbeatInterval).Unix()),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "zero (never seen), write",
|
||||
last: 0,
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, ShouldPersistLastOnline(tt.last, now))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldPersistLastActive(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
last timeutil.TimeStamp
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "fresh, skip write",
|
||||
last: timeutil.TimeStamp(now.Add(-1 * time.Second).Unix()),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "exactly at interval, write",
|
||||
last: timeutil.TimeStamp(now.Add(-RunnerActiveInterval).Unix()),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "stale, write",
|
||||
last: timeutil.TimeStamp(now.Add(-2 * RunnerActiveInterval).Unix()),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "zero (never seen), write",
|
||||
last: 0,
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, ShouldPersistLastActive(tt.last, now))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"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/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// ActionRunnerToken represents runner tokens
|
||||
@@ -97,10 +97,7 @@ func NewRunnerTokenWithValue(ctx context.Context, ownerID, repoID int64, token s
|
||||
}
|
||||
|
||||
func NewRunnerToken(ctx context.Context, ownerID, repoID int64) (*ActionRunnerToken, error) {
|
||||
token, err := util.CryptoRandomString(40)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token := util.CryptoRandomString(40)
|
||||
return NewRunnerTokenWithValue(ctx, ownerID, repoID, token)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ package actions
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -8,12 +8,12 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"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/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
webhook_module "code.gitea.io/gitea/modules/webhook"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
)
|
||||
|
||||
// ActionSchedule represents a schedule of a workflow file
|
||||
|
||||
@@ -4,62 +4,13 @@
|
||||
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"
|
||||
"gitea.dev/models/db"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
type ScheduleList []*ActionSchedule
|
||||
|
||||
// GetUserIDs returns a slice of user's id
|
||||
func (schedules ScheduleList) GetUserIDs() []int64 {
|
||||
return container.FilterSlice(schedules, func(schedule *ActionSchedule) (int64, bool) {
|
||||
return schedule.TriggerUserID, true
|
||||
})
|
||||
}
|
||||
|
||||
func (schedules ScheduleList) GetRepoIDs() []int64 {
|
||||
return container.FilterSlice(schedules, func(schedule *ActionSchedule) (int64, bool) {
|
||||
return schedule.RepoID, true
|
||||
})
|
||||
}
|
||||
|
||||
func (schedules ScheduleList) LoadTriggerUser(ctx context.Context) error {
|
||||
userIDs := schedules.GetUserIDs()
|
||||
users := make(map[int64]*user_model.User, len(userIDs))
|
||||
if err := db.GetEngine(ctx).In("id", userIDs).Find(&users); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, schedule := range schedules {
|
||||
if schedule.TriggerUserID == user_model.ActionsUserID {
|
||||
schedule.TriggerUser = user_model.NewActionsUser()
|
||||
} else {
|
||||
schedule.TriggerUser = users[schedule.TriggerUserID]
|
||||
if schedule.TriggerUser == nil {
|
||||
schedule.TriggerUser = user_model.NewGhostUser()
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (schedules ScheduleList) LoadRepos(ctx context.Context) error {
|
||||
repoIDs := schedules.GetRepoIDs()
|
||||
repos, err := repo_model.GetRepositoriesMapByIDs(ctx, repoIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, schedule := range schedules {
|
||||
schedule.Repo = repos[schedule.RepoID]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type FindScheduleOptions struct {
|
||||
db.ListOptions
|
||||
RepoID int64
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
@@ -6,9 +6,9 @@ package actions
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/container"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/test"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// ActionScopedWorkflowSource registers a repository as a source of scoped workflows, either for an owner (user/org) or for the whole instance.
|
||||
type ActionScopedWorkflowSource struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
|
||||
// OwnerID is the scope the source applies to: a user/org ID (applies to that owner's repos), or 0 for instance-level (applies to every repo).
|
||||
OwnerID int64 `xorm:"UNIQUE(owner_repo) NOT NULL DEFAULT 0"`
|
||||
// SourceRepoID is the source repository providing the workflow files; always non-zero.
|
||||
SourceRepoID int64 `xorm:"INDEX UNIQUE(owner_repo) NOT NULL DEFAULT 0"`
|
||||
|
||||
// WorkflowConfigs maps a workflow ID (entry name) to its merge-gate config.
|
||||
WorkflowConfigs map[string]*ScopedWorkflowConfig `xorm:"JSON TEXT 'workflow_configs'"`
|
||||
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
// ScopedWorkflowConfig is one scoped workflow's config within a source registration.
|
||||
type ScopedWorkflowConfig struct {
|
||||
Required bool `json:"required"`
|
||||
Patterns []string `json:"patterns"` // the status-check patterns that must be present and pass, only effective when Required is true
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(ActionScopedWorkflowSource))
|
||||
}
|
||||
|
||||
// IsWorkflowRequired reports whether the given workflow ID (entry name) is marked required in this source.
|
||||
func (s *ActionScopedWorkflowSource) IsWorkflowRequired(workflowID string) bool {
|
||||
c, ok := s.WorkflowConfigs[workflowID]
|
||||
return ok && c.Required
|
||||
}
|
||||
|
||||
type FindScopedWorkflowSourceOpts struct {
|
||||
db.ListOptions
|
||||
OwnerIDs []int64
|
||||
SourceRepoID int64
|
||||
}
|
||||
|
||||
func (opts FindScopedWorkflowSourceOpts) ToConds() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
if len(opts.OwnerIDs) > 0 {
|
||||
cond = cond.And(builder.In("owner_id", opts.OwnerIDs))
|
||||
}
|
||||
if opts.SourceRepoID != 0 {
|
||||
cond = cond.And(builder.Eq{"source_repo_id": opts.SourceRepoID})
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
// GetEffectiveScopedWorkflowSources returns the scoped-workflow sources effective for a repo owned by repoOwnerID:
|
||||
// the owner's own sources plus instance-level (owner_id=0) sources.
|
||||
func GetEffectiveScopedWorkflowSources(ctx context.Context, repoOwnerID int64) ([]*ActionScopedWorkflowSource, error) {
|
||||
owners := []int64{0}
|
||||
if repoOwnerID != 0 {
|
||||
owners = append(owners, repoOwnerID)
|
||||
}
|
||||
return db.Find[ActionScopedWorkflowSource](ctx, FindScopedWorkflowSourceOpts{OwnerIDs: owners})
|
||||
}
|
||||
|
||||
// IsScopedWorkflowSourceEffective reports whether sourceRepoID is a scoped-workflow source effective for a repo owned by repoOwnerID.
|
||||
func IsScopedWorkflowSourceEffective(ctx context.Context, repoOwnerID, sourceRepoID int64) (bool, error) {
|
||||
owners := []int64{0}
|
||||
if repoOwnerID != 0 {
|
||||
owners = append(owners, repoOwnerID)
|
||||
}
|
||||
return db.Exist[ActionScopedWorkflowSource](ctx, FindScopedWorkflowSourceOpts{OwnerIDs: owners, SourceRepoID: sourceRepoID}.ToConds())
|
||||
}
|
||||
|
||||
// IsWorkflowRequiredInSources reports whether workflowID from sourceRepoID is required by any of the given sources.
|
||||
func IsWorkflowRequiredInSources(sources []*ActionScopedWorkflowSource, sourceRepoID int64, workflowID string) bool {
|
||||
for _, s := range sources {
|
||||
if s.SourceRepoID == sourceRepoID && s.IsWorkflowRequired(workflowID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ScopedStatusContextPrefix returns the source-repo prefix that makes a scoped run's commit-status context distinct from same-named workflows.
|
||||
func ScopedStatusContextPrefix(ctx context.Context, sourceRepoID int64) string {
|
||||
if sourceRepo, err := repo_model.GetRepositoryByID(ctx, sourceRepoID); err == nil {
|
||||
return sourceRepo.FullName()
|
||||
}
|
||||
return fmt.Sprintf("scoped:%d", sourceRepoID)
|
||||
}
|
||||
|
||||
// IsScopedWorkflowRequired reports whether workflowID from sourceRepoID is required for a repo owned by consumerOwnerID.
|
||||
func IsScopedWorkflowRequired(ctx context.Context, consumerOwnerID, sourceRepoID int64, workflowID string) (bool, error) {
|
||||
sources, err := GetEffectiveScopedWorkflowSources(ctx, consumerOwnerID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return IsWorkflowRequiredInSources(sources, sourceRepoID, workflowID), nil
|
||||
}
|
||||
|
||||
// IsScopedWorkflowOptedOutloads the consumer's effective sources then calls ScopedWorkflowOptedOut
|
||||
func IsScopedWorkflowOptedOut(ctx context.Context, cfg *repo_model.ActionsConfig, consumerOwnerID, sourceRepoID int64, workflowID string) (bool, error) {
|
||||
if !cfg.IsScopedWorkflowDisabled(sourceRepoID, workflowID) {
|
||||
return false, nil
|
||||
}
|
||||
sources, err := GetEffectiveScopedWorkflowSources(ctx, consumerOwnerID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return ScopedWorkflowOptedOut(cfg, sources, sourceRepoID, workflowID), nil
|
||||
}
|
||||
|
||||
// ScopedWorkflowOptedOut reports whether a consumer's opt-out of (sourceRepoID, workflowID) is in effect.
|
||||
func ScopedWorkflowOptedOut(cfg *repo_model.ActionsConfig, sources []*ActionScopedWorkflowSource, sourceRepoID int64, workflowID string) bool {
|
||||
return !IsWorkflowRequiredInSources(sources, sourceRepoID, workflowID) && cfg.IsScopedWorkflowDisabled(sourceRepoID, workflowID)
|
||||
}
|
||||
|
||||
// GetScopedWorkflowSourcesByOwner returns the sources an owner (user/org, or 0 for instance) registered.
|
||||
func GetScopedWorkflowSourcesByOwner(ctx context.Context, ownerID int64) ([]*ActionScopedWorkflowSource, error) {
|
||||
return db.Find[ActionScopedWorkflowSource](ctx, FindScopedWorkflowSourceOpts{OwnerIDs: []int64{ownerID}})
|
||||
}
|
||||
|
||||
// GetScopedWorkflowSource returns the (owner, repo) source registration or a NotExist error.
|
||||
func GetScopedWorkflowSource(ctx context.Context, ownerID, repoID int64) (*ActionScopedWorkflowSource, error) {
|
||||
src := &ActionScopedWorkflowSource{}
|
||||
has, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).Get(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, util.NewNotExistErrorf("scoped workflow source (owner %d, repo %d) does not exist", ownerID, repoID)
|
||||
}
|
||||
return src, nil
|
||||
}
|
||||
|
||||
// AddScopedWorkflowSource registers repoID as a source for ownerID (no-op if already registered).
|
||||
func AddScopedWorkflowSource(ctx context.Context, ownerID, repoID int64) error {
|
||||
exists, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).Exist(new(ActionScopedWorkflowSource))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
if err := db.Insert(ctx, &ActionScopedWorkflowSource{OwnerID: ownerID, SourceRepoID: repoID}); err != nil {
|
||||
// Re-check and treat an already-present row as the intended no-op.
|
||||
if exists, existErr := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).Exist(new(ActionScopedWorkflowSource)); existErr == nil && exists {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetScopedWorkflowSourceConfigs replaces the per-workflow merge-gate configs (workflow ID -> config).
|
||||
func SetScopedWorkflowSourceConfigs(ctx context.Context, ownerID, repoID int64, configs map[string]*ScopedWorkflowConfig) error {
|
||||
_, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).
|
||||
Cols("workflow_configs").
|
||||
Update(&ActionScopedWorkflowSource{WorkflowConfigs: configs})
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveScopedWorkflowSource removes the (owner, repo) source registration.
|
||||
func RemoveScopedWorkflowSource(ctx context.Context, ownerID, repoID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).Delete(new(ActionScopedWorkflowSource))
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestScopedWorkflowSource_IsWorkflowRequired(t *testing.T) {
|
||||
src := &ActionScopedWorkflowSource{WorkflowConfigs: map[string]*ScopedWorkflowConfig{
|
||||
"a.yml": {Required: true, Patterns: []string{"p"}},
|
||||
"b.yml": {Required: true, Patterns: []string{"p"}},
|
||||
"c.yml": {Required: false, Patterns: []string{"p"}}, // patterns kept as history, not required
|
||||
}}
|
||||
assert.True(t, src.IsWorkflowRequired("a.yml"))
|
||||
assert.True(t, src.IsWorkflowRequired("b.yml"))
|
||||
assert.False(t, src.IsWorkflowRequired("c.yml"), "config kept as history but not required")
|
||||
assert.False(t, src.IsWorkflowRequired("d.yml"))
|
||||
|
||||
empty := &ActionScopedWorkflowSource{}
|
||||
assert.False(t, empty.IsWorkflowRequired("a.yml"))
|
||||
}
|
||||
|
||||
func TestIsWorkflowRequiredInSources(t *testing.T) {
|
||||
// repo 100 registered twice (org optional + instance required).
|
||||
sources := []*ActionScopedWorkflowSource{
|
||||
{OwnerID: 2, SourceRepoID: 100, WorkflowConfigs: nil},
|
||||
{OwnerID: 0, SourceRepoID: 100, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"a.yml": {Required: true, Patterns: []string{"p"}}}},
|
||||
{OwnerID: 0, SourceRepoID: 200, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"b.yml": {Required: true, Patterns: []string{"p"}}}},
|
||||
}
|
||||
|
||||
assert.True(t, IsWorkflowRequiredInSources(sources, 100, "a.yml"), "required at instance level wins over org optional")
|
||||
assert.False(t, IsWorkflowRequiredInSources(sources, 100, "z.yml"))
|
||||
assert.False(t, IsWorkflowRequiredInSources(sources, 200, "a.yml"), "a.yml is required for repo 100, not repo 200")
|
||||
assert.True(t, IsWorkflowRequiredInSources(sources, 200, "b.yml"))
|
||||
assert.False(t, IsWorkflowRequiredInSources(sources, 999, "a.yml"), "unknown source repo")
|
||||
}
|
||||
|
||||
func TestGetEffectiveScopedWorkflowSources(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
rows := []*ActionScopedWorkflowSource{
|
||||
{OwnerID: 2, SourceRepoID: 100, WorkflowConfigs: nil}, // org 2 registers repo 100 (optional)
|
||||
{OwnerID: 0, SourceRepoID: 100, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"a.yml": {Required: true, Patterns: []string{"p"}}}}, // instance also registers repo 100 (required)
|
||||
{OwnerID: 0, SourceRepoID: 200, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"b.yml": {Required: true, Patterns: []string{"p"}}}}, // instance source 200
|
||||
{OwnerID: 3, SourceRepoID: 300, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"c.yml": {Required: true, Patterns: []string{"p"}}}}, // a different owner's source
|
||||
}
|
||||
for _, r := range rows {
|
||||
require.NoError(t, db.Insert(ctx, r))
|
||||
}
|
||||
|
||||
// owner 2 sees its own sources plus instance-level ones, but not owner 3's.
|
||||
owner2, err := GetEffectiveScopedWorkflowSources(ctx, 2)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, owner2, 3)
|
||||
|
||||
required, err := IsScopedWorkflowRequired(ctx, 2, 100, "a.yml")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, required, "instance marks a.yml required → required for owner 2 even though org left it optional")
|
||||
|
||||
required, err = IsScopedWorkflowRequired(ctx, 2, 100, "x.yml")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, required)
|
||||
|
||||
required, err = IsScopedWorkflowRequired(ctx, 2, 200, "b.yml")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, required)
|
||||
|
||||
// owner 3's source must not be effective for owner 2.
|
||||
required, err = IsScopedWorkflowRequired(ctx, 2, 300, "c.yml")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, required)
|
||||
|
||||
// IsScopedWorkflowSourceEffective: owner-level and instance-level sources are effective; another owner's is not.
|
||||
effective, err := IsScopedWorkflowSourceEffective(ctx, 2, 100)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, effective, "owner 2's own source")
|
||||
|
||||
effective, err = IsScopedWorkflowSourceEffective(ctx, 2, 200)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, effective, "instance-level source is effective for any owner")
|
||||
|
||||
effective, err = IsScopedWorkflowSourceEffective(ctx, 2, 300)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, effective, "owner 3's source is not effective for owner 2")
|
||||
|
||||
effective, err = IsScopedWorkflowSourceEffective(ctx, 2, 999)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, effective, "unknown source repo")
|
||||
|
||||
effective, err = IsScopedWorkflowSourceEffective(ctx, 3, 300)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, effective, "owner 3's own source is effective for owner 3")
|
||||
}
|
||||
|
||||
func TestScopedWorkflowSourceCRUD(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
// add is idempotent
|
||||
require.NoError(t, AddScopedWorkflowSource(ctx, 5, 10))
|
||||
require.NoError(t, AddScopedWorkflowSource(ctx, 5, 10))
|
||||
sources, err := GetScopedWorkflowSourcesByOwner(ctx, 5)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, sources, 1)
|
||||
|
||||
// set the per-workflow configs (entry name -> {required, patterns}); a.yml required, b.yml kept as history (not required)
|
||||
configs := map[string]*ScopedWorkflowConfig{
|
||||
"a.yml": {Required: true, Patterns: []string{"src: a.yml / *"}},
|
||||
"b.yml": {Required: false, Patterns: []string{"src: b.yml / build (push)"}},
|
||||
}
|
||||
require.NoError(t, SetScopedWorkflowSourceConfigs(ctx, 5, 10, configs))
|
||||
src, err := GetScopedWorkflowSource(ctx, 5, 10)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, configs, src.WorkflowConfigs)
|
||||
|
||||
// clearing the configs works
|
||||
require.NoError(t, SetScopedWorkflowSourceConfigs(ctx, 5, 10, nil))
|
||||
src, err = GetScopedWorkflowSource(ctx, 5, 10)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, src.WorkflowConfigs)
|
||||
|
||||
// remove
|
||||
require.NoError(t, RemoveScopedWorkflowSource(ctx, 5, 10))
|
||||
_, err = GetScopedWorkflowSource(ctx, 5, 10)
|
||||
assert.ErrorIs(t, err, util.ErrNotExist)
|
||||
sources, err = GetScopedWorkflowSourcesByOwner(ctx, 5)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, sources)
|
||||
}
|
||||
@@ -6,34 +6,35 @@ package actions
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
"gitea.dev/modules/translation"
|
||||
)
|
||||
|
||||
// Status represents the status of ActionRun, ActionRunJob, ActionTask, or ActionTaskStep
|
||||
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 +89,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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
runnerv1 "gitea.dev/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)
|
||||
}
|
||||
}
|
||||
|
||||
func newJob(status Status, continueOnError bool) *ActionRunJob {
|
||||
return &ActionRunJob{Status: status, ContinueOnError: continueOnError}
|
||||
}
|
||||
|
||||
func TestAggregateJobStatusContinueOnError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
jobs []*ActionRunJob
|
||||
want Status
|
||||
}{
|
||||
{
|
||||
name: "all success",
|
||||
jobs: []*ActionRunJob{newJob(StatusSuccess, false), newJob(StatusSuccess, false)},
|
||||
want: StatusSuccess,
|
||||
},
|
||||
{
|
||||
name: "one failure without continue-on-error",
|
||||
jobs: []*ActionRunJob{newJob(StatusSuccess, false), newJob(StatusFailure, false)},
|
||||
want: StatusFailure,
|
||||
},
|
||||
{
|
||||
name: "one failure with continue-on-error",
|
||||
jobs: []*ActionRunJob{newJob(StatusSuccess, false), newJob(StatusFailure, true)},
|
||||
want: StatusSuccess,
|
||||
},
|
||||
{
|
||||
name: "only continued-failure",
|
||||
jobs: []*ActionRunJob{newJob(StatusFailure, true)},
|
||||
want: StatusSuccess,
|
||||
},
|
||||
{
|
||||
name: "continued-failure plus real failure",
|
||||
jobs: []*ActionRunJob{newJob(StatusFailure, true), newJob(StatusFailure, false)},
|
||||
want: StatusFailure,
|
||||
},
|
||||
{
|
||||
name: "all skipped",
|
||||
jobs: []*ActionRunJob{newJob(StatusSkipped, false), newJob(StatusSkipped, false)},
|
||||
want: StatusSkipped,
|
||||
},
|
||||
{
|
||||
name: "continued-failure plus skipped counts as success",
|
||||
jobs: []*ActionRunJob{newJob(StatusFailure, true), newJob(StatusSkipped, false)},
|
||||
want: StatusSuccess,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, AggregateJobStatus(tt.jobs))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,17 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
"code.gitea.io/gitea/modules/actions/jobparser"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/globallock"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
lru "github.com/hashicorp/golang-lru/v2"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
"xorm.io/builder"
|
||||
@@ -59,6 +60,12 @@ type ActionTask struct {
|
||||
Updated timeutil.TimeStamp `xorm:"updated index"`
|
||||
}
|
||||
|
||||
// taskReportTimeout is how long a task may go without contact from its runner before the
|
||||
// runner is assumed gone. Runners report state and stream logs every few seconds, both of
|
||||
// which refresh ActionTask.Updated. Shorter than setting.Actions.ZombieTaskTimeout because
|
||||
// it only decides whether the runner is reachable, not whether the task should be killed.
|
||||
const taskReportTimeout = time.Minute
|
||||
|
||||
var successfulTokenTaskCache *lru.Cache[string, any]
|
||||
|
||||
func init() {
|
||||
@@ -84,11 +91,15 @@ func (task *ActionTask) IsStopped() bool {
|
||||
return task.Stopped > 0
|
||||
}
|
||||
|
||||
func (task *ActionTask) GetRunLink() string {
|
||||
if task.Job == nil || task.Job.Run == nil {
|
||||
func (task *ActionTask) GetRunJobLink() string {
|
||||
// Run.Repo can be nil when the repository was deleted while task/run rows remain
|
||||
// (TaskList.LoadAttributes copies job.Repo into run.Repo, leaving it nil on a miss).
|
||||
// Run.Link() already returns "" in that case, so guard here to avoid emitting a
|
||||
// broken relative "/jobs/N" link from the Sprintf below.
|
||||
if task.Job == nil || task.Job.Run == nil || task.Job.Run.Repo == nil {
|
||||
return ""
|
||||
}
|
||||
return task.Job.Run.Link()
|
||||
return fmt.Sprintf("%s/jobs/%d", task.Job.Run.Link(), task.Job.ID)
|
||||
}
|
||||
|
||||
func (task *ActionTask) GetCommitLink() string {
|
||||
@@ -125,9 +136,6 @@ func (task *ActionTask) LoadJob(ctx context.Context) error {
|
||||
|
||||
// LoadAttributes load Job Steps if not loaded
|
||||
func (task *ActionTask) LoadAttributes(ctx context.Context) error {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
if err := task.LoadJob(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -147,9 +155,8 @@ func (task *ActionTask) LoadAttributes(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (task *ActionTask) GenerateToken() (err error) {
|
||||
task.Token, task.TokenSalt, task.TokenHash, task.TokenLastEight, err = generateSaltedToken()
|
||||
return err
|
||||
func (task *ActionTask) GenerateAndFillToken() {
|
||||
task.Token, task.TokenSalt, task.TokenHash, task.TokenLastEight = generateSaltedToken()
|
||||
}
|
||||
|
||||
func GetTaskByID(ctx context.Context, id int64) (*ActionTask, error) {
|
||||
@@ -197,7 +204,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 {
|
||||
@@ -230,13 +238,23 @@ func makeTaskStepDisplayName(step *jobparser.Step, limit int) (name string) {
|
||||
return util.EllipsisDisplayString(name, limit) // database column has a length limit
|
||||
}
|
||||
|
||||
func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask, bool, error) {
|
||||
ctx, committer, err := db.TxContext(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer committer.Close()
|
||||
// errJobAlreadyClaimed is a sentinel used inside claimJobForRunner to signal that
|
||||
// another runner won the optimistic-lock race; it is never returned to callers.
|
||||
var errJobAlreadyClaimed = errors.New("job already claimed by another runner")
|
||||
|
||||
// pickTaskBatchSize bounds how many waiting jobs each CreateTaskForRunner query loads,
|
||||
// so a large backlog is not fetched into memory on every runner poll.
|
||||
// It is a var only so tests can shrink it to exercise pagination cheaply.
|
||||
var pickTaskBatchSize = 100
|
||||
|
||||
// CreateTaskForRunner finds a waiting job that matches the runner's labels and
|
||||
// atomically claims it. It iterates through all matching jobs so that a
|
||||
// concurrent claim by another runner (which would lose the optimistic lock on
|
||||
// job #1) does not leave the remaining jobs permanently unassigned.
|
||||
func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask, bool, error) {
|
||||
if db.InTransaction(ctx) {
|
||||
return nil, false, errors.New("CreateTaskForRunner must not be called within a database transaction")
|
||||
}
|
||||
e := db.GetEngine(ctx)
|
||||
|
||||
jobCond := builder.NewCond()
|
||||
@@ -247,96 +265,174 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
|
||||
Join("INNER", "repo_unit", "`repository`.id = `repo_unit`.repo_id").
|
||||
Where(builder.Eq{"`repository`.owner_id": runner.OwnerID, "`repo_unit`.type": unit.TypeActions}))
|
||||
}
|
||||
if jobCond.IsValid() {
|
||||
jobCond = builder.In("run_id", builder.Select("id").From("action_run").Where(jobCond))
|
||||
}
|
||||
|
||||
var jobs []*ActionRunJob
|
||||
if err := e.Where("task_id=? AND status=?", 0, StatusWaiting).And(jobCond).Asc("updated", "id").Find(&jobs); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
baseCond := builder.Eq{"task_id": 0, "status": StatusWaiting, "is_reusable_caller": false}.And(jobCond)
|
||||
|
||||
// TODO: a more efficient way to filter labels
|
||||
var job *ActionRunJob
|
||||
log.Trace("runner labels: %v", runner.AgentLabels)
|
||||
for _, v := range jobs {
|
||||
if runner.CanMatchLabels(v.RunsOn) {
|
||||
job = v
|
||||
break
|
||||
|
||||
// Page through the waiting jobs oldest-first instead of loading the whole backlog into memory on every poll.
|
||||
// Keyset pagination on (updated, id) is safe under concurrent claims:
|
||||
// updated only moves forward, so the advancing cursor never skips a still-waiting job even as claimed jobs drop out.
|
||||
var cursorUpdated timeutil.TimeStamp
|
||||
var cursorID int64
|
||||
for {
|
||||
cond := baseCond
|
||||
if cursorID > 0 {
|
||||
cond = cond.And(builder.Or(
|
||||
builder.Gt{"updated": cursorUpdated},
|
||||
builder.And(builder.Eq{"updated": cursorUpdated}, builder.Gt{"id": cursorID}),
|
||||
))
|
||||
}
|
||||
}
|
||||
if job == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err := job.LoadAttributes(ctx); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
now := timeutil.TimeStampNow()
|
||||
job.Attempt++
|
||||
job.Started = now
|
||||
job.Status = StatusRunning
|
||||
|
||||
task := &ActionTask{
|
||||
JobID: job.ID,
|
||||
Attempt: job.Attempt,
|
||||
RunnerID: runner.ID,
|
||||
Started: now,
|
||||
Status: StatusRunning,
|
||||
RepoID: job.RepoID,
|
||||
OwnerID: job.OwnerID,
|
||||
CommitSHA: job.CommitSHA,
|
||||
IsForkPullRequest: job.IsForkPullRequest,
|
||||
}
|
||||
if err := task.GenerateToken(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
workflowJob, err := job.ParseJob()
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("load job %d: %w", job.ID, err)
|
||||
}
|
||||
|
||||
if _, err := e.Insert(task); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
task.LogFilename = logFileName(job.Run.Repo.FullName(), task.ID)
|
||||
if err := UpdateTask(ctx, task, "log_filename"); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if len(workflowJob.Steps) > 0 {
|
||||
steps := make([]*ActionTaskStep, len(workflowJob.Steps))
|
||||
for i, v := range workflowJob.Steps {
|
||||
steps[i] = &ActionTaskStep{
|
||||
Name: makeTaskStepDisplayName(v, 255),
|
||||
TaskID: task.ID,
|
||||
Index: int64(i),
|
||||
RepoID: task.RepoID,
|
||||
Status: StatusWaiting,
|
||||
}
|
||||
}
|
||||
if _, err := e.Insert(steps); err != nil {
|
||||
var jobs []*ActionRunJob
|
||||
if err := e.Where(cond).Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
task.Steps = steps
|
||||
}
|
||||
|
||||
job.TaskID = task.ID
|
||||
if n, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}); err != nil {
|
||||
return nil, false, err
|
||||
} else if n != 1 {
|
||||
for _, v := range jobs {
|
||||
if !runner.CanMatchLabels(v.RunsOn) {
|
||||
continue
|
||||
}
|
||||
task, ok, err := claimJobForRunner(ctx, runner, v)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if ok {
|
||||
return task, true, nil
|
||||
}
|
||||
// Another runner claimed this job concurrently; try the next one.
|
||||
}
|
||||
|
||||
// A short page means no waiting jobs remain beyond it.
|
||||
if len(jobs) < pickTaskBatchSize {
|
||||
return nil, false, nil
|
||||
}
|
||||
last := jobs[len(jobs)-1]
|
||||
cursorUpdated, cursorID = last.Updated, last.ID
|
||||
}
|
||||
}
|
||||
|
||||
// claimJobForRunner attempts to atomically claim job for runner inside its own
|
||||
// transaction. Returns (task, true, nil) on success, or (nil, false, nil) when
|
||||
// another runner wins the optimistic-lock race (the caller should try the next
|
||||
// candidate job).
|
||||
func claimJobForRunner(ctx context.Context, runner *ActionRunner, job *ActionRunJob) (*ActionTask, bool, error) {
|
||||
var resultTask *ActionTask
|
||||
|
||||
err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
e := db.GetEngine(ctx)
|
||||
|
||||
if err := job.LoadAttributes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := timeutil.TimeStampNow()
|
||||
job.Started = now
|
||||
job.Status = StatusRunning
|
||||
|
||||
task := &ActionTask{
|
||||
JobID: job.ID,
|
||||
Attempt: job.Attempt,
|
||||
RunnerID: runner.ID,
|
||||
Started: now,
|
||||
Status: StatusRunning,
|
||||
RepoID: job.RepoID,
|
||||
OwnerID: job.OwnerID,
|
||||
CommitSHA: job.CommitSHA,
|
||||
IsForkPullRequest: job.IsForkPullRequest,
|
||||
}
|
||||
task.GenerateAndFillToken()
|
||||
|
||||
workflowJob, err := job.ParseJob()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load job %d: %w", job.ID, err)
|
||||
}
|
||||
|
||||
if _, err := e.Insert(task); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
task.LogFilename = logFileName(job.Run.Repo.FullName(), task.ID)
|
||||
if err := UpdateTask(ctx, task, "log_filename"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(workflowJob.Steps) > 0 {
|
||||
steps := make([]*ActionTaskStep, len(workflowJob.Steps))
|
||||
for i, v := range workflowJob.Steps {
|
||||
steps[i] = &ActionTaskStep{
|
||||
Name: makeTaskStepDisplayName(v, 255),
|
||||
TaskID: task.ID,
|
||||
Index: int64(i),
|
||||
RepoID: task.RepoID,
|
||||
Status: StatusWaiting,
|
||||
}
|
||||
}
|
||||
if _, err := e.Insert(steps); err != nil {
|
||||
return err
|
||||
}
|
||||
task.Steps = steps
|
||||
}
|
||||
|
||||
job.TaskID = task.ID
|
||||
n, err := UpdateRunJob(ctx, job, builder.And(builder.Eq{"task_id": 0}, builder.Eq{"status": StatusWaiting}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n != 1 {
|
||||
// Another runner claimed this job between our scan and this update;
|
||||
// signal the outer loop to move on without treating this as an error.
|
||||
return errJobAlreadyClaimed
|
||||
}
|
||||
|
||||
task.Job = job
|
||||
resultTask = task
|
||||
return nil
|
||||
})
|
||||
|
||||
if errors.Is(err, errJobAlreadyClaimed) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
task.Job = job
|
||||
|
||||
if err := committer.Commit(); err != nil {
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return resultTask, true, nil
|
||||
}
|
||||
|
||||
return task, true, nil
|
||||
// ReleaseTaskForRunner reverts a freshly-claimed but undelivered task: it deletes
|
||||
// the task together with its steps and returns the job to the waiting queue. It is
|
||||
// used when assembling the runner response fails after the job was already claimed,
|
||||
// so the job is not stranded in running state with no runner ever executing it.
|
||||
func ReleaseTaskForRunner(ctx context.Context, task *ActionTask) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
e := db.GetEngine(ctx)
|
||||
|
||||
job, err := GetRunJobByRepoAndID(ctx, task.RepoID, task.JobID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
job.Status = StatusWaiting
|
||||
job.Started = 0
|
||||
job.TaskID = 0
|
||||
// Guard on task_id and status so we only release while the job still
|
||||
// references this task and has not progressed past running.
|
||||
n, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": task.ID, "status": StatusRunning}, "status", "started", "task_id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n != 1 {
|
||||
return fmt.Errorf("release task %d: job %d no longer references it", task.ID, task.JobID)
|
||||
}
|
||||
|
||||
if _, err := e.Delete(&ActionTaskStep{TaskID: task.ID}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := e.ID(task.ID).Delete(&ActionTask{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateTask(ctx context.Context, task *ActionTask, cols ...string) error {
|
||||
@@ -353,6 +449,18 @@ func UpdateTask(ctx context.Context, task *ActionTask, cols ...string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func getRunIDByTaskID(ctx context.Context, taskID int64) (runID int64, _ error) {
|
||||
if has, err := db.GetEngine(ctx).Cols("action_run_job.run_id").
|
||||
Table("action_task").
|
||||
Join("INNER", "action_run_job", "action_run_job.id = action_task.job_id").
|
||||
Where(builder.Eq{"action_task.id": taskID}).Get(&runID); err != nil {
|
||||
return runID, err
|
||||
} else if !has {
|
||||
return runID, util.ErrNotExist
|
||||
}
|
||||
return runID, nil
|
||||
}
|
||||
|
||||
// UpdateTaskByState updates the task by the state.
|
||||
// It will always update the task if the state is not final, even there is no change.
|
||||
// So it will update ActionTask.Updated to avoid the task being judged as a zombie task.
|
||||
@@ -362,48 +470,58 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
|
||||
stepStates[v.Id] = v
|
||||
}
|
||||
|
||||
return db.WithTx2(ctx, func(ctx context.Context) (*ActionTask, error) {
|
||||
e := db.GetEngine(ctx)
|
||||
|
||||
task := &ActionTask{}
|
||||
if has, err := e.ID(state.Id).Get(task); err != nil {
|
||||
return nil, err
|
||||
// Only one request can update the task because the final state needs to be calculated with all job states.
|
||||
// Otherwise, concurrent requests with transaction will make the SQL read stale job state and result in wrong final state.
|
||||
taskID := state.Id
|
||||
runID, err := getRunIDByTaskID(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task := &ActionTask{}
|
||||
applyState := func(ctx context.Context) error {
|
||||
if has, err := db.GetEngine(ctx).ID(taskID).Get(task); err != nil {
|
||||
return err
|
||||
} else if !has {
|
||||
return nil, util.ErrNotExist
|
||||
return util.ErrNotExist
|
||||
} else if runnerID != task.RunnerID {
|
||||
return nil, errors.New("invalid runner for task")
|
||||
return errors.New("invalid runner for task")
|
||||
}
|
||||
|
||||
if task.Status.IsDone() {
|
||||
// the state is final, do nothing
|
||||
return task, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
return err
|
||||
}
|
||||
if _, err := UpdateRunJob(ctx, &ActionRunJob{
|
||||
ID: task.JobID,
|
||||
RepoID: task.RepoID,
|
||||
Status: task.Status,
|
||||
Stopped: task.Stopped,
|
||||
}, nil); err != nil {
|
||||
return nil, err
|
||||
}, nil, "status", "stopped"); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Force update ActionTask.Updated to avoid the task being judged as a zombie task
|
||||
task.Updated = timeutil.TimeStampNow()
|
||||
if err := UpdateTask(ctx, task, "updated"); err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := task.LoadAttributes(ctx); err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
for _, step := range task.Steps {
|
||||
@@ -416,21 +534,25 @@ 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
|
||||
}
|
||||
if _, err := e.ID(step.ID).Update(step); err != nil {
|
||||
return nil, err
|
||||
if _, err := db.GetEngine(ctx).ID(step.ID).Update(step); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return task, nil
|
||||
return nil
|
||||
}
|
||||
err = globallock.LockAndDo(ctx, fmt.Sprintf("UpdateTaskByState-run-%d", runID), func(ctx context.Context) error {
|
||||
// A half-written report leaves the task done with a running job, which no retry repairs.
|
||||
return db.WithTx(ctx, applyState)
|
||||
})
|
||||
return task, err
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -446,6 +568,39 @@ 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
|
||||
} else if task.Updated.AddDuration(taskReportTimeout) < now {
|
||||
// A runner that stopped reporting will never acknowledge the cancellation either,
|
||||
// so skip the handshake instead of waiting for the zombie task cleanup.
|
||||
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
|
||||
}
|
||||
|
||||
// NoAutoTime keeps "updated" at the runner's last contact: re-cancelling an already
|
||||
// cancelling task must not defer the timeout above or the zombie task cleanup.
|
||||
_, err := e.ID(task.ID).Cols("status").NoAutoTime().Update(task)
|
||||
return err
|
||||
}
|
||||
|
||||
task.Status = status
|
||||
task.Stopped = now
|
||||
if _, err := UpdateRunJob(ctx, &ActionRunJob{
|
||||
|
||||
@@ -6,9 +6,9 @@ package actions
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ package actions
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"gitea.dev/models/db"
|
||||
)
|
||||
|
||||
// ActionTaskOutput represents an output of ActionTask.
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/timeutil"
|
||||
)
|
||||
|
||||
// ActionTaskStep represents a step of ActionTask
|
||||
|
||||
@@ -4,14 +4,40 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/actions/jobparser"
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
"xorm.io/xorm/contexts"
|
||||
)
|
||||
|
||||
func TestActionTask_GetRunJobLink(t *testing.T) {
|
||||
repo := &repo_model.Repository{OwnerName: "org", Name: "consumer"}
|
||||
run := &ActionRun{ID: 10, Repo: repo}
|
||||
job := &ActionRunJob{ID: 42, Run: run}
|
||||
|
||||
// a task with a loaded job links to that specific job, not just the run
|
||||
task := &ActionTask{Job: job}
|
||||
assert.Equal(t, run.Link()+"/jobs/42", task.GetRunJobLink())
|
||||
|
||||
// missing job, run or repo yields an empty link instead of a broken URL
|
||||
assert.Empty(t, (&ActionTask{}).GetRunJobLink())
|
||||
assert.Empty(t, (&ActionTask{Job: &ActionRunJob{ID: 42}}).GetRunJobLink())
|
||||
assert.Empty(t, (&ActionTask{Job: &ActionRunJob{ID: 42, Run: &ActionRun{ID: 10}}}).GetRunJobLink())
|
||||
}
|
||||
|
||||
func TestMakeTaskStepDisplayName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -75,3 +101,350 @@ func TestMakeTaskStepDisplayName(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskCancellingFinalizesToCancelled(t *testing.T) {
|
||||
testResult := func(t *testing.T, result runnerv1.Result) {
|
||||
t.Helper()
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
task, job := newRunningTaskForCancelling(t, "cancelling-finalization-job", true)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
// TestStopTaskCancellingFallsBackToCancelled covers the cases where the cancelling handshake can
|
||||
// never complete, so StopTask must cancel right away instead of waiting for the zombie task cleanup.
|
||||
func TestStopTaskCancellingFallsBackToCancelled(t *testing.T) {
|
||||
assertCancelled := func(t *testing.T, task *ActionTask, job *ActionRunJob) {
|
||||
t.Helper()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// A runner too old to know the cancelling state can only be stopped by a final status.
|
||||
t.Run("legacy runner", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, job := newRunningTaskForCancelling(t, "legacy-cancelling-job", false)
|
||||
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
assertCancelled(t, task, job)
|
||||
})
|
||||
|
||||
// The runner is gone, e.g. an ephemeral runner was cleaned up, so nobody can acknowledge.
|
||||
t.Run("missing runner", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, job := newRunningTaskForCancelling(t, "missing-runner-cancelling-job", true)
|
||||
|
||||
_, err := db.DeleteByID[ActionRunner](t.Context(), task.RunnerID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
assertCancelled(t, task, job)
|
||||
})
|
||||
|
||||
// The runner went silent, e.g. it gave up while Gitea was restarting, so it never picks up the request.
|
||||
t.Run("silent runner", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, job := newRunningTaskForCancelling(t, "silent-runner-cancelling-job", true)
|
||||
|
||||
// NoAutoTime because the point of the test is an "updated" older than xorm would write
|
||||
task.Updated = timeutil.TimeStampNow().AddDuration(-2 * taskReportTimeout)
|
||||
_, err := db.GetEngine(t.Context()).ID(task.ID).Cols("updated").NoAutoTime().Update(task)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
assertCancelled(t, task, job)
|
||||
|
||||
// A runner coming back still learns the outcome from the UpdateTask response,
|
||||
// and its late result does not overwrite the cancellation.
|
||||
late, err := UpdateTaskByState(t.Context(), task.RunnerID, &runnerv1.TaskState{
|
||||
Id: task.ID,
|
||||
Result: runnerv1.Result_RESULT_SUCCESS,
|
||||
StoppedAt: timestamppb.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, StatusCancelled, late.Status)
|
||||
assert.Equal(t, runnerv1.Result_RESULT_CANCELLED, late.Status.AsResult())
|
||||
})
|
||||
}
|
||||
|
||||
// TestStopTaskCancellingKeepsReportTime makes sure persisting the cancelling status does not refresh
|
||||
// "updated": re-cancelling would otherwise defer both the fallback to cancelled and the zombie task
|
||||
// cleanup, no matter how long the runner has been silent.
|
||||
func TestStopTaskCancellingKeepsReportTime(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, _ := newRunningTaskForCancelling(t, "repeated-cancelling-job", true)
|
||||
|
||||
// NoAutoTime because the point of the test is an "updated" older than xorm would write
|
||||
lastReport := timeutil.TimeStampNow().AddDuration(-taskReportTimeout / 2)
|
||||
task.Updated = lastReport
|
||||
_, err := db.GetEngine(t.Context()).ID(task.ID).Cols("updated").NoAutoTime().Update(task)
|
||||
require.NoError(t, err)
|
||||
|
||||
// the runner reported recently enough, so the task waits for it to acknowledge
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
|
||||
assert.Equal(t, StatusCancelling, taskAfterStop.Status)
|
||||
assert.Equal(t, lastReport, taskAfterStop.Updated)
|
||||
|
||||
// cancelling it again does not reset the clock either
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
taskAfterSecondStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
|
||||
assert.Equal(t, StatusCancelling, taskAfterSecondStop.Status)
|
||||
assert.Equal(t, lastReport, taskAfterSecondStop.Updated)
|
||||
}
|
||||
|
||||
// TestReleaseTaskForRunner verifies that releasing a freshly-claimed task returns
|
||||
// its job to the waiting queue and deletes the task and its steps, so a failure
|
||||
// while assembling the runner response cannot strand the job in running state.
|
||||
func TestReleaseTaskForRunner(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "release-task-test-run",
|
||||
RepoID: 1,
|
||||
OwnerID: 2,
|
||||
WorkflowID: "test.yaml",
|
||||
Index: 9902,
|
||||
TriggerUserID: 2,
|
||||
Ref: "refs/heads/main",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
Status: StatusWaiting,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
job := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "release-job",
|
||||
Attempt: 1,
|
||||
JobID: "release-job",
|
||||
Status: StatusWaiting,
|
||||
RunsOn: []string{"ubuntu-latest"},
|
||||
WorkflowPayload: []byte("on: push\njobs:\n release-job:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
|
||||
runner := &ActionRunner{
|
||||
UUID: "release-runner-uuid",
|
||||
Name: "release-runner",
|
||||
AgentLabels: []string{"ubuntu-latest"},
|
||||
}
|
||||
runner.GenerateAndFillToken()
|
||||
require.NoError(t, db.Insert(t.Context(), runner))
|
||||
|
||||
task, ok, err := CreateTaskForRunner(t.Context(), runner)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, task)
|
||||
|
||||
claimed := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
||||
require.Equal(t, StatusRunning, claimed.Status)
|
||||
require.Equal(t, task.ID, claimed.TaskID)
|
||||
|
||||
require.NoError(t, ReleaseTaskForRunner(t.Context(), task))
|
||||
|
||||
// Job is back in the waiting queue with no task assigned.
|
||||
released := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, StatusWaiting, released.Status)
|
||||
assert.Zero(t, released.TaskID)
|
||||
assert.Zero(t, released.Started)
|
||||
|
||||
// The task and its steps are gone.
|
||||
unittest.AssertNotExistsBean(t, &ActionTask{ID: task.ID})
|
||||
unittest.AssertNotExistsBean(t, &ActionTaskStep{TaskID: task.ID})
|
||||
}
|
||||
|
||||
// TestCreateTaskForRunnerPagination verifies that a job sitting beyond the first page is still claimed
|
||||
func TestCreateTaskForRunnerPagination(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
defer func(orig int) { pickTaskBatchSize = orig }(pickTaskBatchSize)
|
||||
pickTaskBatchSize = 2
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "pagination-test-run",
|
||||
RepoID: 1,
|
||||
OwnerID: 2,
|
||||
WorkflowID: "test.yaml",
|
||||
Index: 9903,
|
||||
TriggerUserID: 2,
|
||||
Ref: "refs/heads/main",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
Status: StatusWaiting,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
// Five waiting jobs the runner cannot run, then one it can.
|
||||
// With a page size of 2 the matching job only appears on the third page.
|
||||
for i := range 5 {
|
||||
mismatch := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "mismatch-" + string(rune('a'+i)),
|
||||
Attempt: 1,
|
||||
JobID: "mismatch-" + string(rune('a'+i)),
|
||||
Status: StatusWaiting,
|
||||
RunsOn: []string{"windows-latest"},
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), mismatch))
|
||||
}
|
||||
|
||||
target := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "target-job",
|
||||
Attempt: 1,
|
||||
JobID: "target-job",
|
||||
Status: StatusWaiting,
|
||||
RunsOn: []string{"ubuntu-latest"},
|
||||
WorkflowPayload: []byte("on: push\njobs:\n target-job:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), target))
|
||||
|
||||
runner := &ActionRunner{
|
||||
UUID: "pagination-runner-uuid",
|
||||
Name: "pagination-runner",
|
||||
AgentLabels: []string{"ubuntu-latest"},
|
||||
}
|
||||
runner.GenerateAndFillToken()
|
||||
require.NoError(t, db.Insert(t.Context(), runner))
|
||||
|
||||
task, ok, err := CreateTaskForRunner(t.Context(), runner)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, task)
|
||||
|
||||
claimed := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: target.ID})
|
||||
assert.Equal(t, StatusRunning, claimed.Status)
|
||||
assert.Equal(t, task.ID, claimed.TaskID)
|
||||
}
|
||||
|
||||
type failFirstStepWrite struct{ fired atomic.Bool }
|
||||
|
||||
func (h *failFirstStepWrite) BeforeProcess(c *contexts.ContextHook) (context.Context, error) {
|
||||
if !h.fired.Load() && strings.HasPrefix(c.SQL, "UPDATE") && strings.Contains(c.SQL, "action_task_step") {
|
||||
h.fired.Store(true)
|
||||
return nil, errors.New("interrupted")
|
||||
}
|
||||
return c.Ctx, nil
|
||||
}
|
||||
|
||||
func (*failFirstStepWrite) AfterProcess(*contexts.ContextHook) error { return nil }
|
||||
|
||||
// TestUpdateTaskByStateIsAtomic checks that an interrupted report writes nothing: a surviving task or
|
||||
// job write would hit the "state is final" early return, which no retry or cleanup repairs.
|
||||
func TestUpdateTaskByStateIsAtomic(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, job := newRunningTaskForCancelling(t, "atomic-report-job", true)
|
||||
require.NoError(t, db.Insert(t.Context(), &ActionTaskStep{TaskID: task.ID, RepoID: task.RepoID, Status: StatusRunning}))
|
||||
unittest.GetXORMEngine().AddHook(&failFirstStepWrite{})
|
||||
finalState := &runnerv1.TaskState{Id: task.ID, Result: runnerv1.Result_RESULT_SUCCESS, StoppedAt: timestamppb.Now()}
|
||||
_, err := UpdateTaskByState(t.Context(), task.RunnerID, finalState)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, StatusRunning, unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID}).Status)
|
||||
assert.Equal(t, StatusRunning, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}).Status)
|
||||
_, err = UpdateTaskByState(t.Context(), task.RunnerID, finalState)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, StatusSuccess, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}).Status)
|
||||
}
|
||||
|
||||
// newRunningTaskForCancelling inserts a running run/job/task assigned to a fresh runner,
|
||||
// which is the state every cancellation test starts from.
|
||||
func newRunningTaskForCancelling(t *testing.T, name string, hasCancellingSupport bool) (*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: name,
|
||||
Attempt: 1,
|
||||
JobID: name,
|
||||
Status: StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
|
||||
runner := &ActionRunner{
|
||||
UUID: name,
|
||||
Name: name,
|
||||
HasCancellingSupport: hasCancellingSupport,
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ package actions
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
)
|
||||
|
||||
// ActionTasksVersion
|
||||
|
||||
@@ -6,8 +6,8 @@ package actions
|
||||
import (
|
||||
"context"
|
||||
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
)
|
||||
|
||||
// ComputeTaskTokenPermissions computes the effective permissions for a job token against the target repository.
|
||||
|
||||
@@ -12,24 +12,18 @@ import (
|
||||
"io"
|
||||
"time"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
func generateSaltedToken() (string, string, string, string, error) {
|
||||
salt, err := util.CryptoRandomString(10)
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
buf, err := util.CryptoRandomBytes(20)
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
func generateSaltedToken() (string, string, string, string) {
|
||||
salt := util.CryptoRandomString(10)
|
||||
buf := util.CryptoRandomBytes(20)
|
||||
token := hex.EncodeToString(buf)
|
||||
hash := auth_model.HashToken(token, salt)
|
||||
return token, salt, hash, token[len(token)-8:], nil
|
||||
return token, salt, hash, token[len(token)-8:]
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user