From 899ede1d55f5333d379d1f7ab745a437d2fb79e2 Mon Sep 17 00:00:00 2001 From: Zettat123 Date: Thu, 23 Apr 2026 17:33:41 -0600 Subject: [PATCH] Introduce `ActionRunAttempt` to represent each execution of a run (#37119) This PR introduces a new `ActionRunAttempt` model and makes Actions execution attempt-scoped. **Main Changes** - Each workflow run trigger generates a new `ActionRunAttempt`. The triggered jobs are then associated with this new `ActionRunAttempt` record. - Each rerun now creates: - a new `ActionRunAttempt` record for the workflow run - a full new set of `ActionRunJob` records for the new `ActionRunAttempt` - For jobs that need to be rerun, the new job records are created as runnable jobs in the new attempt. - For jobs that do not need to be rerun, new job records are still created in the new attempt, but they reuse the result of the previous attempt instead of executing again. - Introduce `rerunPlan` to manage each rerun and refactored rerun flow into a two-phase plan-based model: - `buildRerunPlan` - `execRerunPlan` - `RerunFailedWorkflowRun` and `RerunFailed` no longer directly derives all jobs that need to be rerun; this step is now handled by `buildRerunPlan`. - Converted artifacts from run-scoped to attempt-scoped: - uploads are now associated with `RunAttemptID` - listing, download, and deletion resolve against the current attempt - Added attempt-aware web Actions views: - the default run page shows the latest attempt (`/actions/runs/{run_id}`) - previous attempt pages show jobs and artifacts for that attempt (`/actions/runs/{run_id}/attempts/{attempt_num}`) - New APIs: - `/repos/{owner}/{repo}/actions/runs/{run}/attempts/{attempt}` - `/repos/{owner}/{repo}/actions/runs/{run}/attempts/{attempt}/jobs` - New configuration `MAX_RERUN_ATTEMPTS` - https://gitea.com/gitea/docs/pulls/383 **Compatibility** - Existing legacy runs use `LatestAttemptID = 0` and legacy jobs use `RunAttemptID = 0`. Therefore, these fields can be used to identify legacy runs and jobs and provide backward compatibility. - If a legacy run is rerun, an `ActionRunAttempt` with `attempt=1` will be created to represent the original execution. Then a new `ActionRunAttempt` with `attempt=2` will be created for the real rerun. - Existing artifact records are not backfilled; legacy artifacts continue to use `RunAttemptID = 0`. **Improvements** - It is now easier to inspect and download logs from previous attempts. - [`run_attempt`](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#github-context) semantics are now aligned with GitHub. - > A unique number for each attempt of a particular workflow run in a repository. This number begins at 1 for the workflow run's first attempt, and increments with each re-run. - Rerun behavior is now clearer and more explicit. - Instead of mutating the status of previous jobs in place, each rerun creates a new attempt with a full new set of job records. - Artifacts produced by different reruns can now be listed separately. Signed-off-by: Zettat123 Co-authored-by: silverwind Co-authored-by: Claude Opus 4.7 Co-authored-by: wxiaoguang Co-authored-by: Giteabot --- custom/conf/app.example.ini | 2 + models/actions/artifact.go | 51 +- models/actions/run.go | 75 ++- models/actions/run_attempt.go | 145 +++++ models/actions/run_attempt_list.go | 46 ++ models/actions/run_job.go | 149 ++++- models/actions/run_job_list.go | 5 + models/actions/run_list.go | 6 - models/actions/task.go | 1 - models/migrations/migrations.go | 4 + models/migrations/v1_27/main_test.go | 14 + models/migrations/v1_27/v331.go | 158 ++++++ models/migrations/v1_27/v331_test.go | 156 ++++++ modules/setting/actions.go | 8 + modules/structs/repo_actions.go | 18 +- options/locale/locale_en-US.json | 4 +- routers/api/actions/artifacts.go | 14 +- routers/api/actions/artifacts_chunks.go | 4 +- routers/api/actions/artifactsv4.go | 18 +- routers/api/actions/runner/runner.go | 5 +- routers/api/v1/admin/action.go | 2 +- routers/api/v1/api.go | 4 + routers/api/v1/org/action.go | 2 +- routers/api/v1/repo/action.go | 184 ++++++- routers/api/v1/shared/action.go | 9 +- routers/api/v1/user/action.go | 2 +- routers/common/actions.go | 5 +- routers/web/devtest/mock_actions.go | 119 +++- routers/web/repo/actions/actions.go | 2 +- routers/web/repo/actions/view.go | 443 +++++++++------ routers/web/web.go | 7 + services/actions/approve.go | 69 +++ services/actions/cleanup.go | 6 +- services/actions/clear_tasks.go | 84 +-- services/actions/concurrency.go | 18 +- services/actions/context.go | 47 +- services/actions/context_test.go | 18 +- services/actions/job_emitter.go | 161 +++--- services/actions/job_emitter_test.go | 73 ++- services/actions/notifier.go | 2 +- services/actions/notify.go | 144 +++++ services/actions/rerun.go | 515 ++++++++++++------ services/actions/rerun_test.go | 81 +-- services/actions/run.go | 93 +++- services/actions/task.go | 11 +- services/convert/action_test.go | 2 +- services/convert/convert.go | 78 ++- services/mailer/mail_workflow_run.go | 2 +- services/notify/notify.go | 6 + services/webhook/notifier.go | 2 +- templates/devtest/repo-action-view.tmpl | 6 +- templates/repo/actions/view.tmpl | 3 +- templates/repo/actions/view_component.tmpl | 9 +- templates/swagger/v1_json.tmpl | 139 +++++ tests/integration/actions_concurrency_test.go | 263 +++++++-- tests/integration/actions_delete_run_test.go | 2 +- tests/integration/actions_job_test.go | 160 ++++++ tests/integration/actions_log_test.go | 91 ++++ tests/integration/actions_rerun_test.go | 426 ++++++++++++++- tests/integration/actions_route_test.go | 2 +- .../integration/api_actions_artifact_test.go | 167 ++++++ .../api_actions_artifact_v4_test.go | 126 +++++ tests/integration/api_actions_run_test.go | 35 +- .../js/components/ActionRunArtifacts.test.ts | 8 +- web_src/js/components/ActionRunArtifacts.ts | 7 +- web_src/js/components/ActionRunJobView.vue | 6 +- .../js/components/ActionRunSummaryView.vue | 22 +- web_src/js/components/ActionRunView.ts | 8 +- web_src/js/components/RepoActionView.vue | 71 ++- web_src/js/features/repo-actions.ts | 12 +- web_src/js/modules/gitea-actions.ts | 16 + web_src/js/svg.ts | 2 + .../js/webcomponents/relative-time.test.ts | 27 + web_src/js/webcomponents/relative-time.ts | 4 +- 74 files changed, 3838 insertions(+), 848 deletions(-) create mode 100644 models/actions/run_attempt.go create mode 100644 models/actions/run_attempt_list.go create mode 100644 models/migrations/v1_27/main_test.go create mode 100644 models/migrations/v1_27/v331.go create mode 100644 models/migrations/v1_27/v331_test.go create mode 100644 services/actions/approve.go create mode 100644 services/actions/notify.go diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 2c789dd1032..428ad34aeac 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -2973,6 +2973,8 @@ LEVEL = Info ;; Comma-separated list of workflow directories, the first one to exist ;; in a repo is used to find Actions workflow files ;WORKFLOW_DIRS = .gitea/workflows,.github/workflows +;; Maximum number of attempts a single workflow run can have. Default value is 50. +;MAX_RERUN_ATTEMPTS = 50 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/models/actions/artifact.go b/models/actions/artifact.go index ffadc79661a..f0effdeecaf 100644 --- a/models/actions/artifact.go +++ b/models/actions/artifact.go @@ -12,6 +12,7 @@ import ( "time" "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" @@ -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 + RunAttemptID optional.Option[int64] // use optional to allow filtering by zero (legacy artifacts 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 opts.RunAttemptID.Has() { + cond = cond.And(builder.Eq{"run_attempt_id": opts.RunAttemptID.Value()}) + } if opts.ArtifactName != "" { cond = cond.And(builder.Eq{"artifact_name": opts.ArtifactName}) } @@ -186,11 +193,12 @@ type ActionArtifactMeta struct { 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, max(expired_unix) as expired_unix"). Find(&arts) @@ -217,12 +225,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}) diff --git a/models/actions/run.go b/models/actions/run.go index bce356c0e2b..b8c9a59fb49 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -30,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 @@ -50,15 +50,20 @@ 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 + + // 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() { @@ -160,6 +165,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 @@ -406,14 +436,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{ @@ -425,36 +452,34 @@ 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) } - 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...) } diff --git a/models/actions/run_attempt.go b/models/actions/run_attempt.go new file mode 100644 index 00000000000..7fd0212522e --- /dev/null +++ b/models/actions/run_attempt.go @@ -0,0 +1,145 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "fmt" + "slices" + "time" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/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) error { + if attempt == nil { + return nil + } + + 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 + } + + if attempt.TriggerUser == nil { + u, err := user_model.GetPossibleUserByID(ctx, attempt.TriggerUserID) + if err != nil { + return err + } + attempt.TriggerUser = u + } + + return nil +} + +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 +} + +// 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") +} diff --git a/models/actions/run_attempt_list.go b/models/actions/run_attempt_list.go new file mode 100644 index 00000000000..77a5b8f15c9 --- /dev/null +++ b/models/actions/run_attempt_list.go @@ -0,0 +1,46 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/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) +} diff --git a/models/actions/run_job.go b/models/actions/run_job.go index d1e5d1e9380..09213299978 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -34,7 +34,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 +46,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,6 +67,14 @@ 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"` + Started timeutil.TimeStamp Stopped timeutil.TimeStamp Created timeutil.TimeStamp `xorm:"created"` @@ -75,6 +89,13 @@ 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) @@ -152,9 +173,50 @@ 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 @@ -196,25 +258,51 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col } { - // Other goroutines may aggregate the status of the run and update it too. - // So we need load the run and its jobs before updating the run. - run, err := GetRunByRepoAndID(ctx, job.RepoID, job.RunID) - if err != nil { - return 0, err - } - jobs, err := GetRunJobsByRunID(ctx, job.RunID) - if err != nil { - return 0, err - } - run.Status = AggregateJobStatus(jobs) - if run.Started.IsZero() && run.Status.IsRunning() { - run.Started = timeutil.TimeStampNow() - } - if run.Stopped.IsZero() && run.Status.IsDone() { - run.Stopped = timeutil.TimeStampNow() - } - if err := UpdateRun(ctx, run, "status", "started", "stopped"); err != nil { - return 0, fmt.Errorf("update run %d: %w", run.ID, err) + // 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 job.RunAttemptID > 0 { + attempt, err := GetRunAttemptByRepoAndID(ctx, job.RepoID, job.RunAttemptID) + if err != nil { + return 0, err + } + jobs, err := GetRunJobsByRunAndAttemptID(ctx, job.RunID, job.RunAttemptID) + if err != nil { + return 0, err + } + attempt.Status = AggregateJobStatus(jobs) + 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 0, fmt.Errorf("update run attempt %d: %w", attempt.ID, err) + } + } else { + // 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 + run, err := GetRunByRepoAndID(ctx, job.RepoID, job.RunID) + if err != nil { + return 0, err + } + jobs, err := GetLatestAttemptJobsByRepoAndRunID(ctx, job.RepoID, 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) + } } } @@ -269,7 +357,7 @@ func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob) if job.ConcurrencyCancel { statusFindOption = append(statusFindOption, StatusRunning) } - 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,12 +365,13 @@ 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...) } diff --git a/models/actions/run_job_list.go b/models/actions/run_job_list.go index 10f76d3641b..e06b6beb9ec 100644 --- a/models/actions/run_job_list.go +++ b/models/actions/run_job_list.go @@ -9,6 +9,7 @@ import ( "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/optional" "code.gitea.io/gitea/modules/timeutil" "xorm.io/builder" @@ -70,6 +71,7 @@ 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 @@ -83,6 +85,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}) } diff --git a/models/actions/run_list.go b/models/actions/run_list.go index 8b8c132a482..82dc97f3e5a 100644 --- a/models/actions/run_list.go +++ b/models/actions/run_list.go @@ -83,12 +83,6 @@ 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}) - } return cond } diff --git a/models/actions/task.go b/models/actions/task.go index 28928c2bc6f..016f91a7bb3 100644 --- a/models/actions/task.go +++ b/models/actions/task.go @@ -272,7 +272,6 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask } now := timeutil.TimeStampNow() - job.Attempt++ job.Started = now job.Status = StatusRunning diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index db74ff78d50..c3a8f08b5d7 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -26,6 +26,7 @@ import ( "code.gitea.io/gitea/models/migrations/v1_24" "code.gitea.io/gitea/models/migrations/v1_25" "code.gitea.io/gitea/models/migrations/v1_26" + "code.gitea.io/gitea/models/migrations/v1_27" "code.gitea.io/gitea/models/migrations/v1_6" "code.gitea.io/gitea/models/migrations/v1_7" "code.gitea.io/gitea/models/migrations/v1_8" @@ -405,6 +406,9 @@ func prepareMigrationTasks() []*migration { newMigration(328, "Add TokenPermissions column to ActionRunJob", v1_26.AddTokenPermissionsToActionRunJob), newMigration(329, "Add unique constraint for user badge", v1_26.AddUniqueIndexForUserBadge), newMigration(330, "Add name column to webhook", v1_26.AddNameToWebhook), + // Gitea 1.26.0 ends at migration ID number 330 (database version 331) + + newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel), } return preparedMigrations } diff --git a/models/migrations/v1_27/main_test.go b/models/migrations/v1_27/main_test.go new file mode 100644 index 00000000000..e269e3df9a8 --- /dev/null +++ b/models/migrations/v1_27/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "testing" + + "code.gitea.io/gitea/models/migrations/base" +) + +func TestMain(m *testing.M) { + base.MainTest(m) +} diff --git a/models/migrations/v1_27/v331.go b/models/migrations/v1_27/v331.go new file mode 100644 index 00000000000..204b7b661e9 --- /dev/null +++ b/models/migrations/v1_27/v331.go @@ -0,0 +1,158 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "context" + "time" + + "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +type actionRunAttempt struct { + ID int64 + RepoID int64 `xorm:"index(repo_concurrency_status)"` + RunID int64 `xorm:"UNIQUE(run_attempt)"` + Attempt int64 `xorm:"UNIQUE(run_attempt)"` + TriggerUserID int64 + ConcurrencyGroup string `xorm:"index(repo_concurrency_status) NOT NULL DEFAULT ''"` + ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"` + Status int `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" +} + +type actionArtifact struct { + ID int64 `xorm:"pk autoincr"` + RunID int64 `xorm:"index unique(runid_attempt_name_path)"` + RunAttemptID int64 `xorm:"index unique(runid_attempt_name_path) NOT NULL DEFAULT 0"` + RunnerID int64 + RepoID int64 `xorm:"index"` + OwnerID int64 + CommitSHA string + StoragePath string + FileSize int64 + FileCompressedSize int64 + ContentEncoding string `xorm:"content_encoding"` + ArtifactPath string `xorm:"index unique(runid_attempt_name_path)"` + ArtifactName string `xorm:"index unique(runid_attempt_name_path)"` + Status int `xorm:"index"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated index"` + ExpiredUnix timeutil.TimeStamp `xorm:"index"` +} + +func (actionArtifact) TableName() string { + return "action_artifact" +} + +// actionRun mirrors the post-migration action_run schema. +type actionRun struct { + ID int64 + Title string + RepoID int64 `xorm:"unique(repo_index)"` + OwnerID int64 `xorm:"index"` + WorkflowID string `xorm:"index"` + Index int64 `xorm:"index unique(repo_index)"` + TriggerUserID int64 `xorm:"index"` + ScheduleID int64 + Ref string `xorm:"index"` + CommitSHA string + IsForkPullRequest bool + NeedApproval bool + ApprovedBy int64 `xorm:"index"` + Event string + EventPayload string `xorm:"LONGTEXT"` + TriggerEvent string + Status int `xorm:"index"` + Version int `xorm:"version default 0"` + RawConcurrency string + Started timeutil.TimeStamp + Stopped timeutil.TimeStamp + PreviousDuration time.Duration + LatestAttemptID int64 `xorm:"index NOT NULL DEFAULT 0"` + Created timeutil.TimeStamp `xorm:"created"` + Updated timeutil.TimeStamp `xorm:"updated"` +} + +func (actionRun) TableName() string { + return "action_run" +} + +// AddActionRunAttemptModel adds the ActionRunAttempt table and the supporting ActionRun/ActionRunJob fields. +func AddActionRunAttemptModel(x *xorm.Engine) error { + // add "action_run_attempt" + if _, err := x.SyncWithOptions(xorm.SyncOptions{ + IgnoreDropIndices: true, + }, new(actionRunAttempt)); err != nil { + return err + } + + // update "action_run_job" + type ActionRunJob struct { + RunAttemptID int64 `xorm:"index NOT NULL DEFAULT 0"` + AttemptJobID int64 `xorm:"index NOT NULL DEFAULT 0"` + SourceTaskID int64 `xorm:"NOT NULL DEFAULT 0"` + } + if _, err := x.SyncWithOptions(xorm.SyncOptions{ + IgnoreDropIndices: true, + }, new(ActionRunJob)); err != nil { + return err + } + + // update "action_artifact": let xorm sync add the new 4-column unique index (runid_attempt_name_path) and drop the old 3-column unique (runid_name_path) + if err := x.Sync(new(actionArtifact)); err != nil { + return err + } + + // update "action_run" + // + // This migration intentionally removes the legacy run-level concurrency columns after + // introducing attempt-level concurrency on action_run_attempt. + // + // Existing values from action_run.concurrency_group / action_run.concurrency_cancel are + // not backfilled into action_run_attempt: + // - the old fields are only meaningful while a run is actively participating in + // concurrency scheduling + // - for completed legacy runs, keeping or backfilling those values has no practical + // effect on future scheduling behavior + // - scanning and backfilling old runs would add significant migration cost for little value + // + // This means the schema change is destructive for those two legacy columns by design. + // + // Let xorm sync add the latest_attempt_id column and drop the now-orphan (repo_id, concurrency_group) index. + if err := x.Sync(new(actionRun)); err != nil { + return err + } + concurrencyColumns := make([]string, 0, 2) + for _, col := range []string{"concurrency_group", "concurrency_cancel"} { + exist, err := x.Dialect().IsColumnExist(x.DB(), context.Background(), "action_run", col) + if err != nil { + return err + } + if exist { + concurrencyColumns = append(concurrencyColumns, col) + } + } + if len(concurrencyColumns) == 0 { + return nil + } + sess := x.NewSession() + defer sess.Close() + if err := base.DropTableColumns(sess, "action_run", concurrencyColumns...); err != nil { + return err + } + // DropTableColumns rebuilds the table on SQLite, which drops all existing indexes. + // Re-sync to restore the indexes defined on actionRun. + return x.Sync(new(actionRun)) +} diff --git a/models/migrations/v1_27/v331_test.go b/models/migrations/v1_27/v331_test.go new file mode 100644 index 00000000000..45f467cf9bc --- /dev/null +++ b/models/migrations/v1_27/v331_test.go @@ -0,0 +1,156 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "context" + "slices" + "testing" + + "code.gitea.io/gitea/models/migrations/base" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "xorm.io/xorm/schemas" +) + +type actionRunBeforeV331 struct { + ID int64 `xorm:"pk autoincr"` + ConcurrencyGroup string + ConcurrencyCancel bool + LatestAttemptID int64 `xorm:"-"` +} + +func (actionRunBeforeV331) TableName() string { + return "action_run" +} + +type actionRunJobBeforeV331 struct { + ID int64 `xorm:"pk autoincr"` + RunID int64 `xorm:"index"` + RepoID int64 `xorm:"index"` +} + +func (actionRunJobBeforeV331) TableName() string { + return "action_run_job" +} + +type actionArtifactBeforeV331 struct { + ID int64 `xorm:"pk autoincr"` + RunID int64 `xorm:"index unique(runid_name_path)"` + RepoID int64 `xorm:"index"` + ArtifactPath string `xorm:"index unique(runid_name_path)"` + ArtifactName string `xorm:"index unique(runid_name_path)"` +} + +func (actionArtifactBeforeV331) TableName() string { + return "action_artifact" +} + +func Test_AddActionRunAttemptModel(t *testing.T) { + x, deferable := base.PrepareTestEnv(t, 0, + new(actionRunBeforeV331), + new(actionRunJobBeforeV331), + new(actionArtifactBeforeV331), + ) + defer deferable() + if x == nil || t.Failed() { + return + } + + _, err := x.Insert(&actionArtifactBeforeV331{ + RunID: 1, + RepoID: 1, + ArtifactPath: "artifact/path", + ArtifactName: "artifact-name", + }) + require.NoError(t, err) + + require.NoError(t, AddActionRunAttemptModel(x)) + + tableMap := base.LoadTableSchemasMap(t, x) + + attemptTable := tableMap["action_run_attempt"] + require.NotNil(t, attemptTable) + attemptTablCols := []string{"id", "repo_id", "run_id", "attempt", "trigger_user_id", "status", "started", "stopped", "concurrency_group", "concurrency_cancel", "created", "updated"} + require.ElementsMatch(t, attemptTable.ColumnsSeq(), attemptTablCols) + + runTable := tableMap["action_run"] + require.NotNil(t, runTable) + require.Contains(t, runTable.ColumnsSeq(), "latest_attempt_id") + require.NotContains(t, runTable.ColumnsSeq(), "concurrency_group") + require.NotContains(t, runTable.ColumnsSeq(), "concurrency_cancel") + + jobTable := tableMap["action_run_job"] + require.NotNil(t, jobTable) + require.Contains(t, jobTable.ColumnsSeq(), "run_attempt_id") + require.Contains(t, jobTable.ColumnsSeq(), "attempt_job_id") + require.Contains(t, jobTable.ColumnsSeq(), "source_task_id") + + attemptIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "action_run_attempt") + require.NoError(t, err) + assert.True(t, hasIndexWithColumns(attemptIndexes, []string{"run_id", "attempt"}, true)) + assert.True(t, hasIndexWithColumns(attemptIndexes, []string{"repo_id", "concurrency_group", "status"}, false)) + + runIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "action_run") + require.NoError(t, err) + assert.True(t, hasIndexWithColumns(runIndexes, []string{"latest_attempt_id"}, false)) + assert.False(t, hasIndexWithColumns(runIndexes, []string{"repo_id", "concurrency_group"}, false)) + + jobIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "action_run_job") + require.NoError(t, err) + assert.True(t, hasIndexWithColumns(jobIndexes, []string{"run_attempt_id"}, false)) + assert.True(t, hasIndexWithColumns(jobIndexes, []string{"attempt_job_id"}, false)) + + indexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "action_artifact") + require.NoError(t, err) + assert.False(t, hasIndexWithColumns(indexes, []string{"run_id", "artifact_path", "artifact_name"}, true)) + assert.True(t, hasIndexWithColumns(indexes, []string{"run_id", "run_attempt_id", "artifact_path", "artifact_name"}, true)) + + _, err = x.Insert(&actionArtifact{ + RunID: 1, + RunAttemptID: 2, + RepoID: 1, + ArtifactPath: "artifact/path", + ArtifactName: "artifact-name", + }) + require.NoError(t, err) + _, err = x.Insert(&actionArtifact{ + RunID: 1, + RunAttemptID: 2, + RepoID: 1, + ArtifactPath: "artifact/path", + ArtifactName: "artifact-name", + }) + require.Error(t, err) + + _, err = x.Insert(&actionRunAttempt{ + RepoID: 1, + RunID: 1, + Attempt: 2, + TriggerUserID: 1, + Status: 1, + }) + require.NoError(t, err) + _, err = x.Insert(&actionRunAttempt{ + RepoID: 1, + RunID: 1, + Attempt: 2, + TriggerUserID: 2, + Status: 1, + }) + require.Error(t, err) +} + +func hasIndexWithColumns(indexes map[string]*schemas.Index, cols []string, isUnique bool) bool { + for _, index := range indexes { + if isUnique && index.Type != schemas.UniqueType { + continue + } + if slices.Equal(index.Cols, cols) { + return true + } + } + return false +} diff --git a/modules/setting/actions.go b/modules/setting/actions.go index 7a91ecb5930..0d1bdadc8ec 100644 --- a/modules/setting/actions.go +++ b/modules/setting/actions.go @@ -12,6 +12,8 @@ import ( "code.gitea.io/gitea/modules/log" ) +const defaultMaxRerunAttempts = 50 + // Actions settings var ( Actions = struct { @@ -27,11 +29,13 @@ var ( AbandonedJobTimeout time.Duration `ini:"ABANDONED_JOB_TIMEOUT"` SkipWorkflowStrings []string `ini:"SKIP_WORKFLOW_STRINGS"` WorkflowDirs []string `ini:"WORKFLOW_DIRS"` + MaxRerunAttempts int64 `ini:"MAX_RERUN_ATTEMPTS"` }{ Enabled: true, DefaultActionsURL: defaultActionsURLGitHub, SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"}, WorkflowDirs: []string{".gitea/workflows", ".github/workflows"}, + MaxRerunAttempts: defaultMaxRerunAttempts, } ) @@ -118,6 +122,10 @@ func loadActionsFrom(rootCfg ConfigProvider) error { Actions.EndlessTaskTimeout = sec.Key("ENDLESS_TASK_TIMEOUT").MustDuration(3 * time.Hour) Actions.AbandonedJobTimeout = sec.Key("ABANDONED_JOB_TIMEOUT").MustDuration(24 * time.Hour) + if Actions.MaxRerunAttempts <= 0 { + Actions.MaxRerunAttempts = defaultMaxRerunAttempts + } + if !Actions.LogCompression.IsValid() { return fmt.Errorf("invalid [actions] LOG_COMPRESSION: %q", Actions.LogCompression) } diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go index 92ca9bcccef..4592c18ed69 100644 --- a/modules/structs/repo_actions.go +++ b/modules/structs/repo_actions.go @@ -105,12 +105,18 @@ type ActionArtifact struct { // ActionWorkflowRun represents a WorkflowRun type ActionWorkflowRun struct { - ID int64 `json:"id"` - URL string `json:"url"` - HTMLURL string `json:"html_url"` - DisplayTitle string `json:"display_title"` - Path string `json:"path"` - Event string `json:"event"` + ID int64 `json:"id"` + URL string `json:"url"` + // PreviousAttemptURL is the API URL of the previous attempt of this run, e.g. ".../actions/runs/{run_id}/attempts/{attempt-1}". + // It is set only when the current attempt is > 1 (i.e. a rerun). For the first attempt, or for legacy runs that pre-date ActionRunAttempt, it is null. + PreviousAttemptURL *string `json:"previous_attempt_url"` + HTMLURL string `json:"html_url"` + DisplayTitle string `json:"display_title"` + Path string `json:"path"` + Event string `json:"event"` + // RunAttempt is 1-based for runs created after ActionRunAttempt was introduced. + // A value of 0 is a legacy-only sentinel for runs created before attempts existed + // and indicates no corresponding /attempts/{n} resource is available. RunAttempt int64 `json:"run_attempt"` RunNumber int64 `json:"run_number"` RepositoryID int64 `json:"repository_id,omitempty"` diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index f8828f95c68..18ce2c970a2 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3771,9 +3771,11 @@ "actions.runs.delete.description": "Are you sure you want to permanently delete this workflow run? This action cannot be undone.", "actions.runs.not_done": "This workflow run is not done.", "actions.runs.view_workflow_file": "View workflow file", - "actions.runs.workflow_graph": "Workflow Graph", "actions.runs.summary": "Summary", "actions.runs.all_jobs": "All jobs", + "actions.runs.attempt": "Attempt", + "actions.runs.latest": "Latest", + "actions.runs.latest_attempt": "Latest attempt", "actions.runs.triggered_via": "Triggered via %s", "actions.runs.total_duration": "Total duration:", "actions.workflow.disable": "Disable Workflow", diff --git a/routers/api/actions/artifacts.go b/routers/api/actions/artifacts.go index 13cbecb5cd0..838ddb7f917 100644 --- a/routers/api/actions/artifacts.go +++ b/routers/api/actions/artifacts.go @@ -74,6 +74,7 @@ import ( "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/util" @@ -310,7 +311,7 @@ func (ar artifactRoutes) confirmUploadArtifact(ctx *ArtifactContext) { ctx.HTTPError(http.StatusBadRequest, "Error artifact name is empty") return } - if err := mergeChunksForRun(ctx, ar.fs, runID, artifactName); err != nil { + if err := mergeChunksForRun(ctx, ar.fs, runID, ctx.ActionTask.Job.RunAttemptID, artifactName); err != nil { log.Error("Error merge chunks: %v", err) ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks") return @@ -338,8 +339,9 @@ func (ar artifactRoutes) listArtifacts(ctx *ArtifactContext) { } artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{ - RunID: runID, - Status: int(actions.ArtifactStatusUploadConfirmed), + RunID: runID, + RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID), + Status: int(actions.ArtifactStatusUploadConfirmed), }) if err != nil { log.Error("Error getting artifacts: %v", err) @@ -404,6 +406,7 @@ func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) { artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{ RunID: runID, + RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID), ArtifactName: itemPath, Status: int(actions.ArtifactStatusUploadConfirmed), }) @@ -477,6 +480,11 @@ func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) { ctx.HTTPError(http.StatusBadRequest) return } + if ctx.ActionTask.Job.RunAttemptID > 0 && artifact.RunAttemptID != ctx.ActionTask.Job.RunAttemptID { + log.Error("Error mismatch runAttemptID and artifactID, task: %v, artifact: %v", ctx.ActionTask.Job.RunAttemptID, artifactID) + ctx.HTTPError(http.StatusBadRequest) + return + } if artifact.Status != actions.ArtifactStatusUploadConfirmed { log.Error("Error artifact not found: %s", artifact.Status.ToString()) ctx.HTTPError(http.StatusNotFound, "Error artifact not found") diff --git a/routers/api/actions/artifacts_chunks.go b/routers/api/actions/artifacts_chunks.go index 8d04c689221..6f84f7a5cf8 100644 --- a/routers/api/actions/artifacts_chunks.go +++ b/routers/api/actions/artifacts_chunks.go @@ -20,6 +20,7 @@ import ( "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" ) @@ -257,10 +258,11 @@ func listOrderedChunksForArtifact(st storage.ObjectStorage, runID, artifactID in return emptyListAsError(chunks) } -func mergeChunksForRun(ctx *ArtifactContext, st storage.ObjectStorage, runID int64, artifactName string) error { +func mergeChunksForRun(ctx *ArtifactContext, st storage.ObjectStorage, runID, runAttemptID int64, artifactName string) error { // read all db artifacts by name artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{ RunID: runID, + RunAttemptID: optional.Some(runAttemptID), ArtifactName: artifactName, }) if err != nil { diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index e86645cb0cf..8bd3fb7e2b0 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -107,6 +107,7 @@ import ( "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/util" @@ -266,9 +267,9 @@ func (r *artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (* return task, artifactName, true } -func (r *artifactV4Routes) getArtifactByName(ctx *ArtifactContext, runID int64, name string) (*actions_model.ActionArtifact, error) { +func (r *artifactV4Routes) getArtifactByName(ctx *ArtifactContext, runID, runAttemptID int64, name string) (*actions_model.ActionArtifact, error) { var art actions_model.ActionArtifact - has, err := db.GetEngine(ctx).Where(builder.Eq{"run_id": runID, "artifact_name": name}, builder.Like{"content_encoding", "%/%"}).Get(&art) + has, err := db.GetEngine(ctx).Where(builder.Eq{"run_id": runID, "run_attempt_id": runAttemptID, "artifact_name": name}, builder.Like{"content_encoding", "%/%"}).Get(&art) if err != nil { return nil, err } else if !has { @@ -388,7 +389,7 @@ func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) { switch comp { case "block", "appendBlock": // get artifact by name - artifact, err := r.getArtifactByName(ctx, task.Job.RunID, artifactName) + artifact, err := r.getArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName) if err != nil { log.Error("Error artifact not found: %v", err) ctx.HTTPError(http.StatusNotFound, "Error artifact not found") @@ -475,7 +476,7 @@ func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) { } // get artifact by name - artifact, err := r.getArtifactByName(ctx, runID, req.Name) + artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name) if err != nil { log.Error("Error artifact not found: %v", err) ctx.HTTPError(http.StatusNotFound, "Error artifact not found") @@ -589,6 +590,7 @@ func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) { artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{ RunID: runID, + RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID), Status: int(actions_model.ArtifactStatusUploadConfirmed), FinalizedArtifactsV4: true, }) @@ -642,7 +644,7 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) { artifactName := req.Name // get artifact by name - artifact, err := r.getArtifactByName(ctx, runID, artifactName) + artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, artifactName) if err != nil { log.Error("Error artifact not found: %v", err) ctx.HTTPError(http.StatusNotFound, "Error artifact not found") @@ -676,7 +678,7 @@ func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) { } // get artifact by name - artifact, err := r.getArtifactByName(ctx, task.Job.RunID, artifactName) + artifact, err := r.getArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName) if err != nil { log.Error("Error artifact not found: %v", err) ctx.HTTPError(http.StatusNotFound, "Error artifact not found") @@ -707,14 +709,14 @@ func (r *artifactV4Routes) deleteArtifact(ctx *ArtifactContext) { } // get artifact by name - artifact, err := r.getArtifactByName(ctx, runID, req.Name) + artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name) if err != nil { log.Error("Error artifact not found: %v", err) ctx.HTTPError(http.StatusNotFound, "Error artifact not found") return } - err = actions_model.SetArtifactNeedDelete(ctx, runID, req.Name) + err = actions_model.SetArtifactNeedDeleteByRunAttempt(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name) if err != nil { log.Error("Error deleting artifacts: %v", err) ctx.HTTPError(http.StatusInternalServerError, err.Error()) diff --git a/routers/api/actions/runner/runner.go b/routers/api/actions/runner/runner.go index 886595be715..eee39760edd 100644 --- a/routers/api/actions/runner/runner.go +++ b/routers/api/actions/runner/runner.go @@ -15,7 +15,6 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/util" actions_service "code.gitea.io/gitea/services/actions" - notify_service "code.gitea.io/gitea/services/notify" runnerv1 "code.gitea.io/actions-proto-go/runner/v1" "code.gitea.io/actions-proto-go/runner/v1/runnerv1connect" @@ -224,7 +223,7 @@ func (s *Service) UpdateTask( actions_service.CreateCommitStatusForRunJobs(ctx, task.Job.Run, task.Job) if task.Status.IsDone() { - notify_service.WorkflowJobStatusUpdate(ctx, task.Job.Run.Repo, task.Job.Run.TriggerUser, task.Job, task) + actions_service.NotifyWorkflowJobStatusUpdateWithTask(ctx, task.Job, task) } if req.Msg.State.Result != runnerv1.Result_RESULT_UNSPECIFIED { @@ -232,7 +231,7 @@ func (s *Service) UpdateTask( log.Error("Emit ready jobs of run %d: %v", task.Job.RunID, err) } if task.Job.Run.Status.IsDone() { - actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, task.Job) + actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, task.Job.RepoID, task.Job.RunID) } } diff --git a/routers/api/v1/admin/action.go b/routers/api/v1/admin/action.go index 2fbb8e1a955..62e0c6addcb 100644 --- a/routers/api/v1/admin/action.go +++ b/routers/api/v1/admin/action.go @@ -37,7 +37,7 @@ func ListWorkflowJobs(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - shared.ListJobs(ctx, 0, 0, 0) + shared.ListJobs(ctx, 0, 0, 0, nil) } // ListWorkflowRuns Lists all runs diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 2d80692fef5..68849b87710 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1255,6 +1255,10 @@ func Routes() *web.Router { m.Group("/runs", func() { m.Group("/{run}", func() { m.Get("", repo.GetWorkflowRun) + m.Group("/attempts/{attempt}", func() { + m.Get("", repo.GetWorkflowRunAttempt) + m.Get("/jobs", repo.ListWorkflowRunAttemptJobs) + }) m.Delete("", reqToken(), reqRepoWriter(unit.TypeActions), repo.DeleteActionRun) m.Post("/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowRun) m.Post("/rerun-failed-jobs", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunFailedWorkflowRun) diff --git a/routers/api/v1/org/action.go b/routers/api/v1/org/action.go index 01b57b3fac9..d218c19fd43 100644 --- a/routers/api/v1/org/action.go +++ b/routers/api/v1/org/action.go @@ -624,7 +624,7 @@ func (Action) ListWorkflowJobs(ctx *context.APIContext) { // "$ref": "#/responses/error" // "404": // "$ref": "#/responses/notFound" - shared.ListJobs(ctx, ctx.Org.Organization.ID, 0, 0) + shared.ListJobs(ctx, ctx.Org.Organization.ID, 0, 0, nil) } func (Action) ListWorkflowRuns(ctx *context.APIContext) { diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 7ac8a10575c..8a0be250da1 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -23,6 +23,7 @@ import ( secret_model "code.gitea.io/gitea/models/secret" "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" @@ -676,7 +677,7 @@ func (Action) UpdateRunner(ctx *context.APIContext) { shared.UpdateRunner(ctx, 0, ctx.Repo.Repository.ID, ctx.PathParamInt64("runner_id")) } -// GetWorkflowRunJobs Lists all jobs for a workflow run. +// ListWorkflowJobs Lists all jobs for a repository. func (Action) ListWorkflowJobs(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/actions/jobs repository listWorkflowJobs // --- @@ -717,7 +718,7 @@ func (Action) ListWorkflowJobs(ctx *context.APIContext) { repoID := ctx.Repo.Repository.ID - shared.ListJobs(ctx, 0, repoID, 0) + shared.ListJobs(ctx, 0, repoID, 0, nil) } // ListWorkflowRuns Lists all runs for a repository run. @@ -1163,7 +1164,7 @@ func getCurrentRepoActionRunJobsByID(ctx *context.APIContext) (*actions_model.Ac return nil, nil } - jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID) if err != nil { ctx.APIErrorInternal(err) return nil, nil @@ -1171,6 +1172,24 @@ func getCurrentRepoActionRunJobsByID(ctx *context.APIContext) (*actions_model.Ac return run, jobs } +func getCurrentRepoActionRunAttemptByNumber(ctx *context.APIContext) (*actions_model.ActionRun, *actions_model.ActionRunAttempt) { + run := getCurrentRepoActionRunByID(ctx) + if ctx.Written() { + return nil, nil + } + + attemptNum := ctx.PathParamInt64("attempt") + attempt, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, run.ID, attemptNum) + if errors.Is(err, util.ErrNotExist) { + ctx.APIErrorNotFound(err) + return nil, nil + } else if err != nil { + ctx.APIErrorInternal(err) + return nil, nil + } + return run, attempt +} + // GetWorkflowRun Gets a specific workflow run. func GetWorkflowRun(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run} repository GetWorkflowRun @@ -1207,7 +1226,56 @@ func GetWorkflowRun(ctx *context.APIContext) { return } - convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run) + convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run, nil) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, convertedRun) +} + +// GetWorkflowRunAttempt Gets a specific workflow run attempt. +func GetWorkflowRunAttempt(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/attempts/{attempt} repository getWorkflowRunAttempt + // --- + // summary: Gets a specific workflow run attempt + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: run + // in: path + // description: id of the run + // type: integer + // required: true + // - name: attempt + // in: path + // description: logical attempt number of the run + // type: integer + // required: true + // responses: + // "200": + // "$ref": "#/responses/WorkflowRun" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + run, attempt := getCurrentRepoActionRunAttemptByNumber(ctx) + if ctx.Written() { + return + } + + convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run, attempt) if err != nil { ctx.APIErrorInternal(err) return @@ -1247,6 +1315,8 @@ func RerunWorkflowRun(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" + // "409": + // "$ref": "#/responses/error" // "422": // "$ref": "#/responses/validationError" @@ -1255,12 +1325,12 @@ func RerunWorkflowRun(ctx *context.APIContext) { return } - if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs); err != nil { + if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, jobs); err != nil { handleWorkflowRerunError(ctx, err) return } - convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run) + convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run, nil) if err != nil { ctx.APIErrorInternal(err) return @@ -1298,6 +1368,8 @@ func RerunFailedWorkflowRun(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" + // "409": + // "$ref": "#/responses/error" // "422": // "$ref": "#/responses/validationError" @@ -1306,7 +1378,7 @@ func RerunFailedWorkflowRun(ctx *context.APIContext) { return } - if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, actions_service.GetFailedRerunJobs(jobs)); err != nil { + if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, actions_service.GetFailedJobsForRerun(jobs)); err != nil { handleWorkflowRerunError(ctx, err) return } @@ -1351,6 +1423,8 @@ func RerunWorkflowJob(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" + // "409": + // "$ref": "#/responses/error" // "422": // "$ref": "#/responses/validationError" @@ -1367,12 +1441,28 @@ func RerunWorkflowJob(ctx *context.APIContext) { } targetJob := jobs[jobIdx] - if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, actions_service.GetAllRerunJobs(targetJob, jobs)); err != nil { + newAttempt, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, []*actions_model.ActionRunJob{targetJob}) + if err != nil { handleWorkflowRerunError(ctx, err) return } - convertedJob, err := convert.ToActionWorkflowJob(ctx, ctx.Repo.Repository, nil, targetJob) + // Legacy jobs had AttemptJobID=0 before the rerun; createOriginalAttemptForLegacyRun inside + // RerunWorkflowRunJobs has since backfilled it in the DB, so reload only in that case. + if targetJob.AttemptJobID == 0 { + targetJob, err = actions_model.GetRunJobByRepoAndID(ctx, run.RepoID, targetJob.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + } + rerunJob, err := actions_model.GetRunJobByAttemptJobID(ctx, run.ID, newAttempt.ID, targetJob.AttemptJobID) + if err != nil { + handleWorkflowRerunError(ctx, err) + return + } + + convertedJob, err := convert.ToActionWorkflowJob(ctx, ctx.Repo.Repository, nil, rerunJob) if err != nil { ctx.APIErrorInternal(err) return @@ -1384,6 +1474,12 @@ func handleWorkflowRerunError(ctx *context.APIContext, err error) { if errors.Is(err, util.ErrInvalidArgument) { ctx.APIError(http.StatusBadRequest, err) return + } else if errors.Is(err, util.ErrAlreadyExist) { + ctx.APIError(http.StatusConflict, err) + return + } else if errors.Is(err, util.ErrNotExist) { + ctx.APIError(http.StatusNotFound, err) + return } ctx.APIErrorInternal(err) } @@ -1440,9 +1536,75 @@ func ListWorkflowRunJobs(ctx *context.APIContext) { return } + run, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.APIErrorNotFound(err) + } else { + ctx.APIErrorInternal(err) + } + return + } // runID is used as an additional filter next to repoID to ensure that we only list jobs for the specified repoID and runID. // no additional checks for runID are needed here - shared.ListJobs(ctx, 0, repoID, runID) + shared.ListJobs(ctx, 0, repoID, runID, optional.Some(run.LatestAttemptID)) +} + +// ListWorkflowRunAttemptJobs Lists all jobs for a workflow run attempt. +func ListWorkflowRunAttemptJobs(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/attempts/{attempt}/jobs repository listWorkflowRunAttemptJobs + // --- + // summary: Lists all jobs for a workflow run attempt + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: run + // in: path + // description: id of the workflow run + // type: integer + // required: true + // - name: attempt + // in: path + // description: logical attempt number of the run + // type: integer + // required: true + // - name: status + // in: query + // description: workflow status (pending, queued, in_progress, failure, success, skipped) + // type: string + // required: false + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/WorkflowJobsList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + run, attempt := getCurrentRepoActionRunAttemptByNumber(ctx) + if ctx.Written() { + return + } + + shared.ListJobs(ctx, 0, run.RepoID, run.ID, optional.Some(attempt.ID)) } // GetWorkflowJob Gets a specific workflow job for a workflow run. @@ -1758,7 +1920,7 @@ func DeleteArtifact(ctx *context.APIContext) { } if actions.IsArtifactV4(art) { - if err := actions_model.SetArtifactNeedDelete(ctx, art.RunID, art.ArtifactName); err != nil { + if err := actions_model.SetArtifactNeedDeleteByID(ctx, art.ID); err != nil { ctx.APIErrorInternal(err) return } diff --git a/routers/api/v1/shared/action.go b/routers/api/v1/shared/action.go index 715e76c3557..1b12023d7a0 100644 --- a/routers/api/v1/shared/action.go +++ b/routers/api/v1/shared/action.go @@ -12,6 +12,7 @@ import ( 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/optional" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/webhook" @@ -27,8 +28,9 @@ import ( // ownerID != 0 and repoID != 0 undefined behavior // runID == 0 means all jobs // runID is used as an additional filter together with ownerID and repoID to only return jobs for the given run +// runAttemptID, when set, additionally limits the result to jobs of the specified run attempt. Only takes effect when runID > 0. // Access rights are checked at the API route level -func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64) { +func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64, runAttemptID optional.Option[int64]) { if ownerID != 0 && repoID != 0 { setting.PanicInDevOrTesting("ownerID and repoID should not be both set") } @@ -39,6 +41,9 @@ func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64) { RunID: runID, ListOptions: listOptions, } + if runID > 0 { + opts.RunAttemptID = runAttemptID + } for _, status := range ctx.FormStrings("status") { values, err := convertToInternal(status) if err != nil { @@ -178,7 +183,7 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64) { } } - convertedRun, err := convert.ToActionWorkflowRun(ctx, repository, runs[i]) + convertedRun, err := convert.ToActionWorkflowRun(ctx, repository, runs[i], nil) if err != nil { ctx.APIErrorInternal(err) return diff --git a/routers/api/v1/user/action.go b/routers/api/v1/user/action.go index 573e2e4dd08..4de0b30d983 100644 --- a/routers/api/v1/user/action.go +++ b/routers/api/v1/user/action.go @@ -439,5 +439,5 @@ func ListWorkflowJobs(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - shared.ListJobs(ctx, ctx.Doer.ID, 0, 0) + shared.ListJobs(ctx, ctx.Doer.ID, 0, 0, nil) } diff --git a/routers/common/actions.go b/routers/common/actions.go index 4eb7078db67..2b83e5d8423 100644 --- a/routers/common/actions.go +++ b/routers/common/actions.go @@ -31,7 +31,8 @@ func DownloadActionsRunJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository return util.NewNotExistErrorf("job not found") } - if curJob.TaskID == 0 { + taskID := curJob.EffectiveTaskID() + if taskID == 0 { return util.NewNotExistErrorf("job not started") } @@ -39,7 +40,7 @@ func DownloadActionsRunJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository return fmt.Errorf("LoadRun: %w", err) } - task, err := actions_model.GetTaskByID(ctx, curJob.TaskID) + task, err := actions_model.GetTaskByID(ctx, taskID) if err != nil { return fmt.Errorf("GetTaskByID: %w", err) } diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go index 51c13113e59..a74efaf54e5 100644 --- a/routers/web/devtest/mock_actions.go +++ b/routers/web/devtest/mock_actions.go @@ -4,6 +4,7 @@ package devtest import ( + "fmt" mathRand "math/rand/v2" "net/http" "slices" @@ -12,7 +13,9 @@ import ( "time" actions_model "code.gitea.io/gitea/models/actions" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/web/repo/actions" @@ -59,13 +62,18 @@ func generateMockStepsLog(logCur actions.LogCursor, opts generateMockStepsLogOpt } func MockActionsView(ctx *context.Context) { - ctx.Data["RunID"] = ctx.PathParamInt64("run") + if runID := ctx.PathParamInt64("run"); runID == 0 { + ctx.Redirect("/repo-action-view/runs/10") + return + } ctx.Data["JobID"] = ctx.PathParamInt64("job") + ctx.Data["ActionsViewURL"] = ctx.Req.URL.Path ctx.HTML(http.StatusOK, "devtest/repo-action-view") } func MockActionsRunsJobs(ctx *context.Context) { runID := ctx.PathParamInt64("run") + attemptID := ctx.PathParamInt64("attempt") alignTime := func(v, unit int64) int64 { return (v + unit) / unit * unit @@ -74,16 +82,9 @@ func MockActionsRunsJobs(ctx *context.Context) { resp.State.Run.RepoID = 12345 resp.State.Run.TitleHTML = `mock run title link` resp.State.Run.Link = setting.AppSubURL + "/devtest/repo-action-view/runs/" + strconv.FormatInt(runID, 10) - resp.State.Run.Status = actions_model.StatusRunning.String() - resp.State.Run.CanCancel = runID == 10 - resp.State.Run.CanApprove = runID == 20 - resp.State.Run.CanRerun = runID == 30 - resp.State.Run.CanRerunFailed = runID == 30 resp.State.Run.CanDeleteArtifact = true resp.State.Run.WorkflowID = "workflow-id" resp.State.Run.WorkflowLink = "./workflow-link" - resp.State.Run.Duration = "1h 23m 45s" - resp.State.Run.TriggeredAt = time.Now().Add(-time.Hour).Unix() resp.State.Run.TriggerEvent = "push" resp.State.Run.Commit = actions.ViewCommit{ ShortSha: "ccccdddd", @@ -98,6 +99,88 @@ func MockActionsRunsJobs(ctx *context.Context) { IsDeleted: false, }, } + now := time.Now() + currentAttemptNum := int64(1) + if attemptID > 0 { + currentAttemptNum = attemptID + } + user2 := &user_model.User{Name: "user2"} + user3 := &user_model.User{Name: "user3"} + attempts := []*actions_model.ActionRunAttempt{{ + Attempt: 1, + Status: actions_model.StatusSuccess, + Created: timeutil.TimeStamp(now.Add(-time.Hour).Unix()), + TriggerUserID: 2, + TriggerUser: user2, + }} + if runID == 10 { + attempts = []*actions_model.ActionRunAttempt{ + { + Attempt: 3, + Status: actions_model.StatusSuccess, + Created: timeutil.TimeStamp(alignTime(now.Add(-time.Hour).Unix(), 3600)), + TriggerUserID: 2, + TriggerUser: user2, + }, + { + Attempt: 2, + Status: actions_model.StatusFailure, + Created: timeutil.TimeStamp(alignTime(now.Add(-2*time.Hour).Unix(), 3600)), + TriggerUserID: 1, + TriggerUser: user3, + }, + { + Attempt: 1, + Status: actions_model.StatusSuccess, + Created: timeutil.TimeStamp(alignTime(now.Add(-3*time.Hour).Unix(), 3600)), + TriggerUserID: 2, + TriggerUser: user2, + }, + } + if attemptID == 0 { + currentAttemptNum = 3 + } + } + + latestAttempt := attempts[0] + resp.State.Run.RunAttempt = currentAttemptNum + resp.State.Run.Done = latestAttempt.Status.IsDone() + resp.State.Run.Status = latestAttempt.Status.String() + resp.State.Run.Duration = "1h 23m 45s" + resp.State.Run.TriggeredAt = latestAttempt.Created.AsTime().Unix() + resp.State.Run.ViewLink = resp.State.Run.Link + for _, attempt := range attempts { + link := resp.State.Run.Link + if attempt.Attempt != latestAttempt.Attempt { + link = fmt.Sprintf("%s/attempts/%d", resp.State.Run.Link, attempt.Attempt) + } + current := attempt.Attempt == currentAttemptNum + if current { + resp.State.Run.Status = attempt.Status.String() + resp.State.Run.Done = attempt.Status.IsDone() + resp.State.Run.TriggeredAt = attempt.Created.AsTime().Unix() + if attempt.Attempt != latestAttempt.Attempt { + resp.State.Run.ViewLink = link + } + } + resp.State.Run.Attempts = append(resp.State.Run.Attempts, &actions.ViewRunAttempt{ + Attempt: attempt.Attempt, + Status: attempt.Status.String(), + Done: attempt.Status.IsDone(), + Link: link, + Current: current, + Latest: attempt.Attempt == latestAttempt.Attempt, + TriggeredAt: attempt.Created.AsTime().Unix(), + TriggerUserName: attempt.TriggerUser.GetDisplayName(), + TriggerUserLink: attempt.TriggerUser.HomeLink(), + }) + } + isLatestAttempt := currentAttemptNum == latestAttempt.Attempt + resp.State.Run.CanCancel = runID == 10 && isLatestAttempt + resp.State.Run.CanApprove = runID == 20 && isLatestAttempt + resp.State.Run.CanRerun = runID == 30 && isLatestAttempt + resp.State.Run.CanRerunFailed = runID == 30 && isLatestAttempt + resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{ Name: "artifact-a", Size: 100 * 1024, @@ -123,8 +206,13 @@ func MockActionsRunsJobs(ctx *context.Context) { ExpiresUnix: 0, }) + jobLink := func(jobID int64) string { + return fmt.Sprintf("%s/jobs/%d", resp.State.Run.Link, jobID) + } + resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{ ID: runID * 10, + Link: jobLink(runID * 10), JobID: "job-100", Name: "job 100 (testsubname)", Status: actions_model.StatusRunning.String(), @@ -133,6 +221,7 @@ func MockActionsRunsJobs(ctx *context.Context) { }) resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{ ID: runID*10 + 1, + Link: jobLink(runID*10 + 1), JobID: "job-101", Name: "job 101", Status: actions_model.StatusWaiting.String(), @@ -142,6 +231,7 @@ func MockActionsRunsJobs(ctx *context.Context) { }) resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{ ID: runID*10 + 2, + Link: jobLink(runID*10 + 2), JobID: "job-102", Name: "ULTRA LOOOOOOOOOOOONG job name 102 that exceeds the limit", Status: actions_model.StatusFailure.String(), @@ -151,6 +241,7 @@ func MockActionsRunsJobs(ctx *context.Context) { }) resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{ ID: runID*10 + 3, + Link: jobLink(runID*10 + 3), JobID: "job-103", Name: "job 103", Status: actions_model.StatusCancelled.String(), @@ -162,8 +253,10 @@ func MockActionsRunsJobs(ctx *context.Context) { // add more jobs to a run for UI testing if resp.State.Run.CanCancel { for i := range 10 { + jobID := runID*1000 + int64(i) resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{ - ID: runID*1000 + int64(i), + ID: jobID, + Link: jobLink(jobID), JobID: "job-dup-test-" + strconv.Itoa(i), Name: "job dup test " + strconv.Itoa(i), Status: actions_model.StatusSuccess.String(), @@ -184,6 +277,14 @@ func fillViewRunResponseCurrentJob(ctx *context.Context, resp *actions.ViewRespo return } + for _, job := range resp.State.Run.Jobs { + if job.ID == jobID { + resp.State.CurrentJob.Title = job.Name + resp.State.CurrentJob.Detail = job.Status + break + } + } + req := web.GetForm(ctx).(*actions.ViewRequest) var mockLogOptions []generateMockStepsLogOptions resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{ diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index 644a53f28a0..a6a6e539b98 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -311,7 +311,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo) { if !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning) { continue } - jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID) if err != nil { ctx.ServerError("GetRunJobsByRunID", err) return diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index fb4dfa9603d..b5b72b4f125 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -34,7 +34,6 @@ import ( "code.gitea.io/gitea/routers/common" actions_service "code.gitea.io/gitea/services/actions" context_module "code.gitea.io/gitea/services/context" - notify_service "code.gitea.io/gitea/services/notify" "github.com/nektos/act/pkg/model" ) @@ -166,7 +165,7 @@ func resolveCurrentRunForView(ctx *context_module.Context) *actions_model.Action return nil } if run != nil { - jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID) if err != nil { ctx.ServerError("GetRunJobsByRunID", err) return nil @@ -203,9 +202,23 @@ func View(ctx *context_module.Context) { if ctx.Written() { return } - ctx.Data["RunID"] = run.ID - ctx.Data["JobID"] = ctx.PathParamInt64("job") // it can be 0 when no job (e.g.: run summary view) - ctx.Data["ActionsURL"] = ctx.Repo.RepoLink + "/actions" + run.Repo = ctx.Repo.Repository + + jobID := ctx.PathParamInt64("job") + ctx.Data["JobID"] = jobID // it can be 0 when no job (e.g.: run summary view) + + attemptNum := ctx.PathParamInt64("attempt") + + // ActionsViewURL is the endpoint for viewing a run (job summary), a job, or a job attempt. + // It's POST method handler can provide the state data for the frontend rendering. + switch { + case attemptNum > 0: + ctx.Data["ActionsViewURL"] = fmt.Sprintf("%s/attempts/%d", run.Link(), attemptNum) + case jobID > 0: + ctx.Data["ActionsViewURL"] = fmt.Sprintf("%s/jobs/%d", run.Link(), jobID) + default: + ctx.Data["ActionsViewURL"] = run.Link() + } ctx.HTML(http.StatusOK, tplViewActions) } @@ -259,22 +272,30 @@ type ViewResponse struct { State struct { Run struct { - RepoID int64 `json:"repoId"` - Link string `json:"link"` - Title string `json:"title"` - TitleHTML template.HTML `json:"titleHTML"` - Status string `json:"status"` - CanCancel bool `json:"canCancel"` - CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve - CanRerun bool `json:"canRerun"` - CanRerunFailed bool `json:"canRerunFailed"` - CanDeleteArtifact bool `json:"canDeleteArtifact"` - Done bool `json:"done"` - WorkflowID string `json:"workflowID"` - WorkflowLink string `json:"workflowLink"` - IsSchedule bool `json:"isSchedule"` - Jobs []*ViewJob `json:"jobs"` - Commit ViewCommit `json:"commit"` + RepoID int64 `json:"repoId"` + // Link is the canonical HTML URL of the run, e.g. "/owner/repo/actions/runs/123". + // Used as the base for composing sub-resource URLs (cancel, rerun, artifacts, jobs) that are not attempt-scoped. + Link string `json:"link"` + // ViewLink is the attempt-aware URL for navigation, e.g. "/owner/repo/actions/runs/123" for the latest attempt + // or "/owner/repo/actions/runs/123/attempts/2" for a historical attempt. + // Use this when the target should reflect the currently-viewed attempt. + ViewLink string `json:"viewLink"` + Title string `json:"title"` + TitleHTML template.HTML `json:"titleHTML"` + Status string `json:"status"` + CanCancel bool `json:"canCancel"` + CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve + CanRerun bool `json:"canRerun"` + CanRerunFailed bool `json:"canRerunFailed"` + CanDeleteArtifact bool `json:"canDeleteArtifact"` + Done bool `json:"done"` + WorkflowID string `json:"workflowID"` + WorkflowLink string `json:"workflowLink"` + IsSchedule bool `json:"isSchedule"` + RunAttempt int64 `json:"runAttempt"` + Attempts []*ViewRunAttempt `json:"attempts"` + Jobs []*ViewJob `json:"jobs"` + Commit ViewCommit `json:"commit"` // Summary view: run duration and trigger time/event Duration string `json:"duration"` TriggeredAt int64 `json:"triggeredAt"` // unix seconds for relative time @@ -293,6 +314,7 @@ type ViewResponse struct { type ViewJob struct { ID int64 `json:"id"` + Link string `json:"link"` JobID string `json:"jobId,omitempty"` Name string `json:"name"` Status string `json:"status"` @@ -301,6 +323,18 @@ type ViewJob struct { Needs []string `json:"needs,omitempty"` } +type ViewRunAttempt struct { + Attempt int64 `json:"attempt"` + Status string `json:"status"` + Done bool `json:"done"` + Link string `json:"link"` + Current bool `json:"current"` + Latest bool `json:"latest"` + TriggeredAt int64 `json:"triggeredAt"` + TriggerUserName string `json:"triggerUserName"` + TriggerUserLink string `json:"triggerUserLink"` +} + type ViewCommit struct { ShortSha string `json:"shortSHA"` Link string `json:"link"` @@ -338,24 +372,8 @@ type ViewStepLogLine struct { Timestamp float64 `json:"timestamp"` } -func getActionsViewArtifacts(ctx context.Context, repoID, runID int64) (artifactsViewItems []*ArtifactsViewItem, err error) { - artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, repoID, runID) - if err != nil { - return nil, err - } - for _, art := range artifacts { - artifactsViewItems = append(artifactsViewItems, &ArtifactsViewItem{ - Name: art.ArtifactName, - Size: art.FileSize, - Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"), - ExpiresUnix: int64(art.ExpiredUnix), - }) - } - return artifactsViewItems, nil -} - func ViewPost(ctx *context_module.Context) { - run, jobs := getCurrentRunJobsByPathParam(ctx) + run, attempt, jobs := getCurrentRunJobsByPathParam(ctx) if ctx.Written() { return } @@ -365,7 +383,7 @@ func ViewPost(ctx *context_module.Context) { } resp := &ViewResponse{} - fillViewRunResponseSummary(ctx, resp, run, jobs) + fillViewRunResponseSummary(ctx, resp, run, attempt, jobs) if ctx.Written() { return } @@ -376,23 +394,33 @@ func ViewPost(ctx *context_module.Context) { ctx.JSON(http.StatusOK, resp) } -func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) { - var err error - resp.Artifacts, err = getActionsViewArtifacts(ctx, ctx.Repo.Repository.ID, run.ID) - if err != nil { - ctx.ServerError("getActionsViewArtifacts", err) - return - } +func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, jobs []*actions_model.ActionRunJob) { + // Latest when the run has no attempts yet (legacy) or the viewed attempt is the run's latest. + isLatestAttempt := run.LatestAttemptID == 0 || (attempt != nil && attempt.ID == run.LatestAttemptID) resp.State.Run.RepoID = ctx.Repo.Repository.ID // the title for the "run" is from the commit message resp.State.Run.Title = run.Title resp.State.Run.TitleHTML = templates.NewRenderUtils(ctx).RenderCommitMessage(run.Title, ctx.Repo.Repository) resp.State.Run.Link = run.Link() - resp.State.Run.CanCancel = !run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) - resp.State.Run.CanApprove = run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions) - resp.State.Run.CanRerun = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) - resp.State.Run.CanDeleteArtifact = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) + resp.State.Run.ViewLink = getRunViewLink(run, attempt) + resp.State.Run.Attempts = make([]*ViewRunAttempt, 0) + if attempt != nil { + resp.State.Run.RunAttempt = attempt.Attempt + resp.State.Run.Status = attempt.Status.String() + resp.State.Run.Done = attempt.Status.IsDone() + resp.State.Run.Duration = attempt.Duration().String() + resp.State.Run.TriggeredAt = attempt.Created.AsTime().Unix() + } else { + resp.State.Run.Status = run.Status.String() + resp.State.Run.Done = run.Status.IsDone() + resp.State.Run.Duration = run.Duration().String() + resp.State.Run.TriggeredAt = run.Created.AsTime().Unix() + } + resp.State.Run.CanCancel = isLatestAttempt && !resp.State.Run.Done && ctx.Repo.CanWrite(unit.TypeActions) + resp.State.Run.CanApprove = isLatestAttempt && run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions) + resp.State.Run.CanRerun = isLatestAttempt && resp.State.Run.Done && ctx.Repo.CanWrite(unit.TypeActions) + resp.State.Run.CanDeleteArtifact = resp.State.Run.Done && ctx.Repo.CanWrite(unit.TypeActions) if resp.State.Run.CanRerun { for _, job := range jobs { if job.Status == actions_model.StatusFailure || job.Status == actions_model.StatusCancelled { @@ -401,15 +429,16 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, } } } - resp.State.Run.Done = run.Status.IsDone() resp.State.Run.WorkflowID = run.WorkflowID - resp.State.Run.WorkflowLink = run.WorkflowLink() + if isLatestAttempt { + resp.State.Run.WorkflowLink = run.WorkflowLink() + } resp.State.Run.IsSchedule = run.IsSchedule() resp.State.Run.Jobs = make([]*ViewJob, 0, len(jobs)) // marshal to '[]' instead fo 'null' in json - resp.State.Run.Status = run.Status.String() for _, v := range jobs { resp.State.Run.Jobs = append(resp.State.Run.Jobs, &ViewJob{ ID: v.ID, + Link: fmt.Sprintf("%s/jobs/%d", run.Link(), v.ID), JobID: v.JobID, Name: v.Name, Status: v.Status.String(), @@ -419,6 +448,29 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, }) } + attempts, err := actions_model.ListRunAttemptsByRunID(ctx, run.ID) + if err != nil { + ctx.ServerError("ListRunAttemptsByRunID", err) + return + } + if err := attempts.LoadTriggerUser(ctx); err != nil { + ctx.ServerError("LoadTriggerUser", err) + return + } + for _, runAttempt := range attempts { + resp.State.Run.Attempts = append(resp.State.Run.Attempts, &ViewRunAttempt{ + Attempt: runAttempt.Attempt, + Status: runAttempt.Status.String(), + Done: runAttempt.Status.IsDone(), + Link: getRunViewLink(run, runAttempt), + Current: runAttempt.ID == attempt.ID, + Latest: runAttempt.ID == run.LatestAttemptID, + TriggeredAt: runAttempt.Created.AsTime().Unix(), + TriggerUserName: runAttempt.TriggerUser.GetDisplayName(), + TriggerUserLink: runAttempt.TriggerUser.HomeLink(), + }) + } + pusher := ViewUser{ DisplayName: run.TriggerUser.GetDisplayName(), Link: run.TriggerUser.HomeLink(), @@ -443,9 +495,27 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, Pusher: pusher, Branch: branch, } - resp.State.Run.Duration = run.Duration().String() - resp.State.Run.TriggeredAt = run.Created.AsTime().Unix() resp.State.Run.TriggerEvent = run.TriggerEvent + + // Legacy runs (LatestAttemptID == 0) have no attempt; their artifacts all share run_attempt_id=0, + // so passing 0 here scopes to this run's legacy artifacts only. + var runAttemptID int64 + if attempt != nil { + runAttemptID = attempt.ID + } + arts, err := actions_model.ListUploadedArtifactsMetaByRunAttempt(ctx, ctx.Repo.Repository.ID, run.ID, runAttemptID) + if err != nil { + ctx.ServerError("ListUploadedArtifactsMetaByRunAttempt", err) + return + } + resp.Artifacts = make([]*ArtifactsViewItem, 0, len(arts)) + for _, art := range arts { + resp.Artifacts = append(resp.Artifacts, &ArtifactsViewItem{ + Name: art.ArtifactName, + Size: art.FileSize, + Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"), + }) + } } func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) { @@ -459,9 +529,9 @@ func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewRespon } var task *actions_model.ActionTask - if current.TaskID > 0 { + if effectiveTaskID := current.EffectiveTaskID(); effectiveTaskID > 0 { var err error - task, err = actions_model.GetTaskByID(ctx, current.TaskID) + task, err = actions_model.GetTaskByID(ctx, effectiveTaskID) if err != nil { ctx.ServerError("actions_model.GetTaskByID", err) return @@ -589,13 +659,24 @@ func checkRunRerunAllowed(ctx *context_module.Context, run *actions_model.Action return true } +func checkLatestAttempt(ctx *context_module.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt) bool { + if attempt != nil && run.LatestAttemptID != attempt.ID { + ctx.NotFound(nil) + return false + } + return true +} + // Rerun will rerun jobs in the given run // If jobIDStr is a blank string, it means rerun all jobs func Rerun(ctx *context_module.Context) { - run, jobs := getCurrentRunJobsByPathParam(ctx) + run, attempt, jobs := getCurrentRunJobsByPathParam(ctx) if ctx.Written() { return } + if !checkLatestAttempt(ctx, run, attempt) { + return + } if !checkRunRerunAllowed(ctx, run) { return } @@ -608,35 +689,48 @@ func Rerun(ctx *context_module.Context) { var jobsToRerun []*actions_model.ActionRunJob if currentJob != nil { - jobsToRerun = actions_service.GetAllRerunJobs(currentJob, jobs) - } else { - jobsToRerun = jobs + jobsToRerun = []*actions_model.ActionRunJob{currentJob} } - if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobsToRerun); err != nil { - ctx.ServerError("RerunWorkflowRunJobs", err) + if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, jobsToRerun); err != nil { + handleWorkflowRerunError(ctx, err) return } - ctx.JSONOK() + ctx.JSONRedirect(run.Link()) } // RerunFailed reruns all failed jobs in the given run func RerunFailed(ctx *context_module.Context) { - run, jobs := getCurrentRunJobsByPathParam(ctx) + run, attempt, jobs := getCurrentRunJobsByPathParam(ctx) if ctx.Written() { return } + if !checkLatestAttempt(ctx, run, attempt) { + return + } if !checkRunRerunAllowed(ctx, run) { return } - if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, actions_service.GetFailedRerunJobs(jobs)); err != nil { - ctx.ServerError("RerunWorkflowRunJobs", err) + if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, actions_service.GetFailedJobsForRerun(jobs)); err != nil { + handleWorkflowRerunError(ctx, err) return } - ctx.JSONOK() + ctx.JSONRedirect(run.Link()) +} + +func handleWorkflowRerunError(ctx *context_module.Context, err error) { + if errors.Is(err, util.ErrAlreadyExist) { + ctx.JSON(http.StatusConflict, map[string]any{"message": err.Error()}) + return + } + if errors.Is(err, util.ErrInvalidArgument) { + ctx.JSON(http.StatusBadRequest, map[string]any{"message": err.Error()}) + return + } + ctx.ServerError("RerunWorkflowRunJobs", err) } func Logs(ctx *context_module.Context) { @@ -654,10 +748,13 @@ func Logs(ctx *context_module.Context) { } func Cancel(ctx *context_module.Context) { - run, jobs := getCurrentRunJobsByPathParam(ctx) + run, attempt, jobs := getCurrentRunJobsByPathParam(ctx) if ctx.Written() { return } + if !checkLatestAttempt(ctx, run, attempt) { + return + } var updatedJobs []*actions_model.ActionRunJob @@ -676,13 +773,9 @@ func Cancel(ctx *context_module.Context) { actions_service.CreateCommitStatusForRunJobs(ctx, run, jobs...) actions_service.EmitJobsIfReadyByJobs(updatedJobs) - for _, job := range updatedJobs { - _ = job.LoadAttributes(ctx) - notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) - } + actions_service.NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...) if len(updatedJobs) > 0 { - job := updatedJobs[0] - actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job) + actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, run.RepoID, run.ID) } ctx.JSONOK() } @@ -692,78 +785,14 @@ func Approve(ctx *context_module.Context) { if ctx.Written() { return } - approveRuns(ctx, []int64{run.ID}) - if ctx.Written() { - return - } - - ctx.JSONOK() -} - -func approveRuns(ctx *context_module.Context, runIDs []int64) { - doer := ctx.Doer - repo := ctx.Repo.Repository - - updatedJobs := make([]*actions_model.ActionRunJob, 0) - runMap := make(map[int64]*actions_model.ActionRun, len(runIDs)) - runJobs := make(map[int64][]*actions_model.ActionRunJob, len(runIDs)) - - err := db.WithTx(ctx, func(ctx context.Context) (err error) { - for _, runID := range runIDs { - run, err := actions_model.GetRunByRepoAndID(ctx, repo.ID, runID) - if err != nil { - return err - } - runMap[run.ID] = run - run.Repo = repo - run.NeedApproval = false - run.ApprovedBy = doer.ID - if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil { - return err - } - jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) - if err != nil { - return err - } - runJobs[run.ID] = jobs - for _, job := range jobs { - job.Status, err = actions_service.PrepareToStartJobWithConcurrency(ctx, job) - if err != nil { - return err - } - if job.Status == actions_model.StatusWaiting { - n, err := actions_model.UpdateRunJob(ctx, job, nil, "status") - if err != nil { - return err - } - if n > 0 { - updatedJobs = append(updatedJobs, job) - } - } - } - } - return nil - }) - if err != nil { - ctx.NotFoundOrServerError("approveRuns", func(err error) bool { + if err := actions_service.ApproveRuns(ctx, ctx.Repo.Repository, ctx.Doer, []int64{run.ID}); err != nil { + ctx.NotFoundOrServerError("ApproveRuns", func(err error) bool { return errors.Is(err, util.ErrNotExist) }, err) return } - for runID, run := range runMap { - actions_service.CreateCommitStatusForRunJobs(ctx, run, runJobs[runID]...) - } - - if len(updatedJobs) > 0 { - job := updatedJobs[0] - actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job) - } - - for _, job := range updatedJobs { - _ = job.LoadAttributes(ctx) - notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) - } + ctx.JSONOK() } func Delete(ctx *context_module.Context) { @@ -785,28 +814,108 @@ func Delete(ctx *context_module.Context) { ctx.JSONOK() } -// getRunJobs loads the run and its jobs for runID +func getRunViewLink(run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt) string { + if attempt == nil || run.LatestAttemptID == attempt.ID { + return run.Link() + } + return fmt.Sprintf("%s/attempts/%d", run.Link(), attempt.Attempt) +} + +// getCurrentRunJobsByPathParam resolves the current run view context from path parameters, including the run, optional attempt, and jobs to render. // Any error will be written to the ctx, empty jobs will also result in 404 error, then the return values are all nil. -func getCurrentRunJobsByPathParam(ctx *context_module.Context) (*actions_model.ActionRun, []*actions_model.ActionRunJob) { +func getCurrentRunJobsByPathParam(ctx *context_module.Context) (*actions_model.ActionRun, *actions_model.ActionRunAttempt, []*actions_model.ActionRunJob) { run := getCurrentRunByPathParam(ctx) if ctx.Written() { - return nil, nil + return nil, nil, nil } run.Repo = ctx.Repo.Repository - jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + + var err error + var selectedJob *actions_model.ActionRunJob + if ctx.PathParam("job") != "" { + jobID := ctx.PathParamInt64("job") + selectedJob, err = actions_model.GetRunJobByRunAndID(ctx, run.ID, jobID) + if err != nil { + ctx.NotFoundOrServerError("GetRunJobByRepoAndID", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) + return nil, nil, nil + } + } + + // Resolve the attempt to display. + // Priority: explicit path param (/attempts/:num) > job's attempt (when navigating to a specific job) > latest attempt. + // attempt may be nil for legacy runs that pre-date ActionRunAttempt; callers must handle that case. + attemptNum := ctx.PathParamInt64("attempt") + var attempt *actions_model.ActionRunAttempt + switch { + case attemptNum > 0: + // Explicit attempt number in the URL — user is viewing a historical attempt. + attempt, err = actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, run.ID, attemptNum) + if err != nil { + ctx.NotFoundOrServerError("GetRunAttemptByRunIDAndAttempt", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) + return nil, nil, nil + } + case selectedJob != nil && selectedJob.RunAttemptID > 0: + // No explicit attempt in the URL, but the requested job belongs to a known attempt — resolve via the job. + attempt, err = actions_model.GetRunAttemptByRepoAndID(ctx, selectedJob.RepoID, selectedJob.RunAttemptID) + if err != nil { + ctx.NotFoundOrServerError("GetRunAttemptByRepoAndID", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) + return nil, nil, nil + } + default: + // No attempt context at all — show the latest attempt (nil for legacy runs). + attempt, _, err = run.GetLatestAttempt(ctx) + if err != nil { + ctx.NotFoundOrServerError("GetLatestAttempt", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) + return nil, nil, nil + } + } + + // Resolve the jobs for the resolved attempt. + // When attempt is nil (legacy run or legacy job), jobs are stored with run_attempt_id=0. + var resolvedAttemptID int64 + if attempt != nil { + resolvedAttemptID = attempt.ID + } + jobs, err := actions_model.GetRunJobsByRunAndAttemptID(ctx, run.ID, resolvedAttemptID) if err != nil { - ctx.ServerError("GetRunJobsByRunID", err) - return nil, nil + ctx.ServerError("get current jobs", err) + return nil, nil, nil } if len(jobs) == 0 { ctx.NotFound(nil) - return nil, nil + return nil, nil, nil } for _, job := range jobs { job.Run = run } - return run, jobs + return run, attempt, jobs +} + +// resolveArtifactAttemptIDFromQuery resolves the run_attempt_id used to scope artifact lookups. +// If the `attempt` query parameter is present and valid, it returns the matching attempt's ID. +// Otherwise it falls back to run.LatestAttemptID, which is 0 only for legacy runs created before ActionRunAttempt existed. +func resolveArtifactAttemptIDFromQuery(ctx *context_module.Context, run *actions_model.ActionRun) (int64, error) { + if ctx.FormString("attempt") == "" { + return run.LatestAttemptID, nil + } + attemptNum := ctx.FormInt64("attempt") + if attemptNum <= 0 { + return 0, util.ErrNotExist + } + attempt, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, run.ID, attemptNum) + if err != nil { + return 0, err + } + return attempt.ID, nil } func ArtifactsDeleteView(ctx *context_module.Context) { @@ -814,9 +923,16 @@ func ArtifactsDeleteView(ctx *context_module.Context) { if ctx.Written() { return } + resolvedAttemptID, err := resolveArtifactAttemptIDFromQuery(ctx, run) + if err != nil { + ctx.NotFoundOrServerError("resolveArtifactAttemptIDFromQuery", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) + return + } artifactName := ctx.PathParam("artifact_name") - if err := actions_model.SetArtifactNeedDelete(ctx, run.ID, artifactName); err != nil { - ctx.ServerError("SetArtifactNeedDelete", err) + if err := actions_model.SetArtifactNeedDeleteByRunAttempt(ctx, run.ID, resolvedAttemptID, artifactName); err != nil { + ctx.ServerError("SetArtifactNeedDeleteByRunAttempt", err) return } ctx.JSON(http.StatusOK, struct{}{}) @@ -827,14 +943,17 @@ func ArtifactsDownloadView(ctx *context_module.Context) { if ctx.Written() { return } - - artifactName := ctx.PathParam("artifact_name") - artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{ - RunID: run.ID, - ArtifactName: artifactName, - }) + resolvedAttemptID, err := resolveArtifactAttemptIDFromQuery(ctx, run) if err != nil { - ctx.ServerError("FindArtifacts", err) + ctx.NotFoundOrServerError("resolveArtifactAttemptIDFromQuery", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) + return + } + artifactName := ctx.PathParam("artifact_name") + artifacts, err := actions_model.GetArtifactsByRunAttemptAndName(ctx, run.ID, resolvedAttemptID, artifactName) + if err != nil { + ctx.ServerError("GetArtifactsByRunAttemptAndName", err) return } if len(artifacts) == 0 { @@ -931,8 +1050,10 @@ func ApproveAllChecks(ctx *context_module.Context) { return } - approveRuns(ctx, runIDs) - if ctx.Written() { + if err := actions_service.ApproveRuns(ctx, repo, ctx.Doer, runIDs); err != nil { + ctx.NotFoundOrServerError("ApproveRuns", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) return } diff --git a/routers/web/web.go b/routers/web/web.go index e0ff54fcff5..330a5a9c244 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1539,6 +1539,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Combo(""). Get(actions.View). Post(web.Bind(actions.ViewRequest{}), actions.ViewPost) + m.Group("/attempts/{attempt}", func() { + m.Combo(""). + Get(actions.View). + Post(web.Bind(actions.ViewRequest{}), actions.ViewPost) + }) m.Group("/jobs/{job}", func() { m.Combo(""). Get(actions.View). @@ -1754,8 +1759,10 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Any("/mail-preview/*", devtest.MailPreviewRender) m.Any("/{sub}", devtest.TmplCommon) m.Get("/repo-action-view/runs/{run}", devtest.MockActionsView) + m.Get("/repo-action-view/runs/{run}/attempts/{attempt}", devtest.MockActionsView) m.Get("/repo-action-view/runs/{run}/jobs/{job}", devtest.MockActionsView) m.Post("/repo-action-view/runs/{run}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs) + m.Post("/repo-action-view/runs/{run}/attempts/{attempt}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs) m.Post("/repo-action-view/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs) }) } diff --git a/services/actions/approve.go b/services/actions/approve.go new file mode 100644 index 00000000000..552b055b706 --- /dev/null +++ b/services/actions/approve.go @@ -0,0 +1,69 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" +) + +func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, runIDs []int64) error { + updatedJobs := make([]*actions_model.ActionRunJob, 0) + cancelledConcurrencyJobs := make([]*actions_model.ActionRunJob, 0) + + err := db.WithTx(ctx, func(ctx context.Context) (err error) { + for _, runID := range runIDs { + run, err := actions_model.GetRunByRepoAndID(ctx, repo.ID, runID) + if err != nil { + return err + } + run.NeedApproval = false + run.ApprovedBy = doer.ID + if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil { + return err + } + jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, repo.ID, run.ID) + if err != nil { + return err + } + for _, job := range jobs { + // Skip jobs with `needs`: they stay blocked until their dependencies finish, + // at which point job_emitter will evaluate and start them. + if len(job.Needs) > 0 { + continue + } + var jobsToCancel []*actions_model.ActionRunJob + job.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, job) + if err != nil { + return err + } + cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...) + if job.Status == actions_model.StatusWaiting { + n, err := actions_model.UpdateRunJob(ctx, job, nil, "status") + if err != nil { + return err + } + if n > 0 { + updatedJobs = append(updatedJobs, job) + } + } + } + } + return nil + }) + if err != nil { + return err + } + + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, updatedJobs) + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, cancelledConcurrencyJobs) + + EmitJobsIfReadyByJobs(cancelledConcurrencyJobs) + + return nil +} diff --git a/services/actions/cleanup.go b/services/actions/cleanup.go index d0cc63e5388..f223c981255 100644 --- a/services/actions/cleanup.go +++ b/services/actions/cleanup.go @@ -179,7 +179,7 @@ func DeleteRun(ctx context.Context, run *actions_model.ActionRun) error { repoID := run.RepoID - jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + jobs, err := actions_model.GetAllRunJobsByRepoAndRunID(ctx, run.RepoID, run.ID) if err != nil { return err } @@ -207,6 +207,10 @@ func DeleteRun(ctx context.Context, run *actions_model.ActionRun) error { RepoID: repoID, ID: run.ID, }) + recordsToDelete = append(recordsToDelete, &actions_model.ActionRunAttempt{ + RepoID: repoID, + RunID: run.ID, + }) recordsToDelete = append(recordsToDelete, &actions_model.ActionRunJob{ RepoID: repoID, RunID: run.ID, diff --git a/services/actions/clear_tasks.go b/services/actions/clear_tasks.go index c71f63e7d17..940f1d84544 100644 --- a/services/actions/clear_tasks.go +++ b/services/actions/clear_tasks.go @@ -17,7 +17,6 @@ import ( "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" - notify_service "code.gitea.io/gitea/services/notify" ) // StopZombieTasks stops the task which have running status, but haven't been updated for a long time @@ -36,39 +35,16 @@ func StopEndlessTasks(ctx context.Context) error { }) } -func notifyWorkflowJobStatusUpdate(ctx context.Context, jobs []*actions_model.ActionRunJob) { - if len(jobs) == 0 { - return - } - // The input jobs may belong to different runs, so track each affected run. - runs := make(map[int64]*actions_model.ActionRun, len(jobs)) - for _, job := range jobs { - if err := job.LoadAttributes(ctx); err != nil { - log.Error("Failed to load job attributes: %v", err) - continue - } - CreateCommitStatusForRunJobs(ctx, job.Run, job) - notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) - if _, ok := runs[job.RunID]; !ok { - runs[job.RunID] = job.Run - } - } - - for _, run := range runs { - notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run) - } -} - func CancelPreviousJobs(ctx context.Context, repoID int64, ref, workflowID string, event webhook_module.HookEventType) error { jobs, err := actions_model.CancelPreviousJobs(ctx, repoID, ref, workflowID, event) - notifyWorkflowJobStatusUpdate(ctx, jobs) + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, jobs) EmitJobsIfReadyByJobs(jobs) return err } func CleanRepoScheduleTasks(ctx context.Context, repo *repo_model.Repository) error { jobs, err := actions_model.CleanRepoScheduleTasks(ctx, repo) - notifyWorkflowJobStatusUpdate(ctx, jobs) + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, jobs) EmitJobsIfReadyByJobs(jobs) return err } @@ -83,61 +59,59 @@ func shouldBlockJobByConcurrency(ctx context.Context, job *actions_model.ActionR return false, nil } - runs, jobs, err := actions_model.GetConcurrentRunsAndJobs(ctx, job.RepoID, job.ConcurrencyGroup, []actions_model.Status{actions_model.StatusRunning}) + attempts, jobs, err := actions_model.GetConcurrentRunAttemptsAndJobs(ctx, job.RepoID, job.ConcurrencyGroup, []actions_model.Status{actions_model.StatusRunning}) if err != nil { - return false, fmt.Errorf("GetConcurrentRunsAndJobs: %w", err) + return false, fmt.Errorf("GetConcurrentRunAttemptsAndJobs: %w", err) } - return len(runs) > 0 || len(jobs) > 0, nil + return len(attempts) > 0 || len(jobs) > 0, nil } // PrepareToStartJobWithConcurrency prepares a job to start by its evaluated concurrency group and cancelling previous jobs if necessary. -// It returns the new status of the job (either StatusBlocked or StatusWaiting) and any error encountered during the process. -func PrepareToStartJobWithConcurrency(ctx context.Context, job *actions_model.ActionRunJob) (actions_model.Status, error) { +// It returns the new status of the job (either StatusBlocked or StatusWaiting), any cancelled jobs, and any error encountered during the process. +func PrepareToStartJobWithConcurrency(ctx context.Context, job *actions_model.ActionRunJob) (actions_model.Status, []*actions_model.ActionRunJob, error) { shouldBlock, err := shouldBlockJobByConcurrency(ctx, job) if err != nil { - return actions_model.StatusBlocked, err + return actions_model.StatusBlocked, nil, err } // even if the current job is blocked, we still need to cancel previous "waiting/blocked" jobs in the same concurrency group jobs, err := actions_model.CancelPreviousJobsByJobConcurrency(ctx, job) if err != nil { - return actions_model.StatusBlocked, fmt.Errorf("CancelPreviousJobsByJobConcurrency: %w", err) + return actions_model.StatusBlocked, nil, fmt.Errorf("CancelPreviousJobsByJobConcurrency: %w", err) } - notifyWorkflowJobStatusUpdate(ctx, jobs) - return util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting), nil + return util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting), jobs, nil } -func shouldBlockRunByConcurrency(ctx context.Context, actionRun *actions_model.ActionRun) (bool, error) { - if actionRun.ConcurrencyGroup == "" || actionRun.ConcurrencyCancel { +func shouldBlockRunByConcurrency(ctx context.Context, attempt *actions_model.ActionRunAttempt) (bool, error) { + if attempt.ConcurrencyGroup == "" || attempt.ConcurrencyCancel { return false, nil } - runs, jobs, err := actions_model.GetConcurrentRunsAndJobs(ctx, actionRun.RepoID, actionRun.ConcurrencyGroup, []actions_model.Status{actions_model.StatusRunning}) + attempts, jobs, err := actions_model.GetConcurrentRunAttemptsAndJobs(ctx, attempt.RepoID, attempt.ConcurrencyGroup, []actions_model.Status{actions_model.StatusRunning}) if err != nil { return false, fmt.Errorf("find concurrent runs and jobs: %w", err) } - return len(runs) > 0 || len(jobs) > 0, nil + return len(attempts) > 0 || len(jobs) > 0, nil } -// PrepareToStartRunWithConcurrency prepares a run to start by its evaluated concurrency group and cancelling previous jobs if necessary. -// It returns the new status of the run (either StatusBlocked or StatusWaiting) and any error encountered during the process. -func PrepareToStartRunWithConcurrency(ctx context.Context, run *actions_model.ActionRun) (actions_model.Status, error) { - shouldBlock, err := shouldBlockRunByConcurrency(ctx, run) +// PrepareToStartRunWithConcurrency prepares a run attempt to start by its evaluated concurrency group and cancelling previous jobs if necessary. +// It returns the new status of the run attempt (either StatusBlocked or StatusWaiting), any cancelled jobs, and any error encountered during the process. +func PrepareToStartRunWithConcurrency(ctx context.Context, attempt *actions_model.ActionRunAttempt) (actions_model.Status, []*actions_model.ActionRunJob, error) { + shouldBlock, err := shouldBlockRunByConcurrency(ctx, attempt) if err != nil { - return actions_model.StatusBlocked, err + return actions_model.StatusBlocked, nil, err } // even if the current run is blocked, we still need to cancel previous "waiting/blocked" jobs in the same concurrency group - jobs, err := actions_model.CancelPreviousJobsByRunConcurrency(ctx, run) + jobs, err := actions_model.CancelPreviousJobsByRunConcurrency(ctx, attempt) if err != nil { - return actions_model.StatusBlocked, fmt.Errorf("CancelPreviousJobsByRunConcurrency: %w", err) + return actions_model.StatusBlocked, nil, fmt.Errorf("CancelPreviousJobsByRunConcurrency: %w", err) } - notifyWorkflowJobStatusUpdate(ctx, jobs) - return util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting), nil + return util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting), jobs, nil } func stopTasks(ctx context.Context, opts actions_model.FindTaskOptions) error { @@ -175,7 +149,7 @@ func stopTasks(ctx context.Context, opts actions_model.FindTaskOptions) error { remove() } - notifyWorkflowJobStatusUpdate(ctx, jobs) + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, jobs) EmitJobsIfReadyByJobs(jobs) return nil @@ -194,8 +168,6 @@ func CancelAbandonedJobs(ctx context.Context) error { now := timeutil.TimeStampNow() - // Collect one job per run to send workflow run status update - updatedRuns := map[int64]*actions_model.ActionRunJob{} updatedJobs := []*actions_model.ActionRunJob{} for _, job := range jobs { @@ -211,9 +183,6 @@ func CancelAbandonedJobs(ctx context.Context) error { return err } updated = n > 0 - if updated && job.Run.Status.IsDone() { - updatedRuns[job.RunID] = job - } return nil }); err != nil { log.Warn("cancel abandoned job %v: %v", job.ID, err) @@ -222,16 +191,13 @@ func CancelAbandonedJobs(ctx context.Context) error { if job.Run == nil || job.Run.Repo == nil { continue // error occurs during loading attributes, the following code that depends on "Run.Repo" will fail, so ignore and skip } - CreateCommitStatusForRunJobs(ctx, job.Run, job) if updated { + CreateCommitStatusForRunJobs(ctx, job.Run, job) updatedJobs = append(updatedJobs, job) - notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) } } - for _, job := range updatedRuns { - notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) - } + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, updatedJobs) EmitJobsIfReadyByJobs(updatedJobs) return nil diff --git a/services/actions/concurrency.go b/services/actions/concurrency.go index 878e5c483bf..e1ec5499309 100644 --- a/services/actions/concurrency.go +++ b/services/actions/concurrency.go @@ -17,15 +17,15 @@ import ( ) // EvaluateRunConcurrencyFillModel evaluates the expressions in a run-level (workflow) concurrency, -// and fills the run's model fields with `concurrency.group` and `concurrency.cancel-in-progress`. +// and fills the run attempt model with the evaluated `concurrency.group` and `concurrency.cancel-in-progress` values. // Workflow-level concurrency doesn't depend on the job outputs, so it can always be evaluated if there is no syntax error. // See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency -func EvaluateRunConcurrencyFillModel(ctx context.Context, run *actions_model.ActionRun, wfRawConcurrency *act_model.RawConcurrency, vars map[string]string, inputs map[string]any) error { +func EvaluateRunConcurrencyFillModel(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, wfRawConcurrency *act_model.RawConcurrency, vars map[string]string, inputs map[string]any) error { if err := run.LoadAttributes(ctx); err != nil { return fmt.Errorf("run LoadAttributes: %w", err) } - actionsRunCtx := GenerateGiteaContext(run, nil) + actionsRunCtx := GenerateGiteaContext(ctx, run, attempt, nil) jobResults := map[string]*jobparser.JobResult{"": {}} if inputs == nil { var err error @@ -35,12 +35,8 @@ func EvaluateRunConcurrencyFillModel(ctx context.Context, run *actions_model.Act } } - rawConcurrency, err := yaml.Marshal(wfRawConcurrency) - if err != nil { - return fmt.Errorf("marshal raw concurrency: %w", err) - } - run.RawConcurrency = string(rawConcurrency) - run.ConcurrencyGroup, run.ConcurrencyCancel, err = jobparser.EvaluateConcurrency(wfRawConcurrency, "", nil, actionsRunCtx, jobResults, vars, inputs) + var err error + attempt.ConcurrencyGroup, attempt.ConcurrencyCancel, err = jobparser.EvaluateConcurrency(wfRawConcurrency, "", nil, actionsRunCtx, jobResults, vars, inputs) if err != nil { return fmt.Errorf("evaluate concurrency: %w", err) } @@ -71,7 +67,7 @@ func findJobNeedsAndFillJobResults(ctx context.Context, job *actions_model.Actio // Job-level concurrency may depend on other job's outputs (via `needs`): `concurrency.group: my-group-${{ needs.job1.outputs.out1 }}` // If the needed jobs haven't been executed yet, this evaluation will also fail. // See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idconcurrency -func EvaluateJobConcurrencyFillModel(ctx context.Context, run *actions_model.ActionRun, actionRunJob *actions_model.ActionRunJob, vars map[string]string, inputs map[string]any) error { +func EvaluateJobConcurrencyFillModel(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, actionRunJob *actions_model.ActionRunJob, vars map[string]string, inputs map[string]any) error { if err := actionRunJob.LoadAttributes(ctx); err != nil { return fmt.Errorf("job LoadAttributes: %w", err) } @@ -81,7 +77,7 @@ func EvaluateJobConcurrencyFillModel(ctx context.Context, run *actions_model.Act return fmt.Errorf("unmarshal raw concurrency: %w", err) } - actionsJobCtx := GenerateGiteaContext(run, actionRunJob) + actionsJobCtx := GenerateGiteaContext(ctx, run, attempt, actionRunJob) jobResults, err := findJobNeedsAndFillJobResults(ctx, actionRunJob) if err != nil { diff --git a/services/actions/context.go b/services/actions/context.go index 69d59376232..9250c409835 100644 --- a/services/actions/context.go +++ b/services/actions/context.go @@ -14,6 +14,7 @@ import ( "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" @@ -22,9 +23,14 @@ import ( type GiteaContext map[string]any -// GenerateGiteaContext generate the gitea context without token and gitea_runtime_token -// job can be nil when generating a context for parsing workflow-level expressions -func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.ActionRunJob) GiteaContext { +// GenerateGiteaContext generate the gitea context without token and gitea_runtime_token. +// attempt and job can be nil when generating a context for parsing workflow-level expressions. +// +// The run_attempt value is resolved with the following precedence: +// 1. attempt.Attempt - the explicit attempt argument, or run.GetLatestAttempt() as a fallback +// 2. job.Attempt - only used when neither an explicit nor latest attempt is available +// 3. "1" - when none of the above apply (first-run parse time, before the first attempt exists) +func GenerateGiteaContext(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, job *actions_model.ActionRunJob) GiteaContext { event := map[string]any{} _ = json.Unmarshal([]byte(run.EventPayload), &event) @@ -89,10 +95,28 @@ func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.Actio if job != nil { gitContext["job"] = job.JobID - gitContext["run_id"] = strconv.FormatInt(job.RunID, 10) gitContext["run_attempt"] = strconv.FormatInt(job.Attempt, 10) } + if attempt == nil { + if latestAttempt, has, err := run.GetLatestAttempt(ctx); err == nil && has { + attempt = latestAttempt + } + } + + if attempt != nil { + gitContext["run_attempt"] = strconv.FormatInt(attempt.Attempt, 10) + if err := attempt.LoadAttributes(ctx); err == nil { + gitContext["triggering_actor"] = attempt.TriggerUser.Name + } + } + + // Fallback for first-run parse time: no job, no attempt (LatestAttemptID==0). github.run_attempt + // is 1-based per the documented contract, so emit "1" rather than leaving it empty. + if gitContext["run_attempt"] == "" { + gitContext["run_attempt"] = "1" + } + return gitContext } @@ -108,7 +132,13 @@ func FindTaskNeeds(ctx context.Context, job *actions_model.ActionRunJob) (map[st } needs := container.SetOf(job.Needs...) - jobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: job.RunID}) + // Scope to the same attempt. For legacy jobs RunAttemptID==0, which matches all other legacy jobs in the same run. + findOpts := actions_model.FindRunJobOptions{ + RunID: job.RunID, + RunAttemptID: optional.Some(job.RunAttemptID), + } + + jobs, err := db.Find[actions_model.ActionRunJob](ctx, findOpts) if err != nil { return nil, fmt.Errorf("FindRunJobs: %w", err) } @@ -125,11 +155,12 @@ func FindTaskNeeds(ctx context.Context, job *actions_model.ActionRunJob) (map[st } var jobOutputs map[string]string for _, job := range jobsWithSameID { - if job.TaskID == 0 || !job.Status.IsDone() { - // it shouldn't happen, or the job has been rerun + taskID := job.EffectiveTaskID() + if taskID == 0 || !job.Status.IsDone() { + // it shouldn't happen continue } - got, err := actions_model.FindTaskOutputByTaskID(ctx, job.TaskID) + got, err := actions_model.FindTaskOutputByTaskID(ctx, taskID) if err != nil { return nil, fmt.Errorf("FindTaskOutputByTaskID: %w", err) } diff --git a/services/actions/context_test.go b/services/actions/context_test.go index 4ade67111cf..22f9abcce81 100644 --- a/services/actions/context_test.go +++ b/services/actions/context_test.go @@ -26,17 +26,20 @@ func TestEvaluateRunConcurrency_RunIDFallback(t *testing.T) { runA := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 791}) runB := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 792}) + attemptA := &actions_model.ActionRunAttempt{RepoID: runA.RepoID, RunID: runA.ID, Attempt: 1} + attemptB := &actions_model.ActionRunAttempt{RepoID: runB.RepoID, RunID: runB.ID, Attempt: 1} + expr := &act_model.RawConcurrency{ Group: "${{ github.workflow }}-${{ github.head_ref || github.run_id }}", CancelInProgress: "true", } - assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runA, expr, nil, nil)) - assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runB, expr, nil, nil)) + assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runA, attemptA, expr, nil, nil)) + assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runB, attemptB, expr, nil, nil)) - assert.Contains(t, runA.ConcurrencyGroup, "791") - assert.Contains(t, runB.ConcurrencyGroup, "792") - assert.NotEqual(t, runA.ConcurrencyGroup, runB.ConcurrencyGroup) + assert.Contains(t, attemptA.ConcurrencyGroup, "791") + assert.Contains(t, attemptB.ConcurrencyGroup, "792") + assert.NotEqual(t, attemptA.ConcurrencyGroup, attemptB.ConcurrencyGroup) } func TestPrepareRunAndInsert_ExpressionsSeeRunID(t *testing.T) { @@ -78,7 +81,10 @@ jobs: persisted := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}) runIDStr := strconv.FormatInt(run.ID, 10) assert.Equal(t, "Run "+runIDStr, persisted.Title) - assert.Equal(t, "group-"+runIDStr, persisted.ConcurrencyGroup) + // ConcurrencyGroup lives on the latest attempt after migration v331. + require.Positive(t, persisted.LatestAttemptID) + attempt := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{ID: persisted.LatestAttemptID}) + assert.Equal(t, "group-"+runIDStr, attempt.ConcurrencyGroup) // Rerun reads raw_concurrency from the DB to re-evaluate the group; // see services/actions/rerun.go. Must survive the insert. assert.NotEmpty(t, persisted.RawConcurrency) diff --git a/services/actions/job_emitter.go b/services/actions/job_emitter.go index c7813360abd..489b36a3a7d 100644 --- a/services/actions/job_emitter.go +++ b/services/actions/job_emitter.go @@ -16,7 +16,6 @@ import ( "code.gitea.io/gitea/modules/queue" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" - notify_service "code.gitea.io/gitea/services/notify" "xorm.io/builder" ) @@ -70,30 +69,33 @@ func checkJobsByRunID(ctx context.Context, runID int64) error { if err != nil { return fmt.Errorf("get action run: %w", err) } - var jobs, updatedJobs []*actions_model.ActionRunJob + var jobs, updatedJobs, cancelledJobs []*actions_model.ActionRunJob if err := db.WithTx(ctx, func(ctx context.Context) error { // check jobs of the current run - if js, ujs, err := checkJobsOfRun(ctx, run); err != nil { + if js, ujs, cjs, err := checkJobsOfCurrentRunAttempt(ctx, run); err != nil { return err } else { jobs = append(jobs, js...) updatedJobs = append(updatedJobs, ujs...) + cancelledJobs = append(cancelledJobs, cjs...) } - if js, ujs, err := checkRunConcurrency(ctx, run); err != nil { + if js, ujs, cjs, err := checkRunConcurrency(ctx, run); err != nil { return err } else { jobs = append(jobs, js...) updatedJobs = append(updatedJobs, ujs...) + cancelledJobs = append(cancelledJobs, cjs...) } return nil }); err != nil { return err } - CreateCommitStatusForRunJobs(ctx, run, jobs...) - for _, job := range updatedJobs { - _ = job.LoadAttributes(ctx) - notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, cancelledJobs) + EmitJobsIfReadyByJobs(cancelledJobs) + if err := createCommitStatusesForJobsByRun(ctx, jobs); err != nil { + return err } + NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...) runJobs := make(map[int64][]*actions_model.ActionRunJob) for _, job := range jobs { runJobs[job.RunID] = append(runJobs[job.RunID], job) @@ -114,71 +116,97 @@ func checkJobsByRunID(ctx context.Context, runID int64) error { } } if runUpdated { - NotifyWorkflowRunStatusUpdateWithReload(ctx, js[0]) + NotifyWorkflowRunStatusUpdateWithReload(ctx, js[0].RepoID, js[0].RunID) } } return nil } -// findBlockedRunByConcurrency finds the blocked concurrent run in a repo and returns `nil, nil` when there is no blocked run. -func findBlockedRunByConcurrency(ctx context.Context, repoID int64, concurrencyGroup string) (*actions_model.ActionRun, error) { - if concurrencyGroup == "" { - return nil, nil //nolint:nilnil // return nil to indicate that no blocked run exists - } - cRuns, cJobs, err := actions_model.GetConcurrentRunsAndJobs(ctx, repoID, concurrencyGroup, []actions_model.Status{actions_model.StatusBlocked}) - if err != nil { - return nil, fmt.Errorf("find concurrent runs and jobs: %w", err) +func createCommitStatusesForJobsByRun(ctx context.Context, jobs []*actions_model.ActionRunJob) error { + runJobs := make(map[int64][]*actions_model.ActionRunJob) + for _, job := range jobs { + runJobs[job.RunID] = append(runJobs[job.RunID], job) } - // There can be at most one blocked run or job - var concurrentRun *actions_model.ActionRun - if len(cRuns) > 0 { - concurrentRun = cRuns[0] - } else if len(cJobs) > 0 { - jobRun, exist, err := db.GetByID[actions_model.ActionRun](ctx, cJobs[0].RunID) - if !exist { - return nil, fmt.Errorf("run %d does not exist", cJobs[0].RunID) - } + for jobRunID, jobList := range runJobs { + run, err := actions_model.GetRunByRepoAndID(ctx, jobList[0].RepoID, jobRunID) if err != nil { - return nil, fmt.Errorf("get run by job %d: %w", cJobs[0].ID, err) + return fmt.Errorf("get action run %d: %w", jobRunID, err) } - concurrentRun = jobRun + CreateCommitStatusForRunJobs(ctx, run, jobList...) } - - return concurrentRun, nil + return nil } -func checkRunConcurrency(ctx context.Context, run *actions_model.ActionRun) (jobs, updatedJobs []*actions_model.ActionRunJob, err error) { +// findBlockedRunIDByConcurrency finds a blocked concurrent run in a repo and returns 0 when there is no blocked run. +func findBlockedRunIDByConcurrency(ctx context.Context, repoID int64, concurrencyGroup string) (int64, error) { + if concurrencyGroup == "" { + return 0, nil + } + cAttempts, cJobs, err := actions_model.GetConcurrentRunAttemptsAndJobs(ctx, repoID, concurrencyGroup, []actions_model.Status{actions_model.StatusBlocked}) + if err != nil { + return 0, fmt.Errorf("find concurrent runs and jobs: %w", err) + } + + if len(cAttempts) > 0 { + return cAttempts[0].RunID, nil + } + if len(cJobs) > 0 { + return cJobs[0].RunID, nil + } + + return 0, nil +} + +func checkBlockedConcurrentRun(ctx context.Context, repoID, runID int64) (jobs, updatedJobs, cancelledJobs []*actions_model.ActionRunJob, err error) { + concurrentRun, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID) + if err != nil { + return nil, nil, nil, fmt.Errorf("get run %d: %w", runID, err) + } + if concurrentRun.NeedApproval { + return nil, nil, nil, nil + } + + return checkJobsOfCurrentRunAttempt(ctx, concurrentRun) +} + +// checkRunConcurrency rechecks runs blocked by concurrency that may become unblocked after the current run releases a workflow-level or job-level concurrency group. +func checkRunConcurrency(ctx context.Context, run *actions_model.ActionRun) (jobs, updatedJobs, cancelledJobs []*actions_model.ActionRunJob, err error) { checkedConcurrencyGroup := make(container.Set[string]) collect := func(concurrencyGroup string) error { - concurrentRun, err := findBlockedRunByConcurrency(ctx, run.RepoID, concurrencyGroup) + concurrentRunID, err := findBlockedRunIDByConcurrency(ctx, run.RepoID, concurrencyGroup) if err != nil { return fmt.Errorf("find blocked run by concurrency: %w", err) } - if concurrentRun != nil && !concurrentRun.NeedApproval { - js, ujs, err := checkJobsOfRun(ctx, concurrentRun) + if concurrentRunID > 0 { + js, ujs, cjs, err := checkBlockedConcurrentRun(ctx, run.RepoID, concurrentRunID) if err != nil { return err } jobs = append(jobs, js...) updatedJobs = append(updatedJobs, ujs...) + cancelledJobs = append(cancelledJobs, cjs...) } checkedConcurrencyGroup.Add(concurrencyGroup) return nil } // check run (workflow-level) concurrency - if run.ConcurrencyGroup != "" { - if err := collect(run.ConcurrencyGroup); err != nil { - return nil, nil, err + runConcurrencyGroup, _, err := run.GetEffectiveConcurrency(ctx) + if err != nil { + return nil, nil, nil, fmt.Errorf("GetEffectiveConcurrency: %w", err) + } + if runConcurrencyGroup != "" { + if err := collect(runConcurrencyGroup); err != nil { + return nil, nil, nil, err } } // check job concurrency - runJobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID}) + runJobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID) if err != nil { - return nil, nil, fmt.Errorf("find run %d jobs: %w", run.ID, err) + return nil, nil, nil, fmt.Errorf("find run %d jobs: %w", run.ID, err) } for _, job := range runJobs { if !job.Status.IsDone() { @@ -188,28 +216,30 @@ func checkRunConcurrency(ctx context.Context, run *actions_model.ActionRun) (job continue } if err := collect(job.ConcurrencyGroup); err != nil { - return nil, nil, err + return nil, nil, nil, err } } - return jobs, updatedJobs, nil + return jobs, updatedJobs, cancelledJobs, nil } -func checkJobsOfRun(ctx context.Context, run *actions_model.ActionRun) (jobs, updatedJobs []*actions_model.ActionRunJob, err error) { - jobs, err = db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID}) +// checkJobsOfCurrentRunAttempt resolves blocked jobs of the run's latest attempt. +func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.ActionRun) (jobs, updatedJobs, cancelledJobs []*actions_model.ActionRunJob, err error) { + jobs, err = actions_model.GetRunJobsByRunAndAttemptID(ctx, run.ID, run.LatestAttemptID) if err != nil { - return nil, nil, err + return nil, nil, nil, err } vars, err := actions_model.GetVariablesOfRun(ctx, run) if err != nil { - return nil, nil, err + return nil, nil, nil, err } + resolver := newJobStatusResolver(jobs, vars) if err = db.WithTx(ctx, func(ctx context.Context) error { for _, job := range jobs { job.Run = run } - updates := newJobStatusResolver(jobs, vars).Resolve(ctx) + updates := resolver.Resolve(ctx) for _, job := range jobs { if status, ok := updates[job.ID]; ok { job.Status = status @@ -223,26 +253,18 @@ func checkJobsOfRun(ctx context.Context, run *actions_model.ActionRun) (jobs, up } return nil }); err != nil { - return nil, nil, err + return nil, nil, nil, err } - return jobs, updatedJobs, nil -} - -func NotifyWorkflowRunStatusUpdateWithReload(ctx context.Context, job *actions_model.ActionRunJob) { - job.Run = nil - if err := job.LoadAttributes(ctx); err != nil { - log.Error("LoadAttributes: %v", err) - return - } - notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) + return jobs, updatedJobs, resolver.cancelledJobs, nil } type jobStatusResolver struct { - statuses map[int64]actions_model.Status - needs map[int64][]int64 - jobMap map[int64]*actions_model.ActionRunJob - vars map[string]string + statuses map[int64]actions_model.Status + needs map[int64][]int64 + jobMap map[int64]*actions_model.ActionRunJob + vars map[string]string + cancelledJobs []*actions_model.ActionRunJob } func newJobStatusResolver(jobs actions_model.ActionJobList, vars map[string]string) *jobStatusResolver { @@ -341,9 +363,12 @@ func (r *jobStatusResolver) resolve(ctx context.Context) map[int64]actions_model newStatus := util.Iif(shouldStartJob, actions_model.StatusWaiting, actions_model.StatusSkipped) if newStatus == actions_model.StatusWaiting { - newStatus, err = PrepareToStartJobWithConcurrency(ctx, actionRunJob) + var cancelledJobs []*actions_model.ActionRunJob + newStatus, cancelledJobs, err = PrepareToStartJobWithConcurrency(ctx, actionRunJob) if err != nil { log.Error("ShouldBlockJobByConcurrency failed, this job will stay blocked: job: %d, err: %v", id, err) + } else { + r.cancelledJobs = append(r.cancelledJobs, cancelledJobs...) } } @@ -359,8 +384,16 @@ func updateConcurrencyEvaluationForJobWithNeeds(ctx context.Context, actionRunJo return nil // for testing purpose only, no repo, no evaluation } - err := EvaluateJobConcurrencyFillModel(ctx, actionRunJob.Run, actionRunJob, vars, nil) - if err != nil { + // Legacy jobs (created before migration v331) have RunAttemptID=0 and no attempt record. + var attempt *actions_model.ActionRunAttempt + if actionRunJob.RunAttemptID > 0 { + var err error + attempt, err = actions_model.GetRunAttemptByRepoAndID(ctx, actionRunJob.RepoID, actionRunJob.RunAttemptID) + if err != nil { + return fmt.Errorf("GetRunAttemptByRepoAndID: %w", err) + } + } + if err := EvaluateJobConcurrencyFillModel(ctx, actionRunJob.Run, attempt, actionRunJob, vars, nil); err != nil { return fmt.Errorf("evaluate job concurrency: %w", err) } diff --git a/services/actions/job_emitter_test.go b/services/actions/job_emitter_test.go index 5ab1c0846d7..11998e01b21 100644 --- a/services/actions/job_emitter_test.go +++ b/services/actions/job_emitter_test.go @@ -144,23 +144,36 @@ func Test_checkRunConcurrency_NoDuplicateConcurrencyGroupCheck(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) ctx := t.Context() - // Run A: the triggering run with a concurrency group. + // Run A: the triggering run of attempt A runA := &actions_model.ActionRun{ + RepoID: 4, + OwnerID: 1, + TriggerUserID: 1, + WorkflowID: "test.yml", + Index: 9901, + Ref: "refs/heads/main", + Status: actions_model.StatusRunning, + } + assert.NoError(t, db.Insert(ctx, runA)) + + // Attempt A: an attempt of run A with concurrency group "test-cg" + runAAttempt := &actions_model.ActionRunAttempt{ RepoID: 4, - OwnerID: 1, - TriggerUserID: 1, - WorkflowID: "test.yml", - Index: 9901, - Ref: "refs/heads/main", + RunID: runA.ID, + Attempt: 1, Status: actions_model.StatusRunning, ConcurrencyGroup: "test-cg", } - assert.NoError(t, db.Insert(ctx, runA)) + assert.NoError(t, db.Insert(ctx, runAAttempt)) + _, err := db.Exec(t.Context(), "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", runAAttempt.ID, runA.ID) + assert.NoError(t, err) // A done job for run A with the same ConcurrencyGroup. // This triggers the job-level concurrency check in checkRunConcurrency. jobADone := &actions_model.ActionRunJob{ RunID: runA.ID, + RunAttemptID: runAAttempt.ID, + AttemptJobID: 1, RepoID: 4, OwnerID: 1, JobID: "job1", @@ -170,31 +183,45 @@ func Test_checkRunConcurrency_NoDuplicateConcurrencyGroupCheck(t *testing.T) { } assert.NoError(t, db.Insert(ctx, jobADone)) - // Blocked run B competing for the same concurrency group. + // Run B: a run blocked by concurrency runB := &actions_model.ActionRun{ - RepoID: 4, - OwnerID: 1, - TriggerUserID: 1, - WorkflowID: "test.yml", - Index: 9902, - Ref: "refs/heads/main", - Status: actions_model.StatusBlocked, - ConcurrencyGroup: "test-cg", + RepoID: 4, + OwnerID: 1, + TriggerUserID: 1, + WorkflowID: "test.yml", + Index: 9902, + Ref: "refs/heads/main", + Status: actions_model.StatusBlocked, } assert.NoError(t, db.Insert(ctx, runB)) + // Attempt B: an blocked attempt of run B + runBAttempt := &actions_model.ActionRunAttempt{ + RepoID: 4, + RunID: runB.ID, + Attempt: 1, + Status: actions_model.StatusBlocked, + ConcurrencyGroup: "test-cg", + } + assert.NoError(t, db.Insert(ctx, runBAttempt)) + _, err = db.Exec(t.Context(), "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", runBAttempt.ID, runB.ID) + assert.NoError(t, err) + // A blocked job belonging to run B (no job-level concurrency group). jobBBlocked := &actions_model.ActionRunJob{ - RunID: runB.ID, - RepoID: 4, - OwnerID: 1, - JobID: "job1", - Name: "job1", - Status: actions_model.StatusBlocked, + RunID: runB.ID, + RunAttemptID: runBAttempt.ID, + AttemptJobID: 1, + RepoID: 4, + OwnerID: 1, + JobID: "job1", + Name: "job1", + Status: actions_model.StatusBlocked, } assert.NoError(t, db.Insert(ctx, jobBBlocked)) - jobs, _, err := checkRunConcurrency(ctx, runA) + runA, _, _ = db.GetByID[actions_model.ActionRun](t.Context(), runA.ID) + jobs, _, _, err := checkRunConcurrency(ctx, runA) assert.NoError(t, err) if assert.Len(t, jobs, 1) { diff --git a/services/actions/notifier.go b/services/actions/notifier.go index 5f7ee6fcea0..c3b2003b3cd 100644 --- a/services/actions/notifier.go +++ b/services/actions/notifier.go @@ -815,7 +815,7 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep log.Error("GetActionWorkflow: %v", err) return } - convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run) + convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run, nil) if err != nil { log.Error("ToActionWorkflowRun: %v", err) return diff --git a/services/actions/notify.go b/services/actions/notify.go new file mode 100644 index 00000000000..e8b05c9fecd --- /dev/null +++ b/services/actions/notify.go @@ -0,0 +1,144 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/log" + notify_service "code.gitea.io/gitea/services/notify" +) + +// NotifyWorkflowJobsAndRunsStatusUpdate notifies status changes for a batch of jobs and the runs they affect. +// Use it when a workflow operation updates multiple jobs and runs. +func NotifyWorkflowJobsAndRunsStatusUpdate(ctx context.Context, jobs []*actions_model.ActionRunJob) { + if len(jobs) == 0 { + return + } + + // The input jobs may belong to different runs, so track each affected run. + runs := make(map[int64]*actions_model.ActionRun, len(jobs)) + jobsByRunID := make(map[int64][]*actions_model.ActionRunJob) + + for _, job := range jobs { + if err := job.LoadAttributes(ctx); err != nil { + log.Error("Failed to load job attributes: %v", err) + continue + } + CreateCommitStatusForRunJobs(ctx, job.Run, job) + + if _, ok := runs[job.RunID]; !ok { + runs[job.RunID] = job.Run + } + if _, ok := jobsByRunID[job.RunID]; !ok { + jobsByRunID[job.RunID] = make([]*actions_model.ActionRunJob, 0) + } + jobsByRunID[job.RunID] = append(jobsByRunID[job.RunID], job) + } + + for _, run := range runs { + NotifyWorkflowRunStatusUpdate(ctx, run) + } + + for _, jobs := range jobsByRunID { + NotifyWorkflowJobsStatusUpdate(ctx, jobs...) + } +} + +// NotifyWorkflowRunStatusUpdateWithReload reloads the run before notifying its status update. +// Use it when only repo/run IDs are available or when the in-memory run may be stale after job updates. +func NotifyWorkflowRunStatusUpdateWithReload(ctx context.Context, repoID, runID int64) { + run, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID) + if err != nil { + log.Error("GetRunByRepoAndID: %v", err) + return + } + NotifyWorkflowRunStatusUpdate(ctx, run) +} + +// NotifyWorkflowRunStatusUpdate notifies a run status update using the latest attempt trigger user when available. +// Use it for run-level notifications when the caller already has the run model loaded. +func NotifyWorkflowRunStatusUpdate(ctx context.Context, run *actions_model.ActionRun) { + if err := run.LoadAttributes(ctx); err != nil { + log.Error("run.LoadAttributes: %v", err) + return + } + triggerUser := run.TriggerUser + if run.LatestAttemptID > 0 { + attempt, err := actions_model.GetRunAttemptByRepoAndID(ctx, run.RepoID, run.LatestAttemptID) + if err != nil { + log.Error("GetRunAttemptByRepoAndID: %v", err) + return + } + if err := attempt.LoadAttributes(ctx); err != nil { + log.Error("attempt.LoadAttributes: %v", err) + return + } + triggerUser = attempt.TriggerUser + } + notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, triggerUser, run) +} + +// NotifyWorkflowJobsStatusUpdate notifies status updates for jobs without task. +// Use it for batch or single-job notifications after state changes. +func NotifyWorkflowJobsStatusUpdate(ctx context.Context, jobs ...*actions_model.ActionRunJob) { + jobsByAttempt := make(map[int64][]*actions_model.ActionRunJob) + for _, job := range jobs { + if _, ok := jobsByAttempt[job.RunAttemptID]; !ok { + jobsByAttempt[job.RunAttemptID] = make([]*actions_model.ActionRunJob, 0) + } + jobsByAttempt[job.RunAttemptID] = append(jobsByAttempt[job.RunAttemptID], job) + } + + for attemptID, js := range jobsByAttempt { + if attemptID == 0 { + for _, job := range js { + if err := job.LoadAttributes(ctx); err != nil { + log.Error("job.LoadAttributes: %v", err) + continue + } + notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) + } + continue + } + + attempt, err := actions_model.GetRunAttemptByRepoAndID(ctx, js[0].RepoID, attemptID) + if err != nil { + log.Error("GetRunAttemptByRepoAndID: %v", err) + continue + } + if err := attempt.LoadAttributes(ctx); err != nil { + log.Error("attempt.LoadAttributes: %v", err) + continue + } + for _, job := range js { + notify_service.WorkflowJobStatusUpdate(ctx, attempt.Run.Repo, attempt.TriggerUser, job, nil) + } + } +} + +// NotifyWorkflowJobStatusUpdateWithTask notifies a single job status update when a concrete task is available. +// Use it for runner/task lifecycle callbacks so the notification includes the originating task context. +func NotifyWorkflowJobStatusUpdateWithTask(ctx context.Context, job *actions_model.ActionRunJob, task *actions_model.ActionTask) { + if job.RunAttemptID == 0 { + if err := job.LoadAttributes(ctx); err != nil { + log.Error("job.LoadAttributes: %v", err) + return + } + notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, task) + return + } + + attempt, err := actions_model.GetRunAttemptByRepoAndID(ctx, job.RepoID, job.RunAttemptID) + if err != nil { + log.Error("GetRunAttemptByRepoAndID: %v", err) + return + } + if err := attempt.LoadAttributes(ctx); err != nil { + log.Error("attempt.LoadAttributes: %v", err) + return + } + notify_service.WorkflowJobStatusUpdate(ctx, attempt.Run.Repo, attempt.TriggerUser, job, task) +} diff --git a/services/actions/rerun.go b/services/actions/rerun.go index 1596d9bfc5a..f253181a8dd 100644 --- a/services/actions/rerun.go +++ b/services/actions/rerun.go @@ -6,57 +6,312 @@ package actions import ( "context" "fmt" + "slices" actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" - notify_service "code.gitea.io/gitea/services/notify" "github.com/nektos/act/pkg/model" "go.yaml.in/yaml/v4" - "xorm.io/builder" ) -// GetFailedRerunJobs returns all failed jobs and their downstream dependent jobs that need to be rerun -func GetFailedRerunJobs(allJobs []*actions_model.ActionRunJob) []*actions_model.ActionRunJob { - rerunJobIDSet := make(container.Set[int64]) +// GetFailedJobsForRerun returns the failed or cancelled jobs in a run. +func GetFailedJobsForRerun(allJobs []*actions_model.ActionRunJob) []*actions_model.ActionRunJob { var jobsToRerun []*actions_model.ActionRunJob for _, job := range allJobs { if job.Status == actions_model.StatusFailure || job.Status == actions_model.StatusCancelled { - for _, j := range GetAllRerunJobs(job, allJobs) { - if !rerunJobIDSet.Contains(j.ID) { - rerunJobIDSet.Add(j.ID) - jobsToRerun = append(jobsToRerun, j) - } - } + jobsToRerun = append(jobsToRerun, job) } } return jobsToRerun } -// GetAllRerunJobs returns the target job and all jobs that transitively depend on it. -// Downstream jobs are included regardless of their current status. -func GetAllRerunJobs(job *actions_model.ActionRunJob, allJobs []*actions_model.ActionRunJob) []*actions_model.ActionRunJob { - rerunJobs := []*actions_model.ActionRunJob{job} - rerunJobsIDSet := make(container.Set[string]) - rerunJobsIDSet.Add(job.JobID) +// RerunWorkflowRunJobs reruns the given jobs of a workflow run. +// An empty jobsToRerun means rerunning the whole run. Otherwise jobsToRerun contains only the user-requested target jobs; +// downstream dependent jobs are expanded internally while building the rerun plan. +// +// The three stages below (legacy backfill, plan build, plan exec) deliberately run in separate DB transactions +// rather than one big outer transaction: +// - execRerunPlan performs slow work (loading variables, YAML unmarshal, concurrency expression evaluation) +// before opening its own transaction, so the tx stays focused on inserts/updates. +// - The legacy backfill is idempotent-friendly: if it succeeds but a later stage fails, a subsequent rerun +// will observe run.LatestAttemptID != 0 and skip the backfill, continuing naturally. No data corruption +// or stuck state results from partial progress. +// +// Fast validations that can catch failures early (workflow disabled, run not done, etc.) are therefore +// pushed into validateRerun so we rarely enter createOriginalAttemptForLegacyRun only to fail afterwards. +func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, triggerUser *user_model.User, jobsToRerun []*actions_model.ActionRunJob) (*actions_model.ActionRunAttempt, error) { + if err := validateRerun(ctx, run, repo, triggerUser, jobsToRerun); err != nil { + return nil, err + } + + if run.LatestAttemptID == 0 { + if err := createOriginalAttemptForLegacyRun(ctx, run); err != nil { + return nil, fmt.Errorf("create attempt for legacy run: %w", err) + } + } + + plan, err := buildRerunPlan(ctx, run, triggerUser, jobsToRerun) + if err != nil { + return nil, err + } + return execRerunPlan(ctx, plan) +} + +func validateRerun(ctx context.Context, run *actions_model.ActionRun, repo *repo_model.Repository, triggerUser *user_model.User, jobsToRerun []*actions_model.ActionRunJob) error { + if !run.Status.IsDone() { + return util.NewInvalidArgumentErrorf("this workflow run is not done") + } + if repo == nil { + return util.NewInvalidArgumentErrorf("repo is required") + } + if run.RepoID != repo.ID { + return util.NewInvalidArgumentErrorf("run %d does not belong to repo %d", run.ID, repo.ID) + } + for _, job := range jobsToRerun { + if job.RunID != run.ID { + return util.NewInvalidArgumentErrorf("job %d does not belong to workflow run %d", job.ID, run.ID) + } + } + if triggerUser == nil { + return util.NewInvalidArgumentErrorf("trigger user is required") + } + cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions) + cfg := cfgUnit.ActionsConfig() + if cfg.IsWorkflowDisabled(run.WorkflowID) { + return util.NewInvalidArgumentErrorf("workflow %s is disabled", run.WorkflowID) + } + + // Legacy runs (LatestAttemptID == 0) conceptually have only attempt 1, so they can never be at the cap. + // For non-legacy runs, look up the latest attempt and reject when its number is already at the configured cap. + if run.LatestAttemptID > 0 { + latestAttempt, has, err := run.GetLatestAttempt(ctx) + if err != nil { + return fmt.Errorf("GetLatestAttempt: %w", err) + } + if has && latestAttempt.Attempt >= setting.Actions.MaxRerunAttempts { + return util.NewInvalidArgumentErrorf("workflow run has reached the maximum of %d attempts", setting.Actions.MaxRerunAttempts) + } + } + + return nil +} + +// rerunPlan is a read-only snapshot of the inputs needed to execute a rerun. +// It holds no to-be-persisted entities and no intermediate evaluation results; +// execRerunPlan constructs and evaluates the new ActionRunAttempt itself. +type rerunPlan struct { + run *actions_model.ActionRun + templateAttempt *actions_model.ActionRunAttempt + templateJobs actions_model.ActionJobList + rerunJobIDs container.Set[string] + triggerUser *user_model.User +} + +// buildRerunPlan constructs a rerunPlan for the given workflow run without writing to the database. +// jobsToRerun contains only the user-requested target jobs. An empty jobsToRerun means the entire run should be rerun. +// It loads the latest attempt as a template and expands jobsToRerun to include all transitive downstream dependents. +// The construction of new-attempt and concurrency evaluation are deferred to execRerunPlan so that the plan remains a pure input snapshot. +func buildRerunPlan(ctx context.Context, run *actions_model.ActionRun, triggerUser *user_model.User, jobsToRerun []*actions_model.ActionRunJob) (*rerunPlan, error) { + if err := run.LoadAttributes(ctx); err != nil { + return nil, err + } + + templateAttempt, hasTemplateAttempt, err := run.GetLatestAttempt(ctx) + if err != nil { + return nil, err + } + if !hasTemplateAttempt { + return nil, util.NewNotExistErrorf("latest attempt not found") + } + + templateJobs, err := actions_model.GetRunJobsByRunAndAttemptID(ctx, run.ID, templateAttempt.ID) + if err != nil { + return nil, fmt.Errorf("load template jobs: %w", err) + } + if len(templateJobs) == 0 { + return nil, util.NewNotExistErrorf("no template jobs") + } + + plan := &rerunPlan{ + run: run, + templateAttempt: templateAttempt, + templateJobs: templateJobs, + triggerUser: triggerUser, + } + + if err := plan.expandRerunJobIDs(jobsToRerun); err != nil { + return nil, err + } + + return plan, nil +} + +// execRerunPlan executes the rerun plan built by buildRerunPlan. +// It loads run variables, constructs the new ActionRunAttempt and evaluates run-level concurrency (all outside the transaction to keep the tx short). +// Inside a single database transaction it then inserts the new attempt, clones all template jobs, evaluates job-level concurrency for rerun jobs, +// and updates the run's latest_attempt_id. +// Jobs not in the rerun set are cloned as pass-through: their status is preserved and SourceTaskID points to the original task so the UI can still display their results. +// The attempt's final status is derived only from the rerun jobs, not the pass-through jobs. +// Notifications and commit statuses are sent after the transaction commits. +func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionRunAttempt, error) { + vars, err := actions_model.GetVariablesOfRun(ctx, plan.run) + if err != nil { + return nil, fmt.Errorf("get run %d variables: %w", plan.run.ID, err) + } + + newAttempt := &actions_model.ActionRunAttempt{ + RepoID: plan.run.RepoID, + RunID: plan.run.ID, + Attempt: plan.templateAttempt.Attempt + 1, + TriggerUserID: plan.triggerUser.ID, + Status: actions_model.StatusWaiting, + } + + if plan.run.RawConcurrency != "" { + var rawConcurrency model.RawConcurrency + if err := yaml.Unmarshal([]byte(plan.run.RawConcurrency), &rawConcurrency); err != nil { + return nil, fmt.Errorf("unmarshal raw concurrency: %w", err) + } + if err := EvaluateRunConcurrencyFillModel(ctx, plan.run, newAttempt, &rawConcurrency, vars, nil); err != nil { + return nil, err + } + } + + var newJobs, newJobsToRerun actions_model.ActionJobList + var cancelledConcurrencyJobs []*actions_model.ActionRunJob + + err = db.WithTx(ctx, func(ctx context.Context) error { + newAttemptStatus, jobsToCancel, err := PrepareToStartRunWithConcurrency(ctx, newAttempt) + if err != nil { + return err + } + cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...) + newAttempt.Status = newAttemptStatus + shouldBlock := newAttemptStatus == actions_model.StatusBlocked + + if err := db.Insert(ctx, newAttempt); err != nil { + if _, getErr := actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, plan.run.ID, newAttempt.Attempt); getErr == nil { + return util.NewAlreadyExistErrorf("workflow run attempt %d for run %d already exists", newAttempt.Attempt, plan.run.ID) + } + return err + } + + plan.run.LatestAttemptID = newAttempt.ID + if err := actions_model.UpdateRun(ctx, plan.run, "latest_attempt_id"); err != nil { + return err + } + + hasWaitingJobs := false + newJobs = make(actions_model.ActionJobList, 0, len(plan.templateJobs)) + newJobsToRerun = make(actions_model.ActionJobList, 0, len(plan.rerunJobIDs)) + for _, templateJob := range plan.templateJobs { + newJob := cloneRunJobForAttempt(templateJob, newAttempt) + if plan.rerunJobIDs.Contains(templateJob.JobID) { + shouldBlockJob := shouldBlock || plan.hasRerunDependency(templateJob) + + newJob.Status = util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting) + newJob.TaskID = 0 + newJob.SourceTaskID = 0 + newJob.Started = 0 + newJob.Stopped = 0 + newJob.ConcurrencyGroup = "" + newJob.ConcurrencyCancel = false + newJob.IsConcurrencyEvaluated = false + + if newJob.RawConcurrency != "" && !shouldBlockJob { + if err := EvaluateJobConcurrencyFillModel(ctx, plan.run, newAttempt, newJob, vars, nil); err != nil { + return fmt.Errorf("evaluate job concurrency: %w", err) + } + newJob.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, newJob) + if err != nil { + return fmt.Errorf("prepare to start job with concurrency: %w", err) + } + cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...) + } + + newJobsToRerun = append(newJobsToRerun, newJob) + } else { + newJob.TaskID = 0 + newJob.SourceTaskID = templateJob.EffectiveTaskID() + newJob.Started = templateJob.Started + newJob.Stopped = templateJob.Stopped + } + + if err := db.Insert(ctx, newJob); err != nil { + return err + } + hasWaitingJobs = hasWaitingJobs || newJob.Status == actions_model.StatusWaiting + newJobs = append(newJobs, newJob) + } + + newAttempt.Status = actions_model.AggregateJobStatus(newJobsToRerun) + if err := actions_model.UpdateRunAttempt(ctx, newAttempt, "status"); err != nil { + return err + } + + if hasWaitingJobs { + if err := actions_model.IncreaseTaskVersion(ctx, plan.run.OwnerID, plan.run.RepoID); err != nil { + return err + } + } + + return nil + }) + if err != nil { + return nil, err + } + + if err := plan.run.LoadAttributes(ctx); err != nil { + return nil, err + } + + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, cancelledConcurrencyJobs) + EmitJobsIfReadyByJobs(cancelledConcurrencyJobs) + + CreateCommitStatusForRunJobs(ctx, plan.run, newJobs...) + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, newJobsToRerun) + + return newAttempt, nil +} + +func (p *rerunPlan) expandRerunJobIDs(jobsToRerun []*actions_model.ActionRunJob) error { + templateJobIDs := make(container.Set[string]) + for _, job := range p.templateJobs { + templateJobIDs.Add(job.JobID) + } + + if len(jobsToRerun) == 0 { + p.rerunJobIDs = templateJobIDs + return nil + } + + rerunJobIDs := make(container.Set[string]) + for _, job := range jobsToRerun { + if !templateJobIDs.Contains(job.JobID) { + return util.NewInvalidArgumentErrorf("job %q does not exist in the latest attempt", job.JobID) + } + rerunJobIDs.Add(job.JobID) + } for { found := false - for _, j := range allJobs { - if rerunJobsIDSet.Contains(j.JobID) { + for _, job := range p.templateJobs { + if rerunJobIDs.Contains(job.JobID) { continue } - for _, need := range j.Needs { - if rerunJobsIDSet.Contains(need) { + for _, need := range job.Needs { + if rerunJobIDs.Contains(need) { found = true - rerunJobs = append(rerunJobs, j) - rerunJobsIDSet.Add(j.JobID) + rerunJobIDs.Add(job.JobID) break } } @@ -66,152 +321,100 @@ func GetAllRerunJobs(job *actions_model.ActionRunJob, allJobs []*actions_model.A } } - return rerunJobs + p.rerunJobIDs = rerunJobIDs + return nil } -// prepareRunRerun validates the run, resets its state, handles concurrency, persists the -// updated run, and fires a status-update notification. -// It returns isRunBlocked (true when the run itself is held by a concurrency group). -func prepareRunRerun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) (isRunBlocked bool, err error) { - if !run.Status.IsDone() { - return false, util.NewInvalidArgumentErrorf("this workflow run is not done") - } - - cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions) - - // Rerun is not allowed when workflow is disabled. - cfg := cfgUnit.ActionsConfig() - if cfg.IsWorkflowDisabled(run.WorkflowID) { - return false, util.NewInvalidArgumentErrorf("workflow %s is disabled", run.WorkflowID) - } - - // Reset run's timestamps and status. - run.PreviousDuration = run.Duration() - run.Started = 0 - run.Stopped = 0 - run.Status = actions_model.StatusWaiting - - vars, err := actions_model.GetVariablesOfRun(ctx, run) - if err != nil { - return false, fmt.Errorf("get run %d variables: %w", run.ID, err) - } - - if run.RawConcurrency != "" { - var rawConcurrency model.RawConcurrency - if err := yaml.Unmarshal([]byte(run.RawConcurrency), &rawConcurrency); err != nil { - return false, fmt.Errorf("unmarshal raw concurrency: %w", err) +func (p *rerunPlan) hasRerunDependency(job *actions_model.ActionRunJob) bool { + for _, need := range job.Needs { + if p.rerunJobIDs.Contains(need) { + return true } + } + return false +} - if err := EvaluateRunConcurrencyFillModel(ctx, run, &rawConcurrency, vars, nil); err != nil { - return false, err - } +func cloneRunJobForAttempt(templateJob *actions_model.ActionRunJob, attempt *actions_model.ActionRunAttempt) *actions_model.ActionRunJob { + return &actions_model.ActionRunJob{ + RunID: templateJob.RunID, + RunAttemptID: attempt.ID, + RepoID: templateJob.RepoID, + OwnerID: templateJob.OwnerID, + CommitSHA: templateJob.CommitSHA, + IsForkPullRequest: templateJob.IsForkPullRequest, + Name: templateJob.Name, + Attempt: attempt.Attempt, + WorkflowPayload: slices.Clone(templateJob.WorkflowPayload), + JobID: templateJob.JobID, + AttemptJobID: templateJob.AttemptJobID, + Needs: slices.Clone(templateJob.Needs), + RunsOn: slices.Clone(templateJob.RunsOn), + Status: templateJob.Status, + RawConcurrency: templateJob.RawConcurrency, + IsConcurrencyEvaluated: templateJob.IsConcurrencyEvaluated, + ConcurrencyGroup: templateJob.ConcurrencyGroup, + ConcurrencyCancel: templateJob.ConcurrencyCancel, + TokenPermissions: templateJob.TokenPermissions, + } +} - run.Status, err = PrepareToStartRunWithConcurrency(ctx, run) +// createOriginalAttemptForLegacyRun creates a real attempt=1 for a legacy run and updates the existing legacy jobs and artifacts in place +// so the original execution becomes attempt-aware before the rerun plan is built and all subsequent logic can use real attempts. +// Tasks are not modified: they reference jobs by JobID, so updating jobs implicitly carries the new attempt linkage. +func createOriginalAttemptForLegacyRun(ctx context.Context, run *actions_model.ActionRun) error { + return db.WithTx(ctx, func(ctx context.Context) error { + jobs, err := actions_model.GetRunJobsByRunAndAttemptID(ctx, run.ID, 0) if err != nil { - return false, err + return fmt.Errorf("load legacy run jobs: %w", err) + } + if len(jobs) == 0 { + return fmt.Errorf("run %d has no jobs", run.ID) } - } - if err := actions_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration", "status", "concurrency_group", "concurrency_cancel"); err != nil { - return false, err - } + originalAttempt := &actions_model.ActionRunAttempt{ + RepoID: run.RepoID, + RunID: run.ID, + Attempt: 1, + TriggerUserID: run.TriggerUserID, - if err := run.LoadAttributes(ctx); err != nil { - return false, err - } + // Legacy concurrency fields on ActionRun are intentionally NOT backfilled onto this original attempt. + // They only matter while a run is actively being scheduled, and backfilling them for completed legacy runs + // would add migration/runtime cost without changing any future concurrency behavior. - for _, job := range jobs { - job.Run = run - } + Status: run.Status, + Created: run.Created, + Started: run.Started, + Stopped: run.Stopped, + } - notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run) + // Use NoAutoTime so xorm does not overwrite Created with the current time on insert. + if _, err := db.GetEngine(ctx).NoAutoTime().Insert(originalAttempt); err != nil { + if _, getErr := actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, run.ID, originalAttempt.Attempt); getErr == nil { + return util.NewAlreadyExistErrorf("workflow run attempt %d for run %d already exists", originalAttempt.Attempt, run.ID) + } + return err + } - return run.Status == actions_model.StatusBlocked, nil -} - -// RerunWorkflowRunJobs reruns the given jobs of a workflow run. -// jobsToRerun must include all jobs to be rerun (the target job and its transitively dependent jobs). -// A job is blocked (waiting for dependencies) if the run itself is blocked or if any of its -// needs are also being rerun. -func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, jobsToRerun []*actions_model.ActionRunJob) error { - if len(jobsToRerun) == 0 { - return nil - } - - isRunBlocked, err := prepareRunRerun(ctx, repo, run, jobsToRerun) - if err != nil { - return err - } - - rerunJobIDs := make(container.Set[string]) - for _, j := range jobsToRerun { - rerunJobIDs.Add(j.JobID) - } - - for _, job := range jobsToRerun { - shouldBlockJob := isRunBlocked - if !shouldBlockJob { - for _, need := range job.Needs { - if rerunJobIDs.Contains(need) { - shouldBlockJob = true - break - } + // backfill attempt related fields for jobs + for i, job := range jobs { + job.RunAttemptID = originalAttempt.ID + job.Attempt = originalAttempt.Attempt + job.AttemptJobID = int64(i + 1) + if _, err := db.GetEngine(ctx).ID(job.ID).Cols("run_attempt_id", "attempt", "attempt_job_id").Update(job); err != nil { + return fmt.Errorf("backfill legacy run jobs: %w", err) } } - if err := rerunWorkflowJob(ctx, job, shouldBlockJob); err != nil { - return err - } - } - return nil -} - -func rerunWorkflowJob(ctx context.Context, job *actions_model.ActionRunJob, shouldBlock bool) error { - status := job.Status - if !status.IsDone() { - return nil - } - - job.TaskID = 0 - job.Status = util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting) - job.Started = 0 - job.Stopped = 0 - job.ConcurrencyGroup = "" - job.ConcurrencyCancel = false - job.IsConcurrencyEvaluated = false - - if err := job.LoadRun(ctx); err != nil { - return err - } - if err := job.Run.LoadAttributes(ctx); err != nil { - return err - } - - vars, err := actions_model.GetVariablesOfRun(ctx, job.Run) - if err != nil { - return fmt.Errorf("get run %d variables: %w", job.Run.ID, err) - } - - if job.RawConcurrency != "" && !shouldBlock { - if err := EvaluateJobConcurrencyFillModel(ctx, job.Run, job, vars, nil); err != nil { - return fmt.Errorf("evaluate job concurrency: %w", err) - } - - job.Status, err = PrepareToStartJobWithConcurrency(ctx, job) - if err != nil { - return err - } - } - - if err := db.WithTx(ctx, func(ctx context.Context) error { - updateCols := []string{"task_id", "status", "started", "stopped", "concurrency_group", "concurrency_cancel", "is_concurrency_evaluated"} - _, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": status}, updateCols...) - return err - }); err != nil { - return err - } - - CreateCommitStatusForRunJobs(ctx, job.Run, job) - notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) - return nil + // backfill "run_attempt_id" field for artifacts + if _, err := db.GetEngine(ctx). + Where("run_id=? AND run_attempt_id=0", run.ID). + Cols("run_attempt_id"). + Update(&actions_model.ActionArtifact{RunAttemptID: originalAttempt.ID}); err != nil { + return fmt.Errorf("backfill legacy artifacts: %w", err) + } + + // update "latest_attempt_id" for the run + run.LatestAttemptID = originalAttempt.ID + return actions_model.UpdateRun(ctx, run, "latest_attempt_id") + }) } diff --git a/services/actions/rerun_test.go b/services/actions/rerun_test.go index 3b4dc5483f4..30772980619 100644 --- a/services/actions/rerun_test.go +++ b/services/actions/rerun_test.go @@ -4,54 +4,17 @@ package actions import ( - "context" "testing" actions_model "code.gitea.io/gitea/models/actions" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestGetAllRerunJobs(t *testing.T) { - job1 := &actions_model.ActionRunJob{JobID: "job1"} - job2 := &actions_model.ActionRunJob{JobID: "job2", Needs: []string{"job1"}} - job3 := &actions_model.ActionRunJob{JobID: "job3", Needs: []string{"job2"}} - job4 := &actions_model.ActionRunJob{JobID: "job4", Needs: []string{"job2", "job3"}} - - jobs := []*actions_model.ActionRunJob{job1, job2, job3, job4} - - testCases := []struct { - job *actions_model.ActionRunJob - rerunJobs []*actions_model.ActionRunJob - }{ - { - job1, - []*actions_model.ActionRunJob{job1, job2, job3, job4}, - }, - { - job2, - []*actions_model.ActionRunJob{job2, job3, job4}, - }, - { - job3, - []*actions_model.ActionRunJob{job3, job4}, - }, - { - job4, - []*actions_model.ActionRunJob{job4}, - }, - } - - for _, tc := range testCases { - rerunJobs := GetAllRerunJobs(tc.job, jobs) - assert.ElementsMatch(t, tc.rerunJobs, rerunJobs) - } -} - -func TestGetFailedRerunJobs(t *testing.T) { - // IDs must be non-zero to distinguish jobs in the dedup set. +func TestGetFailedJobsForRerun(t *testing.T) { makeJob := func(id int64, jobID string, status actions_model.Status, needs ...string) *actions_model.ActionRunJob { return &actions_model.ActionRunJob{ID: id, JobID: jobID, Status: status, Needs: needs} } @@ -61,7 +24,7 @@ func TestGetFailedRerunJobs(t *testing.T) { makeJob(1, "job1", actions_model.StatusSuccess), makeJob(2, "job2", actions_model.StatusSkipped, "job1"), } - assert.Empty(t, GetFailedRerunJobs(jobs)) + assert.Empty(t, GetFailedJobsForRerun(jobs)) }) t.Run("single failed job with no dependents", func(t *testing.T) { @@ -69,56 +32,50 @@ func TestGetFailedRerunJobs(t *testing.T) { job2 := makeJob(2, "job2", actions_model.StatusSuccess) jobs := []*actions_model.ActionRunJob{job1, job2} - result := GetFailedRerunJobs(jobs) + result := GetFailedJobsForRerun(jobs) assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1}, result) }) - t.Run("failed job pulls in downstream dependents", func(t *testing.T) { - // job1 failed; job2 depends on job1 (skipped); job3 depends on job2 (skipped) + t.Run("failed job does not pull in downstream dependents", func(t *testing.T) { job1 := makeJob(1, "job1", actions_model.StatusFailure) job2 := makeJob(2, "job2", actions_model.StatusSkipped, "job1") job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job2") job4 := makeJob(4, "job4", actions_model.StatusSuccess) // unrelated, must not appear jobs := []*actions_model.ActionRunJob{job1, job2, job3, job4} - result := GetFailedRerunJobs(jobs) - assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2, job3}, result) + result := GetFailedJobsForRerun(jobs) + assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1}, result) }) - t.Run("multiple independent failed jobs each pull in their own dependents", func(t *testing.T) { - // job1 failed -> job3 depends on job1 - // job2 failed -> job4 depends on job2 + t.Run("multiple failed jobs are returned directly", func(t *testing.T) { job1 := makeJob(1, "job1", actions_model.StatusFailure) job2 := makeJob(2, "job2", actions_model.StatusFailure) job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job1") job4 := makeJob(4, "job4", actions_model.StatusSkipped, "job2") jobs := []*actions_model.ActionRunJob{job1, job2, job3, job4} - result := GetFailedRerunJobs(jobs) - assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2, job3, job4}, result) + result := GetFailedJobsForRerun(jobs) + assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2}, result) }) - t.Run("shared downstream dependent is not duplicated", func(t *testing.T) { - // job1 and job2 both failed; job3 depends on both + t.Run("shared downstream dependent is not included", func(t *testing.T) { job1 := makeJob(1, "job1", actions_model.StatusFailure) job2 := makeJob(2, "job2", actions_model.StatusFailure) job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job1", "job2") jobs := []*actions_model.ActionRunJob{job1, job2, job3} - result := GetFailedRerunJobs(jobs) - assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2, job3}, result) - assert.Len(t, result, 3) // job3 must appear exactly once + result := GetFailedJobsForRerun(jobs) + assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2}, result) + assert.Len(t, result, 2) }) - t.Run("successful downstream job of a failed job is still included", func(t *testing.T) { - // job1 failed; job2 succeeded but depends on job1 — downstream is always rerun - // regardless of its own status (GetAllRerunJobs includes all transitive dependents) + t.Run("successful downstream job of a failed job is not included", func(t *testing.T) { job1 := makeJob(1, "job1", actions_model.StatusFailure) job2 := makeJob(2, "job2", actions_model.StatusSuccess, "job1") jobs := []*actions_model.ActionRunJob{job1, job2} - result := GetFailedRerunJobs(jobs) - assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2}, result) + result := GetFailedJobsForRerun(jobs) + assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1}, result) }) } @@ -129,7 +86,7 @@ func TestRerunValidation(t *testing.T) { jobs := []*actions_model.ActionRunJob{ {ID: 1, JobID: "job1"}, } - err := RerunWorkflowRunJobs(context.Background(), nil, runningRun, jobs) + _, err := RerunWorkflowRunJobs(t.Context(), nil, runningRun, &user_model.User{ID: 1}, jobs) require.Error(t, err) assert.ErrorIs(t, err, util.ErrInvalidArgument) }) @@ -138,7 +95,7 @@ func TestRerunValidation(t *testing.T) { jobs := []*actions_model.ActionRunJob{ {ID: 1, JobID: "job1", Status: actions_model.StatusFailure}, } - err := RerunWorkflowRunJobs(context.Background(), nil, runningRun, GetFailedRerunJobs(jobs)) + _, err := RerunWorkflowRunJobs(t.Context(), nil, runningRun, &user_model.User{ID: 1}, GetFailedJobsForRerun(jobs)) require.Error(t, err) assert.ErrorIs(t, err, util.ErrInvalidArgument) }) diff --git a/services/actions/run.go b/services/actions/run.go index 432bb196284..162e3678aee 100644 --- a/services/actions/run.go +++ b/services/actions/run.go @@ -11,7 +11,6 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/actions/jobparser" "code.gitea.io/gitea/modules/util" - notify_service "code.gitea.io/gitea/services/notify" act_model "github.com/nektos/act/pkg/model" "go.yaml.in/yaml/v4" @@ -47,10 +46,7 @@ func PrepareRunAndInsert(ctx context.Context, content []byte, run *actions_model CreateCommitStatusForRunJobs(ctx, run, allJobs...) - notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run) - for _, job := range allJobs { - notify_service.WorkflowJobStatusUpdate(ctx, run.Repo, run.TriggerUser, job, nil) - } + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, allJobs) return nil } @@ -58,7 +54,8 @@ func PrepareRunAndInsert(ctx context.Context, content []byte, run *actions_model // InsertRun inserts a run // The title will be cut off at 255 characters if it's longer than 255 characters. func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte, vars map[string]string, inputs map[string]any, wfRawConcurrency *act_model.RawConcurrency) error { - return db.WithTx(ctx, func(ctx context.Context) error { + var cancelledConcurrencyJobs []*actions_model.ActionRunJob + if err := db.WithTx(ctx, func(ctx context.Context) error { index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID) if err != nil { return err @@ -67,6 +64,14 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte run.Title = util.EllipsisDisplayString(run.Title, 255) run.Status = actions_model.StatusWaiting + if wfRawConcurrency != nil { + rawConcurrency, err := yaml.Marshal(wfRawConcurrency) + if err != nil { + return fmt.Errorf("marshal raw concurrency: %w", err) + } + run.RawConcurrency = string(rawConcurrency) + } + // Insert before parsing jobs or evaluating workflow-level concurrency // so that run.ID is populated. Expressions referencing github.run_id — // in run-name, job names, runs-on, or a workflow-level concurrency @@ -76,31 +81,54 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte return err } - giteaCtx := GenerateGiteaContext(run, nil) + runAttempt := &actions_model.ActionRunAttempt{ + RepoID: run.RepoID, + RunID: run.ID, + Attempt: 1, + TriggerUserID: run.TriggerUserID, + Status: actions_model.StatusWaiting, + } + + if wfRawConcurrency != nil { + if err := EvaluateRunConcurrencyFillModel(ctx, run, runAttempt, wfRawConcurrency, vars, inputs); err != nil { + return fmt.Errorf("EvaluateRunConcurrencyFillModel: %w", err) + } + // check run (workflow-level) concurrency + var jobsToCancel []*actions_model.ActionRunJob + runAttempt.Status, jobsToCancel, err = PrepareToStartRunWithConcurrency(ctx, runAttempt) + if err != nil { + return err + } + cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...) + } + + if err := db.Insert(ctx, runAttempt); err != nil { + return err + } + run.LatestAttemptID = runAttempt.ID + + giteaCtx := GenerateGiteaContext(ctx, run, runAttempt, nil) jobs, err := jobparser.Parse(content, jobparser.WithVars(vars), jobparser.WithGitContext(giteaCtx.ToGitHubContext()), jobparser.WithInputs(inputs)) if err != nil { return fmt.Errorf("parse workflow: %w", err) } - titleChanged := len(jobs) > 0 && jobs[0].RunName != "" if titleChanged { run.Title = util.EllipsisDisplayString(jobs[0].RunName, 255) } - if wfRawConcurrency != nil { - if err := EvaluateRunConcurrencyFillModel(ctx, run, wfRawConcurrency, vars, inputs); err != nil { - return fmt.Errorf("EvaluateRunConcurrencyFillModel: %w", err) - } - run.Status, err = PrepareToStartRunWithConcurrency(ctx, run) - if err != nil { - return err - } + cols := []string{"latest_attempt_id"} + if titleChanged { + cols = append(cols, "title") + } + if err := actions_model.UpdateRun(ctx, run, cols...); err != nil { + return err } runJobs := make([]*actions_model.ActionRunJob, 0, len(jobs)) var hasWaitingJobs bool - for _, v := range jobs { + for i, v := range jobs { id, job := v.Job() needs := job.Needs() if err := v.SetJob(id, job.EraseNeeds()); err != nil { @@ -108,18 +136,21 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte } payload, _ := v.Marshal() - shouldBlockJob := len(needs) > 0 || run.NeedApproval || run.Status == actions_model.StatusBlocked + shouldBlockJob := runAttempt.Status == actions_model.StatusBlocked || len(needs) > 0 || run.NeedApproval job.Name = util.EllipsisDisplayString(job.Name, 255) runJob := &actions_model.ActionRunJob{ RunID: run.ID, + RunAttemptID: runAttempt.ID, RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA, IsForkPullRequest: run.IsForkPullRequest, Name: job.Name, + Attempt: runAttempt.Attempt, WorkflowPayload: payload, JobID: id, + AttemptJobID: int64(i + 1), Needs: needs, RunsOn: job.RunsOn(), Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting), @@ -139,7 +170,7 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte // do not evaluate job concurrency when it requires `needs`, the jobs with `needs` will be evaluated later by job emitter if len(needs) == 0 { - err = EvaluateJobConcurrencyFillModel(ctx, run, runJob, vars, inputs) + err = EvaluateJobConcurrencyFillModel(ctx, run, runAttempt, runJob, vars, inputs) if err != nil { return fmt.Errorf("evaluate job concurrency: %w", err) } @@ -148,10 +179,12 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte // If a job needs other jobs ("needs" is not empty), its status is set to StatusBlocked at the entry of the loop // No need to check job concurrency for a blocked job (it will be checked by job emitter later) if runJob.Status == actions_model.StatusWaiting { - runJob.Status, err = PrepareToStartJobWithConcurrency(ctx, runJob) + var jobsToCancel []*actions_model.ActionRunJob + runJob.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, runJob) if err != nil { return fmt.Errorf("prepare to start job with concurrency: %w", err) } + cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...) } } @@ -163,15 +196,8 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte runJobs = append(runJobs, runJob) } - run.Status = actions_model.AggregateJobStatus(runJobs) - cols := []string{"status"} - if titleChanged { - cols = append(cols, "title") - } - if wfRawConcurrency != nil { - cols = append(cols, "raw_concurrency", "concurrency_group", "concurrency_cancel") - } - if err := actions_model.UpdateRun(ctx, run, cols...); err != nil { + runAttempt.Status = actions_model.AggregateJobStatus(runJobs) + if err := actions_model.UpdateRunAttempt(ctx, runAttempt, "status"); err != nil { return err } @@ -183,5 +209,12 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte } return nil - }) + }); err != nil { + return err + } + + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, cancelledConcurrencyJobs) + EmitJobsIfReadyByJobs(cancelledConcurrencyJobs) + + return nil } diff --git a/services/actions/task.go b/services/actions/task.go index 2cb10b6cd8f..9dc3c9a34b7 100644 --- a/services/actions/task.go +++ b/services/actions/task.go @@ -11,7 +11,6 @@ import ( actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" secret_model "code.gitea.io/gitea/models/secret" - notify_service "code.gitea.io/gitea/services/notify" runnerv1 "code.gitea.io/actions-proto-go/runner/v1" "google.golang.org/protobuf/types/known/structpb" @@ -78,7 +77,7 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv return fmt.Errorf("findTaskNeeds: %w", err) } - taskContext, err := generateTaskContext(t) + taskContext, err := generateTaskContext(ctx, t) if err != nil { return fmt.Errorf("generateTaskContext: %w", err) } @@ -102,23 +101,23 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv } CreateCommitStatusForRunJobs(ctx, job.Run, job) - notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, actionTask) + NotifyWorkflowJobStatusUpdateWithTask(ctx, job, actionTask) // job.Run is loaded inside the transaction before UpdateRunJob sets run.Started, // so Started is zero only on the very first pick-up of that run. if job.Run.Started.IsZero() { - NotifyWorkflowRunStatusUpdateWithReload(ctx, job) + NotifyWorkflowRunStatusUpdateWithReload(ctx, job.RepoID, job.RunID) } return task, true, nil } -func generateTaskContext(t *actions_model.ActionTask) (*structpb.Struct, error) { +func generateTaskContext(ctx context.Context, t *actions_model.ActionTask) (*structpb.Struct, error) { giteaRuntimeToken, err := CreateAuthorizationToken(t.ID, t.Job.RunID, t.JobID) if err != nil { return nil, err } - gitCtx := GenerateGiteaContext(t.Job.Run, t.Job) + gitCtx := GenerateGiteaContext(ctx, t.Job.Run, nil, t.Job) gitCtx["token"] = t.Token gitCtx["gitea_runtime_token"] = giteaRuntimeToken diff --git a/services/convert/action_test.go b/services/convert/action_test.go index a0a16cb0faa..9ecb4a2ca64 100644 --- a/services/convert/action_test.go +++ b/services/convert/action_test.go @@ -120,7 +120,7 @@ func TestToActionWorkflowRun_UsesTriggerEvent(t *testing.T) { run.Event = "push" run.TriggerEvent = "schedule" - apiRun, err := ToActionWorkflowRun(t.Context(), repo, run) + apiRun, err := ToActionWorkflowRun(t.Context(), repo, run, nil) require.NoError(t, err) assert.Equal(t, "schedule", apiRun.Event) } diff --git a/services/convert/convert.go b/services/convert/convert.go index 269defe6a11..3ef3b4f9cb2 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -247,30 +247,64 @@ func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.Action }, nil } -func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun) (*api.ActionWorkflowRun, error) { - err := run.LoadAttributes(ctx) - if err != nil { +func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt) (*api.ActionWorkflowRun, error) { + if err := run.LoadAttributes(ctx); err != nil { return nil, err } + + if attempt == nil { + if latestAttempt, has, err := run.GetLatestAttempt(ctx); err != nil { + return nil, err + } else if has { + attempt = latestAttempt + } + } + + runAttempt := int64(0) status, conclusion := ToActionsStatus(run.Status) + startedAt := run.Started.AsLocalTime() + completedAt := run.Stopped.AsLocalTime() + actor := run.TriggerUser // The username of the user that triggered the initial workflow run. + triggerUser := run.TriggerUser // The username of the user that initiated the workflow run. If the workflow run is a re-run, this value may differ from actor. + + // previousAttemptURL is the value of ActionWorkflowRun.PreviousAttemptURL, which is declared as *string without `omitempty` on purpose: + // a nil value must still appear in the JSON body as `"previous_attempt_url": null`, matching GitHub's Actions API. + var previousAttemptURL *string + + if attempt != nil { + if err := attempt.LoadAttributes(ctx); err != nil { + return nil, err + } + runAttempt = attempt.Attempt + status, conclusion = ToActionsStatus(attempt.Status) + startedAt = attempt.Started.AsLocalTime() + completedAt = attempt.Stopped.AsLocalTime() + triggerUser = attempt.TriggerUser + if attempt.Attempt > 1 { + url := fmt.Sprintf("%s/actions/runs/%d/attempts/%d", repo.APIURL(), run.ID, attempt.Attempt-1) + previousAttemptURL = &url + } + } + return &api.ActionWorkflowRun{ - ID: run.ID, - URL: fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), run.ID), - HTMLURL: run.HTMLURL(), - RunNumber: run.Index, - StartedAt: run.Started.AsLocalTime(), - CompletedAt: run.Stopped.AsLocalTime(), - Event: run.TriggerEvent, - DisplayTitle: run.Title, - HeadBranch: git.RefName(run.Ref).BranchName(), - HeadSha: run.CommitSHA, - Status: status, - Conclusion: conclusion, - Path: fmt.Sprintf("%s@%s", run.WorkflowID, run.Ref), - Repository: ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeNone}), - TriggerActor: ToUser(ctx, run.TriggerUser, nil), - // We do not have a way to get a different User for the actor than the trigger user - Actor: ToUser(ctx, run.TriggerUser, nil), + ID: run.ID, + URL: fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), run.ID), + PreviousAttemptURL: previousAttemptURL, + HTMLURL: run.HTMLURL(), + RunNumber: run.Index, + RunAttempt: runAttempt, + StartedAt: startedAt, + CompletedAt: completedAt, + Event: run.TriggerEvent, + DisplayTitle: run.Title, + HeadBranch: git.RefName(run.Ref).BranchName(), + HeadSha: run.CommitSHA, + Status: status, + Conclusion: conclusion, + Path: fmt.Sprintf("%s@%s", run.WorkflowID, run.Ref), + Repository: ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeNone}), + TriggerActor: ToUser(ctx, triggerUser, nil), + Actor: ToUser(ctx, actor, nil), }, nil } @@ -329,9 +363,9 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task var runnerName string var steps []*api.ActionWorkflowStep - if job.TaskID != 0 { + if effectiveTaskID := job.EffectiveTaskID(); effectiveTaskID != 0 { if task == nil { - task, _, err = db.GetByID[actions_model.ActionTask](ctx, job.TaskID) + task, _, err = db.GetByID[actions_model.ActionTask](ctx, effectiveTaskID) if err != nil { return nil, err } diff --git a/services/mailer/mail_workflow_run.go b/services/mailer/mail_workflow_run.go index 9efaa4182b4..18c13bcc755 100644 --- a/services/mailer/mail_workflow_run.go +++ b/services/mailer/mail_workflow_run.go @@ -37,7 +37,7 @@ func generateMessageIDForActionsWorkflowRunStatusEmail(repo *repo_model.Reposito } func composeAndSendActionsWorkflowRunStatusEmail(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, sender *user_model.User, recipients []*user_model.User) error { - jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, repo.ID, run.ID) if err != nil { return err } diff --git a/services/notify/notify.go b/services/notify/notify.go index 2416cbd2e08..152d53b01c9 100644 --- a/services/notify/notify.go +++ b/services/notify/notify.go @@ -399,12 +399,18 @@ func CreateCommitStatus(ctx context.Context, repo *repo_model.Repository, commit } } +// WorkflowRunStatusUpdate dispatches a workflow run status change to every registered notifier. +// Prefer the helpers in services/actions/notify.go over calling this directly; +// unless you are sure the caller has already resolved the correct sender and paired notifications. func WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) { for _, notifier := range notifiers { notifier.WorkflowRunStatusUpdate(ctx, repo, sender, run) } } +// WorkflowJobStatusUpdate dispatches a workflow job status change to every registered notifier. +// Prefer the helpers in services/actions/notify.go over calling this directly; +// unless you are sure the caller has already resolved the correct sender and paired notifications. func WorkflowJobStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask) { for _, notifier := range notifiers { notifier.WorkflowJobStatusUpdate(ctx, repo, sender, job, task) diff --git a/services/webhook/notifier.go b/services/webhook/notifier.go index 2b301d4d583..d2575e9931f 100644 --- a/services/webhook/notifier.go +++ b/services/webhook/notifier.go @@ -1043,7 +1043,7 @@ func (*webhookNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_ return } - convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run) + convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run, nil) if err != nil { log.Error("ToActionWorkflowRun: %v", err) return diff --git a/templates/devtest/repo-action-view.tmpl b/templates/devtest/repo-action-view.tmpl index 46f040d8a6f..2971039fc94 100644 --- a/templates/devtest/repo-action-view.tmpl +++ b/templates/devtest/repo-action-view.tmpl @@ -3,12 +3,12 @@ {{template "repo/actions/view_component" (dict - "RunID" (or .RunID 10) "JobID" (or .JobID 0) - "ActionsURL" (print AppSubUrl "/devtest/repo-action-view") + "ActionsViewURL" $.ActionsViewURL )}} {{template "base/footer" .}} diff --git a/templates/repo/actions/view.tmpl b/templates/repo/actions/view.tmpl index 1eb84a9b937..3f879e0e5a9 100644 --- a/templates/repo/actions/view.tmpl +++ b/templates/repo/actions/view.tmpl @@ -3,9 +3,8 @@
{{template "repo/header" .}} {{template "repo/actions/view_component" (dict - "RunID" .RunID "JobID" .JobID - "ActionsURL" .ActionsURL + "ActionsViewURL" .ActionsViewURL )}}
diff --git a/templates/repo/actions/view_component.tmpl b/templates/repo/actions/view_component.tmpl index 2cc70e499ad..67926276c0c 100644 --- a/templates/repo/actions/view_component.tmpl +++ b/templates/repo/actions/view_component.tmpl @@ -1,17 +1,18 @@ -
{ test('active artifact', () => { + const expiresUnix = Date.UTC(2026, 2, 20, 12, 0, 0) / 1000; + const expiresLocal = new Date(expiresUnix * 1000).toLocaleString(); const result = buildArtifactTooltipHtml({ name: 'artifact.zip', size: 1024 * 1024, status: 'completed', - expiresUnix: Date.UTC(2026, 2, 20, 12, 0, 0) / 1000, + expiresUnix, }, 'Expires at %s (extra)'); expect(normalizeTestHtml(result)).toBe(normalizeTestHtml(` Expires at - - 2026-03-20T12:00:00.000Z + + ${expiresLocal} (extra) , diff --git a/web_src/js/components/ActionRunArtifacts.ts b/web_src/js/components/ActionRunArtifacts.ts index ca8f5991620..84787fc5709 100644 --- a/web_src/js/components/ActionRunArtifacts.ts +++ b/web_src/js/components/ActionRunArtifacts.ts @@ -7,15 +7,14 @@ export function buildArtifactTooltipHtml(artifact: ActionsArtifact, expiresAtLoc if (artifact.expiresUnix <= 0) { return html`${sizeText}`; // use the same layout as below } - + const datetimeLocal = new Date(artifact.expiresUnix * 1000).toLocaleString(); // split so the element can be interleaved, e.g. "Expires at %s" -> ["Expires at ", ""] const [prefix, suffix = ''] = expiresAtLocale.split('%s'); - const datetime = new Date(artifact.expiresUnix * 1000).toISOString(); return html` ${prefix} - - ${datetime} + + ${datetimeLocal} ${suffix} , diff --git a/web_src/js/components/ActionRunJobView.vue b/web_src/js/components/ActionRunJobView.vue index 67d4c1048b5..89b0d35b37b 100644 --- a/web_src/js/components/ActionRunJobView.vue +++ b/web_src/js/components/ActionRunJobView.vue @@ -77,9 +77,8 @@ defineOptions({ const props = defineProps<{ store: ActionRunViewStore, - runId: number; jobId: number; - actionsUrl: string; + actionsViewUrl: string; locale: Record; }>(); const store = props.store; @@ -270,8 +269,7 @@ async function fetchJobData(abortController: AbortController): Promise // for example: make cursor=null means the first time to fetch logs, cursor=eof means no more logs, etc return {step: idx, cursor: it.cursor, expanded: it.expanded}; }); - const url = `${props.actionsUrl}/runs/${props.runId}/jobs/${props.jobId}`; - const resp = await POST(url, { + const resp = await POST(props.actionsViewUrl, { signal: abortController.signal, data: {logCursors}, }); diff --git a/web_src/js/components/ActionRunSummaryView.vue b/web_src/js/components/ActionRunSummaryView.vue index 48af966c94b..a50ccaf5b6f 100644 --- a/web_src/js/components/ActionRunSummaryView.vue +++ b/web_src/js/components/ActionRunSummaryView.vue @@ -13,11 +13,18 @@ const props = defineProps<{ locale: Record; }>(); +const locale = props.locale; const {currentRun: run} = toRefs(props.store.viewData); -const runTriggeredAtIso = computed(() => { - const t = props.store.viewData.currentRun.triggeredAt; - return t ? new Date(t * 1000).toISOString() : ''; +const isRerun = computed(() => run.value.runAttempt > 1); + +const triggerUser = computed(() => { + const currentAttempt = run.value.attempts.find((attempt) => attempt.current); + if (currentAttempt) { + return {name: currentAttempt.triggerUserName, link: currentAttempt.triggerUserLink}; + } + const pusher = run.value.commit.pusher; + return pusher.displayName ? {name: pusher.displayName, link: pusher.link} : null; }); onMounted(async () => { @@ -32,7 +39,14 @@ onBeforeUnmount(() => {
- {{ locale.triggeredVia.replace('%s', run.triggerEvent) }} • + {{ isRerun ? locale.rerun : locale.triggeredVia.replace('%s', run.triggerEvent) }} + + +
diff --git a/web_src/js/components/ActionRunView.ts b/web_src/js/components/ActionRunView.ts index 133b7263eba..1bc1844dc1d 100644 --- a/web_src/js/components/ActionRunView.ts +++ b/web_src/js/components/ActionRunView.ts @@ -91,6 +91,7 @@ export function createEmptyActionsRun(): ActionsRun { return { repoId: 0, link: '', + viewLink: '', title: '', titleHTML: '', status: '' as ActionsRunStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon @@ -103,6 +104,8 @@ export function createEmptyActionsRun(): ActionsRun { workflowID: '', workflowLink: '', isSchedule: false, + runAttempt: 0, + attempts: [], duration: '', triggeredAt: 0, triggerEvent: '', @@ -125,7 +128,7 @@ export function createEmptyActionsRun(): ActionsRun { }; } -export function createActionRunViewStore(actionsUrl: string, runId: number) { +export function createActionRunViewStore(viewUrl: string) { let loadingAbortController: AbortController | null = null; let intervalID: IntervalId | null = null; const viewData = reactive({ @@ -137,8 +140,7 @@ export function createActionRunViewStore(actionsUrl: string, runId: number) { const abortController = new AbortController(); loadingAbortController = abortController; try { - const url = `${actionsUrl}/runs/${runId}`; - const resp = await POST(url, {signal: abortController.signal, data: {}}); + const resp = await POST(viewUrl, {signal: abortController.signal, data: {}}); const runResp = await resp.json(); if (loadingAbortController !== abortController) return; diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index 9877a8fcc2e..4d20392dacc 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -5,6 +5,7 @@ import {toRefs} from 'vue'; import {POST, DELETE} from '../modules/fetch.ts'; import ActionRunSummaryView from './ActionRunSummaryView.vue'; import ActionRunJobView from './ActionRunJobView.vue'; +import type {ActionsRunAttempt} from '../modules/gitea-actions.ts'; import {createActionRunViewStore} from './ActionRunView.ts'; import {buildArtifactTooltipHtml} from './ActionRunArtifacts.ts'; @@ -13,16 +14,28 @@ defineOptions({ }); const props = defineProps<{ - runId: number; jobId: number; - actionsUrl: string; + actionsViewUrl: string; locale: Record; }>(); const locale = props.locale; -const store = createActionRunViewStore(props.actionsUrl, props.runId); +const store = createActionRunViewStore(props.actionsViewUrl); const {currentRun: run, runArtifacts: artifacts} = toRefs(store.viewData); +function formatAttemptTitle(attempt: ActionsRunAttempt) { + return attempt.latest ? `${locale.latestAttempt} #${attempt.attempt}` : `${locale.attempt} #${attempt.attempt}`; +} + +function formatCurrentAttemptTitle(attempt: ActionsRunAttempt) { + return attempt.latest ? `${locale.latest} #${attempt.attempt}` : formatAttemptTitle(attempt); +} + +function buildArtifactLink(name: string) { + const searchString = run.value.runAttempt > 0 ? `?attempt=${run.value.runAttempt}` : ''; + return `${run.value.link}/artifacts/${encodeURIComponent(name)}${searchString}`; +} + function cancelRun() { POST(`${run.value.link}/cancel`); } @@ -33,7 +46,7 @@ function approveRun() { async function deleteArtifact(name: string) { if (!window.confirm(locale.confirmDeleteArtifact.replace('%s', name))) return; - await DELETE(`${run.value.link}/artifacts/${encodeURIComponent(name)}`); + await DELETE(buildArtifactLink(name)); await store.forceReloadCurrentRun(); } @@ -51,10 +64,10 @@ async function deleteArtifact(name: string) { - -