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 <zettat123@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
Zettat123
2026-04-23 17:33:41 -06:00
committed by GitHub
parent aedf4e84f5
commit 899ede1d55
74 changed files with 3838 additions and 848 deletions
+2
View File
@@ -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
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+38 -13
View File
@@ -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})
+50 -25
View File
@@ -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...)
}
+145
View File
@@ -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")
}
+46
View File
@@ -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)
}
+119 -30
View File
@@ -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...)
}
+5
View File
@@ -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})
}
-6
View File
@@ -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
}
-1
View File
@@ -272,7 +272,6 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
}
now := timeutil.TimeStampNow()
job.Attempt++
job.Started = now
job.Status = StatusRunning
+4
View File
@@ -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
}
+14
View File
@@ -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)
}
+158
View File
@@ -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))
}
+156
View File
@@ -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
}
+8
View File
@@ -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)
}
+12 -6
View File
@@ -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"`
+3 -1
View File
@@ -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",
+11 -3
View File
@@ -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")
+3 -1
View File
@@ -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 {
+10 -8
View File
@@ -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())
+2 -3
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
+4
View File
@@ -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)
+1 -1
View File
@@ -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) {
+173 -11
View File
@@ -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
}
+7 -2
View File
@@ -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
+1 -1
View File
@@ -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)
}
+3 -2
View File
@@ -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)
}
+110 -9
View File
@@ -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 <a href="/">link</a>`
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{
+1 -1
View File
@@ -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
+282 -161
View File
@@ -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
}
+7
View File
@@ -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)
})
}
+69
View File
@@ -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
}
+5 -1
View File
@@ -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,
+25 -59
View File
@@ -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
+7 -11
View File
@@ -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 {
+39 -8
View File
@@ -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)
}
+12 -6
View File
@@ -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)
+97 -64
View File
@@ -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)
}
+50 -23
View File
@@ -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) {
+1 -1
View File
@@ -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
+144
View File
@@ -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)
}
+359 -156
View File
@@ -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")
})
}
+19 -62
View File
@@ -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)
})
+63 -30
View File
@@ -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
}
+5 -6
View File
@@ -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
+1 -1
View File
@@ -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)
}
+56 -22
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+6
View File
@@ -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)
+1 -1
View File
@@ -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
+3 -3
View File
@@ -3,12 +3,12 @@
<div class="flex-text-block tw-justify-center tw-gap-5">
<a href="/devtest/repo-action-view/runs/10">Run:CanCancel</a>
<a href="/devtest/repo-action-view/runs/20">Run:CanApprove</a>
<a href="/devtest/repo-action-view/runs/30">Run:CanRerun</a>
<a href="/devtest/repo-action-view/runs/30">Run:CanRerunLatest</a>
<a href="/devtest/repo-action-view/runs/10/attempts/2">Run:PreviousAttempt</a>
</div>
{{template "repo/actions/view_component" (dict
"RunID" (or .RunID 10)
"JobID" (or .JobID 0)
"ActionsURL" (print AppSubUrl "/devtest/repo-action-view")
"ActionsViewURL" $.ActionsViewURL
)}}
</div>
{{template "base/footer" .}}
+1 -2
View File
@@ -3,9 +3,8 @@
<div class="page-content repository">
{{template "repo/header" .}}
{{template "repo/actions/view_component" (dict
"RunID" .RunID
"JobID" .JobID
"ActionsURL" .ActionsURL
"ActionsViewURL" .ActionsViewURL
)}}
</div>
+5 -4
View File
@@ -1,17 +1,18 @@
<div id="repo-action-view"
data-run-id="{{.RunID}}"
<div id="repo-action-view"
data-job-id="{{.JobID}}"
data-actions-url="{{.ActionsURL}}"
data-actions-view-url="{{.ActionsViewURL}}"
data-locale-approve="{{ctx.Locale.Tr "repo.diff.review.approve"}}"
data-locale-cancel="{{ctx.Locale.Tr "actions.runs.cancel"}}"
data-locale-rerun="{{ctx.Locale.Tr "rerun"}}"
data-locale-rerun-all="{{ctx.Locale.Tr "rerun_all"}}"
data-locale-rerun-failed="{{ctx.Locale.Tr "rerun_failed"}}"
data-locale-latest="{{ctx.Locale.Tr "actions.runs.latest"}}"
data-locale-latest-attempt="{{ctx.Locale.Tr "actions.runs.latest_attempt"}}"
data-locale-attempt="{{ctx.Locale.Tr "actions.runs.attempt"}}"
data-locale-runs-scheduled="{{ctx.Locale.Tr "actions.runs.scheduled"}}"
data-locale-runs-commit="{{ctx.Locale.Tr "actions.runs.commit"}}"
data-locale-runs-pushed-by="{{ctx.Locale.Tr "actions.runs.pushed_by"}}"
data-locale-runs-workflow-graph="{{ctx.Locale.Tr "actions.runs.workflow_graph"}}"
data-locale-summary="{{ctx.Locale.Tr "actions.runs.summary"}}"
data-locale-all-jobs="{{ctx.Locale.Tr "actions.runs.all_jobs"}}"
data-locale-triggered-via="{{ctx.Locale.Tr "actions.runs.triggered_via"}}"
+139
View File
@@ -5473,6 +5473,130 @@
}
}
},
"/repos/{owner}/{repo}/actions/runs/{run}/attempts/{attempt}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Gets a specific workflow run attempt",
"operationId": "getWorkflowRunAttempt",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repository",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "id of the run",
"name": "run",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "logical attempt number of the run",
"name": "attempt",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/WorkflowRun"
},
"400": {
"$ref": "#/responses/error"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/actions/runs/{run}/attempts/{attempt}/jobs": {
"get": {
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Lists all jobs for a workflow run attempt",
"operationId": "listWorkflowRunAttemptJobs",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repository",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "id of the workflow run",
"name": "run",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "logical attempt number of the run",
"name": "attempt",
"in": "path",
"required": true
},
{
"type": "string",
"description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
"name": "status",
"in": "query"
},
{
"type": "integer",
"description": "page number of results to return (1-based)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "page size of results",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"$ref": "#/responses/WorkflowJobsList"
},
"400": {
"$ref": "#/responses/error"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/actions/runs/{run}/jobs": {
"get": {
"produces": [
@@ -5590,6 +5714,9 @@
"404": {
"$ref": "#/responses/notFound"
},
"409": {
"$ref": "#/responses/error"
},
"422": {
"$ref": "#/responses/validationError"
}
@@ -5642,6 +5769,9 @@
"404": {
"$ref": "#/responses/notFound"
},
"409": {
"$ref": "#/responses/error"
},
"422": {
"$ref": "#/responses/validationError"
}
@@ -5691,6 +5821,9 @@
"404": {
"$ref": "#/responses/notFound"
},
"409": {
"$ref": "#/responses/error"
},
"422": {
"$ref": "#/responses/validationError"
}
@@ -21896,6 +22029,11 @@
"type": "string",
"x-go-name": "Path"
},
"previous_attempt_url": {
"description": "PreviousAttemptURL is the API URL of the previous attempt of this run, e.g. \".../actions/runs/{run_id}/attempts/{attempt-1}\".\nIt is set only when the current attempt is \u003e 1 (i.e. a rerun). For the first attempt, or for legacy runs that pre-date ActionRunAttempt, it is null.",
"type": "string",
"x-go-name": "PreviousAttemptURL"
},
"repository": {
"$ref": "#/definitions/Repository"
},
@@ -21905,6 +22043,7 @@
"x-go-name": "RepositoryID"
},
"run_attempt": {
"description": "RunAttempt is 1-based for runs created after ActionRunAttempt was introduced.\nA value of 0 is a legacy-only sentinel for runs created before attempts existed\nand indicates no corresponding /attempts/{n} resource is available.",
"type": "integer",
"format": "int64",
"x-go-name": "RunAttempt"
+216 -47
View File
@@ -13,6 +13,7 @@ import (
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
@@ -20,10 +21,12 @@ import (
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/timeutil"
webhook_module "code.gitea.io/gitea/modules/webhook"
actions_web "code.gitea.io/gitea/routers/web/repo/actions"
actions_service "code.gitea.io/gitea/services/actions"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWorkflowConcurrency(t *testing.T) {
@@ -96,7 +99,7 @@ jobs:
// fetch and exec workflow1
task := runner.fetchTask(t)
_, _, run := getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-1.yml", run.WorkflowID)
runner.fetchNoTask(t)
runner.execTask(t, task, &mockTaskOutcome{
@@ -109,7 +112,7 @@ jobs:
// fetch workflow2
task = runner.fetchTask(t)
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-2.yml", run.WorkflowID)
// push workflow3
@@ -125,7 +128,7 @@ jobs:
// fetch and exec workflow3
task = runner.fetchTask(t)
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-3.yml", run.WorkflowID)
runner.fetchNoTask(t)
runner.execTask(t, task, &mockTaskOutcome{
@@ -201,7 +204,7 @@ jobs:
// fetch and exec workflow1
task := runner.fetchTask(t)
_, _, run := getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-1.yml", run.WorkflowID)
runner.fetchNoTask(t)
runner.execTask(t, task, &mockTaskOutcome{
@@ -214,7 +217,7 @@ jobs:
// fetch workflow2
task = runner.fetchTask(t)
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-2.yml", run.WorkflowID)
// push workflow3
@@ -230,7 +233,7 @@ jobs:
// fetch and exec workflow3
task = runner.fetchTask(t)
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-3.yml", run.WorkflowID)
runner.fetchNoTask(t)
runner.execTask(t, task, &mockTaskOutcome{
@@ -318,7 +321,7 @@ jobs:
// fetch and exec workflow1
task := runner.fetchTask(t)
_, _, run := getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-1.yml", run.WorkflowID)
runner.fetchNoTask(t)
runner.execTask(t, task, &mockTaskOutcome{
@@ -331,7 +334,7 @@ jobs:
// fetch workflow2
task = runner.fetchTask(t)
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-2.yml", run.WorkflowID)
// push workflow3
@@ -347,7 +350,7 @@ jobs:
// fetch and exec workflow3
task = runner.fetchTask(t)
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-3.yml", run.WorkflowID)
runner.fetchNoTask(t)
runner.execTask(t, task, &mockTaskOutcome{
@@ -412,8 +415,8 @@ jobs:
doAPICreatePullRequest(user2APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, "bugfix/aaa")(t)
pr1Task1 := runner.fetchTask(t)
_, _, pr1Run1 := getTaskAndJobAndRunByTaskID(t, pr1Task1.Id)
assert.Equal(t, "pull-request-test", pr1Run1.ConcurrencyGroup)
assert.True(t, pr1Run1.ConcurrencyCancel)
assert.Equal(t, "pull-request-test", getRunConcurrencyGroup(t, pr1Run1))
assert.True(t, getRunConcurrencyCancel(t, pr1Run1))
assert.Equal(t, actions_model.StatusRunning, pr1Run1.Status)
// user4 forks the repo
@@ -458,8 +461,8 @@ jobs:
// fetch the task and the previous task has been cancelled
pr2Task1 := runner.fetchTask(t)
_, _, pr2Run1 = getTaskAndJobAndRunByTaskID(t, pr2Task1.Id)
assert.Equal(t, "pull-request-test", pr2Run1.ConcurrencyGroup)
assert.True(t, pr2Run1.ConcurrencyCancel)
assert.Equal(t, "pull-request-test", getRunConcurrencyGroup(t, pr2Run1))
assert.True(t, getRunConcurrencyCancel(t, pr2Run1))
assert.Equal(t, actions_model.StatusRunning, pr2Run1.Status)
pr1Run1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: pr1Run1.ID})
assert.Equal(t, actions_model.StatusCancelled, pr1Run1.Status)
@@ -495,8 +498,8 @@ jobs:
// fetch the task
pr3Task1 := runner.fetchTask(t)
_, _, pr3Run1 := getTaskAndJobAndRunByTaskID(t, pr3Task1.Id)
assert.Equal(t, "pull-request-test", pr3Run1.ConcurrencyGroup)
assert.False(t, pr3Run1.ConcurrencyCancel)
assert.Equal(t, "pull-request-test", getRunConcurrencyGroup(t, pr3Run1))
assert.False(t, getRunConcurrencyCancel(t, pr3Run1))
assert.Equal(t, actions_model.StatusRunning, pr3Run1.Status)
})
}
@@ -643,6 +646,7 @@ jobs:
assert.Equal(t, "job-main-v1.24.0", wf2Job2Rerun1Job.ConcurrencyGroup)
// rerun wf2-job2
wf2Job2ActionJob = getLatestAttemptJobByTemplateJobID(t, wf2Run.ID, wf2Job2ActionJob.ID)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, wf2Run.ID, wf2Job2ActionJob.ID))
_ = session.MakeRequest(t, req, http.StatusOK)
// (rerun2) fetch and exec wf2-job2
@@ -803,7 +807,7 @@ jobs:
session.MakeRequest(t, req, http.StatusSeeOther)
task1 := runner.fetchTask(t)
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
assert.Equal(t, "workflow-dispatch-v1.21", run1.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.21", getRunConcurrencyGroup(t, run1))
// run the workflow with appVersion=v1.22 and cancel=false
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
@@ -813,7 +817,7 @@ jobs:
session.MakeRequest(t, req, http.StatusSeeOther)
task2 := runner.fetchTask(t)
_, _, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, "workflow-dispatch-v1.22", run2.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run2))
// run the workflow with appVersion=v1.22 and cancel=false again
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
@@ -832,7 +836,7 @@ jobs:
session.MakeRequest(t, req, http.StatusSeeOther)
task4 := runner.fetchTask(t)
_, _, run4 := getTaskAndJobAndRunByTaskID(t, task4.Id)
assert.Equal(t, "workflow-dispatch-v1.22", run4.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4))
_, _, run2 = getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, actions_model.StatusCancelled, run2.Status)
})
@@ -893,7 +897,7 @@ jobs:
session.MakeRequest(t, req, http.StatusSeeOther)
task1 := runner.fetchTask(t)
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
assert.Equal(t, "workflow-dispatch-v1.21", run1.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.21", getRunConcurrencyGroup(t, run1))
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
"ref": "refs/heads/main",
@@ -902,7 +906,7 @@ jobs:
session.MakeRequest(t, req, http.StatusSeeOther)
task2 := runner.fetchTask(t)
_, _, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, "workflow-dispatch-v1.22", run2.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run2))
// run the workflow with appVersion=v1.22 and cancel=false again
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
@@ -927,7 +931,7 @@ jobs:
task4 := runner.fetchTask(t)
_, _, run4 := getTaskAndJobAndRunByTaskID(t, task4.Id)
assert.Equal(t, actions_model.StatusRunning, run4.Status)
assert.Equal(t, "workflow-dispatch-v1.22", run4.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4))
_, _, run2 = getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, actions_model.StatusCancelled, run2.Status)
@@ -945,7 +949,7 @@ jobs:
task5 := runner.fetchTask(t)
_, _, run4_1 := getTaskAndJobAndRunByTaskID(t, task5.Id)
assert.Equal(t, "workflow-dispatch-v1.22", run4_1.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4_1))
assert.Equal(t, run4.ID, run4_1.ID)
_, _, run2_1 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, actions_model.StatusCancelled, run2_1.Status)
@@ -969,7 +973,7 @@ jobs:
_, _, run3_2 := getTaskAndJobAndRunByTaskID(t, task6.Id)
assert.Equal(t, run3.ID, run3_2.ID)
assert.Equal(t, actions_model.StatusRunning, run3_2.Status)
assert.Equal(t, "workflow-dispatch-v1.22", run3.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run3))
run2_2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run2_2.ID})
assert.Equal(t, actions_model.StatusCancelled, run2_2.Status) // cancelled by run3
@@ -1031,7 +1035,7 @@ jobs:
session.MakeRequest(t, req, http.StatusSeeOther)
task1 := runner.fetchTask(t)
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
assert.Equal(t, "workflow-dispatch-v1.21", run1.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.21", getRunConcurrencyGroup(t, run1))
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
"ref": "refs/heads/main",
@@ -1040,7 +1044,7 @@ jobs:
session.MakeRequest(t, req, http.StatusSeeOther)
task2 := runner.fetchTask(t)
_, job2, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, "workflow-dispatch-v1.22", run2.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run2))
// run the workflow with appVersion=v1.22 and cancel=false again
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
@@ -1065,7 +1069,7 @@ jobs:
task4 := runner.fetchTask(t)
_, job4, run4 := getTaskAndJobAndRunByTaskID(t, task4.Id)
assert.Equal(t, actions_model.StatusRunning, run4.Status)
assert.Equal(t, "workflow-dispatch-v1.22", run4.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4))
_, _, run2 = getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, actions_model.StatusCancelled, run2.Status)
@@ -1074,15 +1078,17 @@ jobs:
})
// rerun cancel true scenario
job2 = getLatestAttemptJobByTemplateJobID(t, run2.ID, job2.ID)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run2.ID, job2.ID))
_ = session.MakeRequest(t, req, http.StatusOK)
job4 = getLatestAttemptJobByTemplateJobID(t, run4.ID, job4.ID)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run4.ID, job4.ID))
_ = session.MakeRequest(t, req, http.StatusOK)
task5 := runner.fetchTask(t)
_, _, run4_1 := getTaskAndJobAndRunByTaskID(t, task5.Id)
assert.Equal(t, "workflow-dispatch-v1.22", run4_1.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4_1))
assert.Equal(t, run4.ID, run4_1.ID)
_, _, run2_1 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, actions_model.StatusCancelled, run2_1.Status)
@@ -1093,18 +1099,20 @@ jobs:
// rerun cancel false scenario
job2 = getLatestAttemptJobByTemplateJobID(t, run2.ID, job2.ID)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run2.ID, job2.ID))
_ = session.MakeRequest(t, req, http.StatusOK)
run2_2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run2.ID})
assert.Equal(t, actions_model.StatusWaiting, run2_2.Status)
job3 = getLatestAttemptJobByTemplateJobID(t, run3.ID, job3.ID)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run3.ID, job3.ID))
_ = session.MakeRequest(t, req, http.StatusOK)
task6 := runner.fetchTask(t)
_, _, run3 = getTaskAndJobAndRunByTaskID(t, task6.Id)
assert.Equal(t, "workflow-dispatch-v1.22", run3.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run3))
run2_2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run2_2.ID})
assert.Equal(t, actions_model.StatusCancelled, run2_2.Status) // cancelled by run3
@@ -1147,8 +1155,8 @@ jobs:
// fetch the task triggered by push
task1 := runner.fetchTask(t)
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
assert.Equal(t, "schedule-concurrency", run1.ConcurrencyGroup)
assert.True(t, run1.ConcurrencyCancel)
assert.Equal(t, "schedule-concurrency", getRunConcurrencyGroup(t, run1))
assert.True(t, getRunConcurrencyCancel(t, run1))
assert.Equal(t, string(webhook_module.HookEventPush), run1.TriggerEvent)
assert.Equal(t, actions_model.StatusRunning, run1.Status)
@@ -1165,8 +1173,8 @@ jobs:
assert.Equal(t, actions_model.StatusSuccess, run1.Status)
task2 := runner.fetchTask(t)
_, _, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, "schedule-concurrency", run2.ConcurrencyGroup)
assert.False(t, run2.ConcurrencyCancel)
assert.Equal(t, "schedule-concurrency", getRunConcurrencyGroup(t, run2))
assert.False(t, getRunConcurrencyCancel(t, run2))
assert.Equal(t, string(webhook_module.HookEventSchedule), run2.TriggerEvent)
assert.Equal(t, actions_model.StatusRunning, run2.Status)
@@ -1177,8 +1185,8 @@ jobs:
assert.NoError(t, actions_service.StartScheduleTasks(t.Context()))
runner.fetchNoTask(t) // cannot fetch because task2 is not completed
run3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID, Status: actions_model.StatusBlocked})
assert.Equal(t, "schedule-concurrency", run3.ConcurrencyGroup)
assert.False(t, run3.ConcurrencyCancel)
assert.Equal(t, "schedule-concurrency", getRunConcurrencyGroup(t, run3))
assert.False(t, getRunConcurrencyCancel(t, run3))
assert.Equal(t, string(webhook_module.HookEventSchedule), run3.TriggerEvent)
// trigger the task by push
@@ -1204,8 +1212,8 @@ jobs:
task4 := runner.fetchTask(t)
_, _, run4 := getTaskAndJobAndRunByTaskID(t, task4.Id)
assert.Equal(t, "schedule-concurrency", run4.ConcurrencyGroup)
assert.True(t, run4.ConcurrencyCancel)
assert.Equal(t, "schedule-concurrency", getRunConcurrencyGroup(t, run4))
assert.True(t, getRunConcurrencyCancel(t, run4))
assert.Equal(t, string(webhook_module.HookEventPush), run4.TriggerEvent)
run3 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run3.ID})
assert.Equal(t, actions_model.StatusCancelled, run3.Status)
@@ -1317,7 +1325,7 @@ jobs:
w1j2Task := runner2.fetchTask(t)
_, w1j1Job, w1Run := getTaskAndJobAndRunByTaskID(t, w1j1Task.Id)
assert.Equal(t, "job-group-1", w1j1Job.ConcurrencyGroup)
assert.Equal(t, "workflow-group-1", w1Run.ConcurrencyGroup)
assert.Equal(t, "workflow-group-1", getRunConcurrencyGroup(t, w1Run))
assert.Equal(t, "concurrent-workflow-1.yml", w1Run.WorkflowID)
assert.Equal(t, actions_model.StatusRunning, w1j1Job.Status)
_, w1j2Job, _ := getTaskAndJobAndRunByTaskID(t, w1j2Task.Id)
@@ -1358,7 +1366,7 @@ jobs:
w3j1Task := runner1.fetchTask(t)
_, w3j1Job, w3Run = getTaskAndJobAndRunByTaskID(t, w3j1Task.Id)
assert.Equal(t, "job-group-1", w3j1Job.ConcurrencyGroup)
assert.Equal(t, "workflow-group-2", w3Run.ConcurrencyGroup)
assert.Equal(t, "workflow-group-2", getRunConcurrencyGroup(t, w3Run))
assert.Equal(t, "concurrent-workflow-3.yml", w3Run.WorkflowID)
// exec wf1-job2
@@ -1370,7 +1378,7 @@ jobs:
w2j2Task := runner2.fetchTask(t)
_, w2j2Job, w2Run := getTaskAndJobAndRunByTaskID(t, w2j2Task.Id)
assert.Equal(t, "job-group-2", w2j2Job.ConcurrencyGroup)
assert.Equal(t, "workflow-group-1", w2Run.ConcurrencyGroup)
assert.Equal(t, "workflow-group-1", getRunConcurrencyGroup(t, w2Run))
assert.Equal(t, "concurrent-workflow-2.yml", w2Run.WorkflowID)
assert.Equal(t, actions_model.StatusRunning, w2j2Job.Status)
@@ -1397,7 +1405,7 @@ jobs:
assert.Equal(t, actions_model.StatusCancelled, w2Run.Status)
_, w4j1Job, w4Run := getTaskAndJobAndRunByTaskID(t, w4j1Task.Id)
assert.Equal(t, "job-group-2", w4j1Job.ConcurrencyGroup)
assert.Equal(t, "workflow-group-2", w4Run.ConcurrencyGroup)
assert.Equal(t, "workflow-group-2", getRunConcurrencyGroup(t, w4Run))
assert.Equal(t, "concurrent-workflow-4.yml", w4Run.WorkflowID)
})
}
@@ -1435,8 +1443,8 @@ jobs:
// fetch and check the first task
task1 := runner.fetchTask(t)
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
assert.Equal(t, "cancel-run-group", run1.ConcurrencyGroup)
assert.False(t, run1.ConcurrencyCancel)
assert.Equal(t, "cancel-run-group", getRunConcurrencyGroup(t, run1))
assert.False(t, getRunConcurrencyCancel(t, run1))
assert.Equal(t, actions_model.StatusRunning, run1.Status)
// push another file to trigger the workflow again
@@ -1473,8 +1481,8 @@ jobs:
// fetch and check the second task
task2 := runner.fetchTask(t)
_, _, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, "cancel-run-group", run2.ConcurrencyGroup)
assert.False(t, run2.ConcurrencyCancel)
assert.Equal(t, "cancel-run-group", getRunConcurrencyGroup(t, run2))
assert.False(t, getRunConcurrencyCancel(t, run2))
assert.Equal(t, actions_model.StatusRunning, run2.Status)
})
}
@@ -1533,7 +1541,7 @@ jobs:
// fetch wf1-job1
w1j1Task := runner.fetchTask(t)
_, _, run1 := getTaskAndJobAndRunByTaskID(t, w1j1Task.Id)
assert.Equal(t, "test-group", run1.ConcurrencyGroup)
assert.Equal(t, "test-group", getRunConcurrencyGroup(t, run1))
assert.Equal(t, actions_model.StatusRunning, run1.Status)
// query wf1-job2 from db and check its status
w1j2Job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run1.ID, JobID: "wf1-job2"})
@@ -1571,7 +1579,7 @@ jobs:
// fetch wf2-job1 and check
w2j1Task := runner.fetchTask(t)
_, w2j1Job, run2 := getTaskAndJobAndRunByTaskID(t, w2j1Task.Id)
assert.Equal(t, "test-group", run2.ConcurrencyGroup)
assert.Equal(t, "test-group", getRunConcurrencyGroup(t, run2))
assert.Equal(t, "wf2-job1", w2j1Job.JobID)
assert.Equal(t, actions_model.StatusRunning, run2.Status)
assert.Equal(t, actions_model.StatusRunning, w2j1Job.Status)
@@ -1650,7 +1658,7 @@ jobs:
// cannot fetch run2 because run1 is still running
runner.fetchNoTask(t)
run2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID, WorkflowID: "concurrent-workflow-2.yml"})
assert.Equal(t, "test-group", run2.ConcurrencyGroup)
assert.Equal(t, "test-group", getRunConcurrencyGroup(t, run2))
assert.Equal(t, actions_model.StatusBlocked, run2.Status)
// exec run1
@@ -1677,3 +1685,164 @@ jobs:
assert.Equal(t, actions_model.StatusCancelled, run2.Status)
})
}
// TestCancelLegacyRunBlockedByConcurrency simulates a workflow run created before migration v331:
// it has no ActionRunAttempt record (LatestAttemptID == 0) and was blocked by workflow-level concurrency.
// Migration v331 drops action_run.concurrency_group / concurrency_cancel, so the run ends up "stuck" with no way for the job emitter to naturally unblock it.
// The test verifies the user can still:
// 1. view the stuck legacy run correctly (web view renders)
// 2. cancel it from the UI, which transitions the run and all its jobs to Cancelled
// 3. rerun the (now cancelled) legacy run successfully
func TestCancelLegacyRunBlockedByConcurrency(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiRepo := createActionsTestRepo(t, token, "actions-legacy-concurrency", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(httpContext)(t)
runner := newMockRunner()
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
// Manually insert a "legacy" run blocked by workflow-level concurrency: no ActionRunAttempt, LatestAttemptID=0.
// Its workflow-level concurrency info would have been stored on action_run.concurrency_group pre-v331;
// after the migration that column is gone, so we simply mark the run (and its jobs) as Blocked.
legacyWfContent := `name: legacy-blocked
on:
workflow_dispatch:
concurrency:
group: test-group
jobs:
legacy-job1:
runs-on: ubuntu-latest
steps:
- run: echo 'legacy-job1'
legacy-job2:
runs-on: ubuntu-latest
steps:
- run: echo 'legacy-job2'
`
payloads := mustParseSingleWorkflowPayloads(t, legacyWfContent)
now := timeutil.TimeStamp(time.Now().Unix())
legacyRun := &actions_model.ActionRun{
Title: "legacy blocked run",
RepoID: repo.ID,
OwnerID: repo.OwnerID,
WorkflowID: "legacy-blocked.yml",
Index: 1,
TriggerUserID: user2.ID,
Ref: "refs/heads/" + repo.DefaultBranch,
CommitSHA: "0000000000000000000000000000000000000000",
Event: "workflow_dispatch",
TriggerEvent: "workflow_dispatch",
EventPayload: "{}",
Status: actions_model.StatusBlocked,
Created: now - 1,
Updated: now - 1,
}
require.NoError(t, db.Insert(t.Context(), legacyRun))
legacyJob1 := &actions_model.ActionRunJob{
RunID: legacyRun.ID,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
Name: payloads["legacy-job1"].name,
Attempt: 1,
WorkflowPayload: payloads["legacy-job1"].payload,
JobID: "legacy-job1",
Needs: payloads["legacy-job1"].needs,
RunsOn: payloads["legacy-job1"].runsOn,
Status: actions_model.StatusBlocked,
RunAttemptID: 0,
AttemptJobID: 0,
}
legacyJob2 := &actions_model.ActionRunJob{
RunID: legacyRun.ID,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
Name: payloads["legacy-job2"].name,
Attempt: 1,
WorkflowPayload: payloads["legacy-job2"].payload,
JobID: "legacy-job2",
Needs: payloads["legacy-job2"].needs,
RunsOn: payloads["legacy-job2"].runsOn,
Status: actions_model.StatusBlocked,
RunAttemptID: 0,
AttemptJobID: 0,
}
require.NoError(t, db.Insert(t.Context(), legacyJob1, legacyJob2))
// 1) User visits the legacy run's web view - it renders without error.
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, legacyRun.ID))
resp := session.MakeRequest(t, req, http.StatusOK)
viewResp := DecodeJSON(t, resp, &actions_web.ViewResponse{})
// Legacy run has no attempt record, so RunAttempt is 0 and Attempts is empty.
assert.EqualValues(t, 0, viewResp.State.Run.RunAttempt)
assert.Empty(t, viewResp.State.Run.Attempts)
assert.Equal(t, actions_model.StatusBlocked.String(), viewResp.State.Run.Status)
assert.False(t, viewResp.State.Run.Done)
// Legacy workflow-level concurrency info is gone (columns dropped by v331), so GetEffectiveConcurrency returns "": the run cannot self-unblock via job_emitter.
afterLoadRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
assert.Empty(t, getRunConcurrencyGroup(t, afterLoadRun))
// Still Blocked, not Done, but user should be able to cancel.
assert.True(t, viewResp.State.Run.CanCancel)
assert.False(t, viewResp.State.Run.CanRerun)
if assert.Len(t, viewResp.State.Run.Jobs, 2) {
assert.Equal(t, actions_model.StatusBlocked.String(), viewResp.State.Run.Jobs[0].Status)
assert.Equal(t, actions_model.StatusBlocked.String(), viewResp.State.Run.Jobs[1].Status)
}
// 2) User cancels the legacy run to clean it up.
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/cancel", user2.Name, repo.Name, legacyRun.ID))
session.MakeRequest(t, req, http.StatusOK)
// Run and all its jobs transition to Cancelled.
cancelledRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
assert.Equal(t, actions_model.StatusCancelled, cancelledRun.Status)
cancelledJob1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: legacyJob1.ID})
assert.Equal(t, actions_model.StatusCancelled, cancelledJob1.Status)
cancelledJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: legacyJob2.ID})
assert.Equal(t, actions_model.StatusCancelled, cancelledJob2.Status)
// 3) User reruns the now-cancelled legacy run.
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, legacyRun.ID))
session.MakeRequest(t, req, http.StatusOK)
rerunRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
assert.Positive(t, rerunRun.LatestAttemptID)
assert.EqualValues(t, 2, getRunLatestAttemptNum(t, legacyRun.ID))
// Both jobs run successfully on the registered runner.
for range 2 {
task := runner.fetchTask(t)
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
}
finalRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
assert.Equal(t, actions_model.StatusSuccess, finalRun.Status)
})
}
func getRunConcurrencyGroup(t *testing.T, run *actions_model.ActionRun) string {
cg, _, err := run.GetEffectiveConcurrency(t.Context())
assert.NoError(t, err)
return cg
}
func getRunConcurrencyCancel(t *testing.T, run *actions_model.ActionRun) bool {
_, cc, err := run.GetEffectiveConcurrency(t.Context())
assert.NoError(t, err)
return cc
}
func getLatestAttemptJobByTemplateJobID(t *testing.T, runID, templateJobID int64) *actions_model.ActionRunJob {
t.Helper()
templateJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: templateJobID, RunID: runID})
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
job, err := actions_model.GetRunJobByAttemptJobID(t.Context(), run.ID, run.LatestAttemptID, templateJob.AttemptJobID)
assert.NoError(t, err)
return job
}
+1 -1
View File
@@ -136,7 +136,7 @@ jobs:
runID = run.ID
}
jobs, err := actions_model.GetRunJobsByRunID(t.Context(), runID)
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(t.Context(), apiRepo.ID, runID)
assert.NoError(t, err)
for i := 0; i < len(testCase.outcomes); i++ {
+160
View File
@@ -15,6 +15,7 @@ import (
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
@@ -22,6 +23,7 @@ import (
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/timeutil"
actions_service "code.gitea.io/gitea/services/actions"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
@@ -759,3 +761,161 @@ func getTaskJobNameByTaskID(t *testing.T, authToken, ownerName, repoName string,
}
return ""
}
// TestLegacyRunsInCronTasks verifies that the background cron tasks correctly handle runs/jobs
// created before migration v331 (legacy data with LatestAttemptID=0 and jobs with RunAttemptID=0).
func TestLegacyRunsInCronTasks(t *testing.T) {
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiRepo := createActionsTestRepo(t, token, "actions-legacy-cron", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(httpContext)(t)
// Far-past timestamp so the queries match regardless of the configured timeouts.
oldTS := timeutil.TimeStamp(time.Now().Add(-30 * 24 * time.Hour).Unix())
// insertLegacyRunJob inserts a run + job without an ActionRunAttempt record, simulating data created before migration v331 (LatestAttemptID=0, job.RunAttemptID=0, job.AttemptJobID=0).
insertLegacyRunJob := func(t *testing.T, index int64, runStatus, jobStatus actions_model.Status) (*actions_model.ActionRun, *actions_model.ActionRunJob) {
t.Helper()
run := &actions_model.ActionRun{
Title: fmt.Sprintf("legacy run %d", index),
RepoID: repo.ID,
OwnerID: repo.OwnerID,
WorkflowID: fmt.Sprintf("legacy-%d.yml", index),
Index: index,
TriggerUserID: user2.ID,
Ref: "refs/heads/" + repo.DefaultBranch,
CommitSHA: "0000000000000000000000000000000000000000",
Event: "workflow_dispatch",
TriggerEvent: "workflow_dispatch",
EventPayload: "{}",
Status: runStatus,
}
require.NoError(t, db.Insert(t.Context(), run))
job := &actions_model.ActionRunJob{
RunID: run.ID,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: run.CommitSHA,
Name: "legacy-job",
Attempt: 1,
JobID: "legacy-job",
RunsOn: []string{"ubuntu-latest"},
Status: jobStatus,
RunAttemptID: 0,
AttemptJobID: 0,
}
require.NoError(t, db.Insert(t.Context(), job))
// backfill timestamps so the cron task queries can match them.
_, err := db.GetEngine(t.Context()).Exec("UPDATE action_run SET created=?, updated=? WHERE id=?", int64(oldTS), int64(oldTS), run.ID)
require.NoError(t, err)
_, err = db.GetEngine(t.Context()).Exec("UPDATE action_run_job SET created=?, updated=? WHERE id=?", int64(oldTS), int64(oldTS), job.ID)
require.NoError(t, err)
run.Created, run.Updated = oldTS, oldTS
job.Created, job.Updated = oldTS, oldTS
return run, job
}
t.Run("StopZombieTasks", func(t *testing.T) {
run, job := insertLegacyRunJob(t, 10, actions_model.StatusRunning, actions_model.StatusRunning)
task := &actions_model.ActionTask{
JobID: job.ID,
Attempt: 1,
Status: actions_model.StatusRunning,
Started: oldTS,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: run.CommitSHA,
}
task.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), task))
_, err := db.GetEngine(t.Context()).Exec("UPDATE action_task SET updated=? WHERE id=?", int64(oldTS), task.ID)
require.NoError(t, err)
job.TaskID = task.ID
_, err = db.GetEngine(t.Context()).ID(job.ID).Cols("task_id").Update(job)
require.NoError(t, err)
require.NoError(t, actions_service.StopZombieTasks(t.Context()))
gotTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.ID})
assert.Equal(t, actions_model.StatusFailure, gotTask.Status)
gotJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
assert.Equal(t, actions_model.StatusFailure, gotJob.Status)
gotRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.Equal(t, actions_model.StatusFailure, gotRun.Status)
})
t.Run("StopEndlessTasks", func(t *testing.T) {
run, job := insertLegacyRunJob(t, 20, actions_model.StatusRunning, actions_model.StatusRunning)
task := &actions_model.ActionTask{
JobID: job.ID,
Attempt: 1,
Status: actions_model.StatusRunning,
Started: oldTS,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: run.CommitSHA,
}
task.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), task))
job.TaskID = task.ID
_, err := db.GetEngine(t.Context()).ID(job.ID).Cols("task_id").Update(job)
require.NoError(t, err)
require.NoError(t, actions_service.StopEndlessTasks(t.Context()))
gotTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.ID})
assert.Equal(t, actions_model.StatusFailure, gotTask.Status)
gotJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
assert.Equal(t, actions_model.StatusFailure, gotJob.Status)
gotRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.Equal(t, actions_model.StatusFailure, gotRun.Status)
})
t.Run("CancelAbandonedJobs", func(t *testing.T) {
run, job := insertLegacyRunJob(t, 30, actions_model.StatusWaiting, actions_model.StatusWaiting)
require.NoError(t, actions_service.CancelAbandonedJobs(t.Context()))
gotJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
assert.Equal(t, actions_model.StatusCancelled, gotJob.Status)
gotRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.Equal(t, actions_model.StatusCancelled, gotRun.Status)
})
t.Run("Cleanup", func(t *testing.T) {
run, _ := insertLegacyRunJob(t, 40, actions_model.StatusSuccess, actions_model.StatusSuccess)
expiredArtifact := &actions_model.ActionArtifact{
RunID: run.ID,
RunAttemptID: 0, // legacy artifact
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: run.CommitSHA,
StoragePath: fmt.Sprintf("artifacts/legacy-expired-%d.zip", run.ID),
FileSize: 1,
FileCompressedSize: 1,
ContentEncodingOrType: actions_model.ContentTypeZip,
ArtifactPath: "legacy-expired.zip",
ArtifactName: "legacy-expired",
Status: actions_model.ArtifactStatusUploadConfirmed,
ExpiredUnix: oldTS,
}
require.NoError(t, db.Insert(t.Context(), expiredArtifact))
require.NoError(t, actions_service.Cleanup(t.Context()))
gotArtifact := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionArtifact{ID: expiredArtifact.ID})
assert.Equal(t, actions_model.ArtifactStatusExpired, gotArtifact.Status)
})
})
}
+91
View File
@@ -11,6 +11,7 @@ import (
"testing"
"time"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
@@ -214,5 +215,95 @@ jobs:
resetFunc()
})
}
t.Run("DownloadRerunTaskLogs", func(t *testing.T) {
treePath := ".gitea/workflows/download-rerun-logs.yml"
fileContent := `name: download-rerun-logs
on:
push:
paths:
- '.gitea/workflows/download-rerun-logs.yml'
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo job1
job2:
runs-on: ubuntu-latest
needs: [job1]
steps:
- run: echo job2
`
// create the workflow file
opts := getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "create "+treePath, fileContent)
createWorkflowFile(t, token, user2.Name, repo.Name, treePath, opts)
// first run
job1Task1 := runner.fetchTask(t)
_, job1, _ := getTaskAndJobAndRunByTaskID(t, job1Task1.Id)
runner.execTask(t, job1Task1, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
logRows: []*runnerv1.LogRow{
{
Time: timestamppb.New(now.Add(1 * time.Second)),
Content: "job1 first run",
},
},
})
job2Task1 := runner.fetchTask(t)
_, job2, run := getTaskAndJobAndRunByTaskID(t, job2Task1.Id)
runner.execTask(t, job2Task1, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
logRows: []*runnerv1.LogRow{
{
Time: timestamppb.New(now.Add(1 * time.Second)),
Content: "job2 first run",
},
},
})
// check job1 log
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job1.ID)).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "job1 first run")
// check job2 log
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job2.ID)).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "job2 first run")
// only rerun job2
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, run.ID, job2.ID))
session.MakeRequest(t, req, http.StatusOK)
job2TaskRerun := runner.fetchTask(t)
runner.execTask(t, job2TaskRerun, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
logRows: []*runnerv1.LogRow{
{
Time: timestamppb.New(now.Add(1 * time.Second)),
Content: "job2 rerun",
},
},
})
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
job1Rerun := getLatestAttemptJobByTemplateJobID(t, run.ID, job1.ID)
assert.Equal(t, run.LatestAttemptID, job1Rerun.RunAttemptID)
job2Rerun := getLatestAttemptJobByTemplateJobID(t, run.ID, job2.ID)
assert.Equal(t, run.LatestAttemptID, job2Rerun.RunAttemptID)
// check job1 rerun log
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job1Rerun.ID)).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "job1 first run") // should return the log of first run because job1 didn't rerun
// check job2 rerun log
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job2Rerun.ID)).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "job2 rerun")
})
})
}
+425 -1
View File
@@ -7,18 +7,33 @@ import (
"fmt"
"net/http"
"net/url"
"strings"
"testing"
"time"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/actions/jobparser"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/timeutil"
actions_web "code.gitea.io/gitea/routers/web/repo/actions"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestActionsRerun(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
userAdmin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
sessionAdmin := loginUser(t, userAdmin.Name)
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
@@ -54,6 +69,7 @@ jobs:
// fetch and exec job1
job1Task := runner.fetchTask(t)
assert.Equal(t, "1", job1Task.Context.GetFields()["run_attempt"].GetStringValue())
_, job1, run := getTaskAndJobAndRunByTaskID(t, job1Task.Id)
runner.execTask(t, job1Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
@@ -67,45 +83,453 @@ jobs:
runner.execTask(t, job2Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
assert.EqualValues(t, 1, getRunLatestAttemptNum(t, run.ID))
// RERUN-1: rerun the run
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, run.ID))
session.MakeRequest(t, req, http.StatusOK)
sessionAdmin.MakeRequest(t, req, http.StatusOK) // triggered by admin user
// fetch and exec job1
job1TaskR1 := runner.fetchTask(t)
assert.Equal(t, "2", job1TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
_, job1R1, _ := getTaskAndJobAndRunByTaskID(t, job1TaskR1.Id)
assert.Equal(t, job1.AttemptJobID, job1R1.AttemptJobID)
runner.execTask(t, job1TaskR1, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
// fetch and exec job2
job2TaskR1 := runner.fetchTask(t)
assert.Equal(t, "2", job2TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
_, job2R1, _ := getTaskAndJobAndRunByTaskID(t, job2TaskR1.Id)
assert.Equal(t, job2.AttemptJobID, job2R1.AttemptJobID)
runner.execTask(t, job2TaskR1, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
assert.EqualValues(t, 2, getRunLatestAttemptNum(t, run.ID))
// RERUN-2: rerun job1
job1 = getLatestAttemptJobByTemplateJobID(t, run.ID, job1.ID)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, run.ID, job1.ID))
session.MakeRequest(t, req, http.StatusOK)
// job2 needs job1, so rerunning job1 will also rerun job2
// fetch and exec job1
job1TaskR2 := runner.fetchTask(t)
assert.Equal(t, "3", job1TaskR2.Context.GetFields()["run_attempt"].GetStringValue())
runner.execTask(t, job1TaskR2, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
// fetch and exec job2
job2TaskR2 := runner.fetchTask(t)
assert.Equal(t, "3", job2TaskR2.Context.GetFields()["run_attempt"].GetStringValue())
runner.execTask(t, job2TaskR2, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
assert.EqualValues(t, 3, getRunLatestAttemptNum(t, run.ID))
// RERUN-3: rerun job2
job2 = getLatestAttemptJobByTemplateJobID(t, run.ID, job2.ID)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, run.ID, job2.ID))
session.MakeRequest(t, req, http.StatusOK)
// only job2 will rerun
// fetch and exec job2
job2TaskR3 := runner.fetchTask(t)
assert.Equal(t, "4", job2TaskR3.Context.GetFields()["run_attempt"].GetStringValue())
runner.execTask(t, job2TaskR3, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
runner.fetchNoTask(t)
assert.EqualValues(t, 4, getRunLatestAttemptNum(t, run.ID))
runLatestAttempt := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
job2LatestAttempt := getLatestAttemptJobByTemplateJobID(t, run.ID, job2.ID)
assert.Equal(t, runLatestAttempt.LatestAttemptID, job2LatestAttempt.RunAttemptID)
t.Run("AttemptAPI", func(t *testing.T) {
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/attempts/2", user2.Name, repo.Name, run.ID)).
AddTokenAuth(token)
attemptResp := MakeRequest(t, req, http.StatusOK)
apiAttempt := DecodeJSON(t, attemptResp, &api.ActionWorkflowRun{})
assert.Equal(t, run.ID, apiAttempt.ID)
assert.EqualValues(t, 2, apiAttempt.RunAttempt)
assert.Equal(t, "completed", apiAttempt.Status)
assert.Equal(t, "success", apiAttempt.Conclusion)
assert.NotNil(t, apiAttempt.PreviousAttemptURL)
assert.True(t, strings.HasSuffix(*apiAttempt.PreviousAttemptURL, fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/attempts/1", user2.Name, repo.Name, run.ID)))
assert.Equal(t, user2.Name, apiAttempt.Actor.UserName)
assert.Equal(t, userAdmin.Name, apiAttempt.TriggerActor.UserName)
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/attempts/2/jobs", user2.Name, repo.Name, run.ID)).
AddTokenAuth(token)
attemptJobsResp := MakeRequest(t, req, http.StatusOK)
apiAttemptJobs := DecodeJSON(t, attemptJobsResp, &api.ActionWorkflowJobsResponse{})
assert.Len(t, apiAttemptJobs.Entries, 2)
assert.ElementsMatch(t, []int64{job1R1.ID, job2R1.ID}, []int64{apiAttemptJobs.Entries[0].ID, apiAttemptJobs.Entries[1].ID})
})
t.Run("MaxRerunAttempts", func(t *testing.T) {
// The run has 4 attempts after the previous reruns. Lower the cap to 4 to hit the limit.
defer test.MockVariableValue(&setting.Actions.MaxRerunAttempts, int64(4))()
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, run.ID))
resp := session.MakeRequest(t, req, http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "workflow run has reached the maximum")
assert.EqualValues(t, 4, getRunLatestAttemptNum(t, run.ID))
// Raising the cap lets rerun proceed again.
defer test.MockVariableValue(&setting.Actions.MaxRerunAttempts, int64(5))()
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, run.ID))
session.MakeRequest(t, req, http.StatusOK)
// fetch and exec job1
job1TaskR4 := runner.fetchTask(t)
assert.Equal(t, "5", job1TaskR4.Context.GetFields()["run_attempt"].GetStringValue())
runner.execTask(t, job1TaskR4, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
job2TaskR4 := runner.fetchTask(t)
assert.Equal(t, "5", job2TaskR4.Context.GetFields()["run_attempt"].GetStringValue())
runner.execTask(t, job2TaskR4, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
assert.EqualValues(t, 5, getRunLatestAttemptNum(t, run.ID))
})
})
}
func TestActionsRerunLegacyNoAttemptRun(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiRepo := createActionsTestRepo(t, token, "actions-rerun-legacy", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(httpContext)(t)
runner := newMockRunner()
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
wfTreePath := ".gitea/workflows/actions-rerun-legacy.yml"
wfFileContent := `name: actions-rerun-legacy
on:
workflow_dispatch:
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo 'job1'
job2:
runs-on: ubuntu-latest
needs: [job1]
steps:
- run: echo 'job2'
`
opts := getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "create "+wfTreePath, wfFileContent)
fileResp := createWorkflowFile(t, token, user2.Name, repo.Name, wfTreePath, opts)
require.NotNil(t, fileResp)
// Start preparing legacy data
payloads := mustParseSingleWorkflowPayloads(t, wfFileContent)
now := timeutil.TimeStamp(time.Now().Unix())
started := now - 20
stopped := now - 10
legacyRun := &actions_model.ActionRun{
Title: "legacy rerun test",
RepoID: repo.ID,
OwnerID: repo.OwnerID,
WorkflowID: "actions-rerun-legacy.yml",
Index: 1,
TriggerUserID: user2.ID,
Ref: "refs/heads/" + repo.DefaultBranch,
CommitSHA: fileResp.Commit.SHA,
Event: "workflow_dispatch",
TriggerEvent: "workflow_dispatch",
EventPayload: "{}",
Status: actions_model.StatusSuccess,
Started: started,
Stopped: stopped,
Created: started - 5,
Updated: stopped,
}
require.NoError(t, db.Insert(t.Context(), legacyRun))
// xorm does not update "created"-tagged fields via ORM methods; use raw SQL to backfill historical timestamps.
_, err := db.GetEngine(t.Context()).Exec("UPDATE action_run SET created=?, updated=? WHERE id=?", int64(started-5), int64(stopped), legacyRun.ID)
require.NoError(t, err)
legacyRun.Created = started - 5
legacyRun.Updated = stopped
legacyJob1 := &actions_model.ActionRunJob{
RunID: legacyRun.ID,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
Name: payloads["job1"].name,
Attempt: 1,
WorkflowPayload: payloads["job1"].payload,
JobID: "job1",
Needs: payloads["job1"].needs,
RunsOn: payloads["job1"].runsOn,
Status: actions_model.StatusSuccess,
RunAttemptID: 0,
AttemptJobID: 0,
Started: started,
Stopped: stopped,
IsForkPullRequest: false,
}
legacyJob2 := &actions_model.ActionRunJob{
RunID: legacyRun.ID,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
Name: payloads["job2"].name,
Attempt: 1,
WorkflowPayload: payloads["job2"].payload,
JobID: "job2",
Needs: payloads["job2"].needs,
RunsOn: payloads["job2"].runsOn,
Status: actions_model.StatusSuccess,
RunAttemptID: 0,
AttemptJobID: 0,
Started: started,
Stopped: stopped,
IsForkPullRequest: false,
}
require.NoError(t, db.Insert(t.Context(), legacyJob1, legacyJob2))
legacyTask1 := &actions_model.ActionTask{
JobID: legacyJob1.ID,
Attempt: 1,
Status: actions_model.StatusSuccess,
Started: started,
Stopped: stopped,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
IsForkPullRequest: false,
}
legacyTask1.GenerateAndFillToken()
legacyTask2 := &actions_model.ActionTask{
JobID: legacyJob2.ID,
Attempt: 1,
Status: actions_model.StatusSuccess,
Started: started,
Stopped: stopped,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
IsForkPullRequest: false,
}
legacyTask2.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), legacyTask1, legacyTask2))
legacyJob1.TaskID = legacyTask1.ID
legacyJob2.TaskID = legacyTask2.ID
_, err = db.GetEngine(t.Context()).ID(legacyJob1.ID).Cols("task_id").Update(legacyJob1)
require.NoError(t, err)
_, err = db.GetEngine(t.Context()).ID(legacyJob2.ID).Cols("task_id").Update(legacyJob2)
require.NoError(t, err)
legacyArtifact := &actions_model.ActionArtifact{
RunID: legacyRun.ID,
RunAttemptID: 0,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
StoragePath: "artifacts/legacy-artifact.zip",
FileSize: 123,
FileCompressedSize: 123,
ContentEncodingOrType: actions_model.ContentTypeZip,
ArtifactPath: "legacy-artifact.zip",
ArtifactName: "legacy-artifact",
Status: actions_model.ArtifactStatusUploadConfirmed,
ExpiredUnix: now + timeutil.Day,
}
require.NoError(t, db.Insert(t.Context(), legacyArtifact))
// Done preparing legacy data
// assert the web view for the legacy run before rerun
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, legacyRun.ID))
legacyResp := session.MakeRequest(t, req, http.StatusOK)
legacyView := DecodeJSON(t, legacyResp, &actions_web.ViewResponse{})
// legacy run has no attempt records, so RunAttempt is 0 and Attempts list is empty
assert.EqualValues(t, 0, legacyView.State.Run.RunAttempt)
assert.Empty(t, legacyView.State.Run.Attempts)
assert.Equal(t, "success", legacyView.State.Run.Status)
assert.True(t, legacyView.State.Run.Done)
// isLatestAttempt=true, done=true: can rerun but not cancel
assert.False(t, legacyView.State.Run.CanCancel)
assert.False(t, legacyView.State.Run.CanApprove)
assert.True(t, legacyView.State.Run.CanRerun)
assert.False(t, legacyView.State.Run.CanRerunFailed) // all jobs succeeded
assert.True(t, legacyView.State.Run.CanDeleteArtifact)
if assert.Len(t, legacyView.State.Run.Jobs, 2) {
assert.Equal(t, legacyJob1.ID, legacyView.State.Run.Jobs[0].ID)
assert.Equal(t, legacyJob2.ID, legacyView.State.Run.Jobs[1].ID)
}
if assert.Len(t, legacyView.Artifacts, 1) {
assert.Equal(t, legacyArtifact.ArtifactName, legacyView.Artifacts[0].Name)
assert.Equal(t, "completed", legacyView.Artifacts[0].Status)
}
// rerun the legacy run
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, legacyRun.ID))
session.MakeRequest(t, req, http.StatusOK)
runAfterRerun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
assert.EqualValues(t, 2, getRunLatestAttemptNum(t, legacyRun.ID))
jobsAfterRerun, err := actions_model.GetRunJobsByRunAndAttemptID(t.Context(), legacyRun.ID, runAfterRerun.LatestAttemptID)
require.NoError(t, err)
require.Len(t, jobsAfterRerun, 2)
rerunJobsByJobID := map[string]*actions_model.ActionRunJob{}
for _, job := range jobsAfterRerun {
rerunJobsByJobID[job.JobID] = job
}
require.Contains(t, rerunJobsByJobID, "job1")
require.Contains(t, rerunJobsByJobID, "job2")
assert.Equal(t, actions_model.StatusWaiting, rerunJobsByJobID["job1"].Status)
assert.Equal(t, actions_model.StatusBlocked, rerunJobsByJobID["job2"].Status)
// fetch job1 rerun task
job1TaskR1 := runner.fetchTask(t)
assert.Equal(t, "2", job1TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
rerunJob1Task, rerunJob1, rerunRun := getTaskAndJobAndRunByTaskID(t, job1TaskR1.Id)
assert.Equal(t, legacyRun.ID, rerunRun.ID)
assert.Equal(t, rerunJob1.RunAttemptID, rerunRun.LatestAttemptID)
runner.execTask(t, job1TaskR1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
// fetch job2 rerun task
job2TaskR1 := runner.fetchTask(t)
assert.Equal(t, "2", job2TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
rerunJob2Task, rerunJob2, _ := getTaskAndJobAndRunByTaskID(t, job2TaskR1.Id)
runner.execTask(t, job2TaskR1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
runner.fetchNoTask(t)
// query the 2 attempts
runAfterRerun = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
attempt1, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(t.Context(), legacyRun.ID, 1)
require.NoError(t, err)
assert.Equal(t, legacyRun.Created, attempt1.Created)
assert.Equal(t, legacyRun.Started, attempt1.Started)
assert.Equal(t, legacyRun.Stopped, attempt1.Stopped)
attempt2, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(t.Context(), legacyRun.ID, 2)
require.NoError(t, err)
assert.Equal(t, attempt2.ID, runAfterRerun.LatestAttemptID)
assert.Equal(t, runAfterRerun.Created, attempt1.Created)
assert.Equal(t, runAfterRerun.Started, attempt2.Started)
assert.Equal(t, runAfterRerun.Stopped, attempt2.Stopped)
// assert legacy jobs
legacyJob1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: legacyJob1.ID})
legacyJob2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: legacyJob2.ID})
assert.Equal(t, attempt1.ID, legacyJob1.RunAttemptID)
assert.Equal(t, attempt1.ID, legacyJob2.RunAttemptID)
assert.EqualValues(t, 1, legacyJob1.Attempt)
assert.EqualValues(t, 1, legacyJob2.Attempt)
assert.EqualValues(t, 1, legacyJob1.AttemptJobID)
assert.EqualValues(t, 2, legacyJob2.AttemptJobID)
legacyTask1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: legacyTask1.ID})
legacyTask2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: legacyTask2.ID})
assert.EqualValues(t, 1, legacyTask1.Attempt)
assert.EqualValues(t, 1, legacyTask2.Attempt)
// assert legacy artifacts
legacyArtifact = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionArtifact{ID: legacyArtifact.ID})
assert.Equal(t, attempt1.ID, legacyArtifact.RunAttemptID)
// assert jobs of the latest rerun
rerunJob1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: rerunJob1.ID})
rerunJob2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: rerunJob2.ID})
assert.Equal(t, attempt2.ID, rerunJob1.RunAttemptID)
assert.Equal(t, attempt2.ID, rerunJob2.RunAttemptID)
assert.Equal(t, legacyJob1.AttemptJobID, rerunJob1.AttemptJobID)
assert.Equal(t, legacyJob2.AttemptJobID, rerunJob2.AttemptJobID)
assert.EqualValues(t, 2, rerunJob1Task.Attempt)
assert.EqualValues(t, 2, rerunJob2Task.Attempt)
// assert the web view for the original attempt
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/attempts/1", user2.Name, repo.Name, legacyRun.ID))
attempt1Resp := session.MakeRequest(t, req, http.StatusOK)
attempt1View := DecodeJSON(t, attempt1Resp, &actions_web.ViewResponse{})
assert.EqualValues(t, 1, attempt1View.State.Run.RunAttempt)
if assert.Len(t, attempt1View.State.Run.Attempts, 2) {
// attempts ordered by attempt DESC: index 0 = attempt #2 (latest), index 1 = attempt #1 (current)
assert.False(t, attempt1View.State.Run.Attempts[0].Current)
assert.True(t, attempt1View.State.Run.Attempts[0].Latest)
assert.True(t, attempt1View.State.Run.Attempts[1].Current)
assert.False(t, attempt1View.State.Run.Attempts[1].Latest)
}
// isLatestAttempt=false: all write operations disabled
assert.False(t, attempt1View.State.Run.CanCancel)
assert.False(t, attempt1View.State.Run.CanApprove)
assert.False(t, attempt1View.State.Run.CanRerun)
assert.False(t, attempt1View.State.Run.CanRerunFailed)
assert.True(t, attempt1View.State.Run.CanDeleteArtifact)
assert.Equal(t, legacyJob1.ID, attempt1View.State.Run.Jobs[0].ID)
assert.Equal(t, legacyJob2.ID, attempt1View.State.Run.Jobs[1].ID)
if assert.Len(t, attempt1View.Artifacts, 1) {
assert.Equal(t, attempt1View.Artifacts[0].Name, legacyArtifact.ArtifactName)
}
// assert the web view for the latest attempt
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, legacyRun.ID))
attempt2Resp := session.MakeRequest(t, req, http.StatusOK)
attempt2View := DecodeJSON(t, attempt2Resp, &actions_web.ViewResponse{})
assert.EqualValues(t, 2, attempt2View.State.Run.RunAttempt)
if assert.Len(t, attempt2View.State.Run.Attempts, 2) {
// attempts ordered by attempt DESC: index 0 = attempt #2 (latest, current), index 1 = attempt #1
assert.True(t, attempt2View.State.Run.Attempts[0].Current)
assert.True(t, attempt2View.State.Run.Attempts[0].Latest)
assert.False(t, attempt2View.State.Run.Attempts[1].Current)
assert.False(t, attempt2View.State.Run.Attempts[1].Latest)
}
// isLatestAttempt=true, done=true: can rerun but not cancel
assert.False(t, attempt2View.State.Run.CanCancel)
assert.False(t, attempt2View.State.Run.CanApprove)
assert.True(t, attempt2View.State.Run.CanRerun)
assert.False(t, attempt2View.State.Run.CanRerunFailed) // all jobs succeeded
assert.True(t, attempt2View.State.Run.CanDeleteArtifact)
assert.Equal(t, rerunJob1.ID, attempt2View.State.Run.Jobs[0].ID)
assert.Equal(t, rerunJob2.ID, attempt2View.State.Run.Jobs[1].ID)
assert.Empty(t, attempt2View.Artifacts)
})
}
type workflowJobPayload struct {
name string
payload []byte
needs []string
runsOn []string
}
func mustParseSingleWorkflowPayloads(t *testing.T, workflowContent string) map[string]workflowJobPayload {
t.Helper()
workflows, err := jobparser.Parse([]byte(workflowContent))
require.NoError(t, err)
payloads := make(map[string]workflowJobPayload, len(workflows))
for _, workflow := range workflows {
id, job := workflow.Job()
needs := job.Needs()
require.NoError(t, workflow.SetJob(id, job.EraseNeeds()))
payload, err := workflow.Marshal()
require.NoError(t, err)
payloads[id] = workflowJobPayload{
name: job.Name,
payload: payload,
needs: needs,
runsOn: job.RunsOn(),
}
}
return payloads
}
func getRunLatestAttemptNum(t *testing.T, runID int64) int64 {
t.Helper()
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
attempt := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{ID: run.LatestAttemptID})
return attempt.Attempt
}
+1 -1
View File
@@ -207,7 +207,7 @@ func testActionsRouteForLegacyIndexBasedURL(t *testing.T) {
// Best-effort compatibility prefers the run ID when the same number also exists as a legacy run index.
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, collisionRun.Index))
resp = user2Session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), fmt.Sprintf(`data-run-id="%d"`, normalRun.ID)) // because collisionRun.Index == normalRun.ID
assert.Contains(t, resp.Body.String(), fmt.Sprintf(`data-actions-view-url="/%s/%s/actions/runs/%d"`, user2.Name, repo.Name, normalRun.ID))
// by_index=1 should force the summary page to use the legacy run index interpretation.
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d?by_index=1", user2.Name, repo.Name, collisionRun.Index))
@@ -4,13 +4,27 @@
package integration
import (
"archive/zip"
"bytes"
"crypto/md5"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/tests"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type uploadArtifactResponse struct {
@@ -393,3 +407,156 @@ func TestActionsArtifactOverwrite(t *testing.T) {
assert.Equal(t, resp.Body.String(), body)
}
}
func TestActionRunAttemptArtifact(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiRepo := createActionsTestRepo(t, token, "actions-run-attempt-artifact", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(httpContext)(t)
runner := newMockRunner()
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
wfTreePath := ".gitea/workflows/run-attempt-artifact.yml"
wfFileContent := `name: run-attempt-artifact
on:
workflow_dispatch:
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo 'job1'
`
opts := getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "create "+wfTreePath, wfFileContent)
createWorkflowFile(t, token, user2.Name, repo.Name, wfTreePath, opts)
urlStr := fmt.Sprintf("/%s/%s/actions/run?workflow=%s", user2.Name, repo.Name, "run-attempt-artifact.yml")
req := NewRequestWithValues(t, "POST", urlStr, map[string]string{
"ref": "refs/heads/main",
})
session.MakeRequest(t, req, http.StatusSeeOther)
t.Run("testActionRunAttemptArtifactV3", func(t *testing.T) {
testActionRunAttemptArtifactV3(t, repo, session, runner)
})
t.Run("testActionRunAttemptArtifactV4", func(t *testing.T) {
testActionRunAttemptArtifactV4(t, repo, session, runner)
})
})
}
func testActionRunAttemptArtifactV3(t *testing.T, repo *repo_model.Repository, session *TestSession, runner *mockRunner) {
// first run
task1 := runner.fetchTask(t)
_, job1, run := getTaskAndJobAndRunByTaskID(t, task1.Id)
require.NotZero(t, job1.RunAttemptID)
taskToken1 := task1.Context.GetFields()["gitea_runtime_token"].GetStringValue()
require.NotEmpty(t, taskToken1)
uploadTestArtifactFile(t, run.ID, taskToken1, "artifact-attempt-1", "attempt-1.txt", strings.Repeat("A", 32))
uploadTestArtifactFile(t, run.ID, taskToken1, "artifact-shared", "shared.txt", strings.Repeat("C", 32))
attempt1Names := listArtifactNamesForRun(t, run.ID, taskToken1)
assert.ElementsMatch(t, []string{"artifact-attempt-1", "artifact-shared"}, attempt1Names)
runner.execTask(t, task1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS}) // complete first run
// rerun
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", repo.OwnerName, repo.Name, run.ID))
session.MakeRequest(t, req, http.StatusOK)
task2 := runner.fetchTask(t)
_, job2, _ := getTaskAndJobAndRunByTaskID(t, task2.Id)
require.NotZero(t, job2.RunAttemptID)
assert.NotEqual(t, job1.RunAttemptID, job2.RunAttemptID)
taskToken2 := task2.Context.GetFields()["gitea_runtime_token"].GetStringValue()
require.NotEmpty(t, taskToken2)
uploadTestArtifactFile(t, run.ID, taskToken2, "artifact-attempt-2", "attempt-2.txt", strings.Repeat("B", 32))
uploadTestArtifactFile(t, run.ID, taskToken2, "artifact-shared", "shared.txt", strings.Repeat("D", 32))
attempt2Names := listArtifactNamesForRun(t, run.ID, taskToken2)
assert.ElementsMatch(t, []string{"artifact-attempt-2", "artifact-shared"}, attempt2Names)
assert.NotContains(t, attempt2Names, "artifact-attempt-1")
// "artifact-attempt-1" belongs to the first attempt, so the rerun token cannot access it
req = NewRequest(t, "GET", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts/%x/download_url?itemPath=artifact-attempt-1", run.ID, md5.Sum([]byte("artifact-attempt-1")))).
AddTokenAuth(taskToken2)
MakeRequest(t, req, http.StatusNotFound)
// "artifact-shared" for each attempt has different content
sharedContent1 := downloadArtifactFileContentByAttempt(t, session, repo.OwnerName, repo.Name, run.ID, "artifact-shared", 1, "shared.txt")
assert.Equal(t, strings.Repeat("C", 32), sharedContent1)
sharedContent2 := downloadArtifactFileContentByAttempt(t, session, repo.OwnerName, repo.Name, run.ID, "artifact-shared", 2, "shared.txt")
assert.Equal(t, strings.Repeat("D", 32), sharedContent2)
}
func uploadTestArtifactFile(t *testing.T, runID int64, authToken, artifactName, fileName, content string) {
t.Helper()
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts", runID), getUploadArtifactRequest{
Type: "actions_storage",
Name: artifactName,
}).AddTokenAuth(authToken)
resp := MakeRequest(t, req, http.StatusOK)
var uploadResp uploadArtifactResponse
DecodeJSON(t, resp, &uploadResp)
idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
uploadURL := uploadResp.FileContainerResourceURL[idx:] + "?itemPath=" + artifactName + "/" + fileName
contentLen := strconv.Itoa(len(content))
contentMD5 := md5.Sum([]byte(content))
req = NewRequestWithBody(t, "PUT", uploadURL, strings.NewReader(content)).
AddTokenAuth(authToken).
SetHeader("Content-Range", fmt.Sprintf("bytes 0-%d/%d", len(content)-1, len(content))).
SetHeader("x-tfs-filelength", contentLen).
SetHeader("x-actions-results-md5", base64.StdEncoding.EncodeToString(contentMD5[:]))
MakeRequest(t, req, http.StatusOK)
req = NewRequest(t, "PATCH", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts?artifactName=%s", runID, artifactName)).
AddTokenAuth(authToken)
MakeRequest(t, req, http.StatusOK)
}
func listArtifactNamesForRun(t *testing.T, runID int64, taskToken string) []string {
t.Helper()
req := NewRequest(t, "GET", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts", runID)).
AddTokenAuth(taskToken)
resp := MakeRequest(t, req, http.StatusOK)
var listResp listArtifactsResponse
DecodeJSON(t, resp, &listResp)
names := make([]string, 0, len(listResp.Value))
for _, item := range listResp.Value {
names = append(names, item.Name)
}
return names
}
func downloadArtifactFileContentByAttempt(t *testing.T, session *TestSession, owner, repo string, runID int64, artifactName string, attempt int64, fileName string) string {
t.Helper()
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/artifacts/%s?attempt=%d", owner, repo, runID, url.PathEscape(artifactName), attempt))
resp := session.MakeRequest(t, req, http.StatusOK)
zr, err := zip.NewReader(bytes.NewReader(resp.Body.Bytes()), int64(resp.Body.Len()))
require.NoError(t, err)
for _, f := range zr.File {
if f.Name != fileName {
continue
}
rc, err := f.Open()
require.NoError(t, err)
content, err := io.ReadAll(rc)
rc.Close()
require.NoError(t, err)
return string(content)
}
require.FailNowf(t, "artifact file not found", "artifact %q attempt %d does not contain file %q", artifactName, attempt, fileName)
return ""
}
@@ -13,6 +13,7 @@ import (
"io"
"mime"
"net/http"
"strconv"
"strings"
"testing"
"time"
@@ -31,6 +32,7 @@ import (
"code.gitea.io/gitea/routers/api/actions"
actions_service "code.gitea.io/gitea/services/actions"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/encoding/protojson"
@@ -880,3 +882,127 @@ func TestActionsArtifactV4DeletePublicApiNotAllowedReadScope(t *testing.T) {
AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
}
func testActionRunAttemptArtifactV4(t *testing.T, repo *repo_model.Repository, session *TestSession, runner *mockRunner) {
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/actions/run?workflow=%s", repo.OwnerName, repo.Name, "run-attempt-artifact.yml"), map[string]string{
"ref": "refs/heads/main",
})
session.MakeRequest(t, req, http.StatusSeeOther)
// first run
task1 := runner.fetchTask(t)
_, job1, run := getTaskAndJobAndRunByTaskID(t, task1.Id)
require.NotZero(t, job1.RunAttemptID)
taskToken1 := task1.Context.GetFields()["gitea_runtime_token"].GetStringValue()
require.NotEmpty(t, taskToken1)
uploadTestArtifactFileV4(t, run.ID, job1.ID, taskToken1, "artifact-attempt-1", strings.Repeat("A", 32))
uploadTestArtifactFileV4(t, run.ID, job1.ID, taskToken1, "artifact-shared", strings.Repeat("C", 32))
attempt1Names := listArtifactNamesForRunV4(t, run.ID, job1.ID, taskToken1)
assert.ElementsMatch(t, []string{"artifact-attempt-1", "artifact-shared"}, attempt1Names)
runner.execTask(t, task1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
// rerun
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", repo.OwnerName, repo.Name, run.ID))
session.MakeRequest(t, req, http.StatusOK)
task2 := runner.fetchTask(t)
_, job2, _ := getTaskAndJobAndRunByTaskID(t, task2.Id)
require.NotZero(t, job2.RunAttemptID)
assert.NotEqual(t, job1.RunAttemptID, job2.RunAttemptID)
taskToken2 := task2.Context.GetFields()["gitea_runtime_token"].GetStringValue()
require.NotEmpty(t, taskToken2)
uploadTestArtifactFileV4(t, run.ID, job2.ID, taskToken2, "artifact-attempt-2", strings.Repeat("B", 32))
uploadTestArtifactFileV4(t, run.ID, job2.ID, taskToken2, "artifact-shared", strings.Repeat("D", 32))
attempt2Names := listArtifactNamesForRunV4(t, run.ID, job2.ID, taskToken2)
assert.ElementsMatch(t, []string{"artifact-attempt-2", "artifact-shared"}, attempt2Names)
assert.NotContains(t, attempt2Names, "artifact-attempt-1")
// "artifact-attempt-1" belongs to the first attempt, so the rerun token cannot access it
req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/GetSignedArtifactURL", toProtoJSON(&actions.GetSignedArtifactURLRequest{
Name: "artifact-attempt-1",
WorkflowRunBackendId: strconv.FormatInt(run.ID, 10),
WorkflowJobRunBackendId: strconv.FormatInt(job2.ID, 10),
})).AddTokenAuth(taskToken2)
MakeRequest(t, req, http.StatusNotFound)
// the run-scoped repo API should list finalized v4 artifacts from all attempts
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/artifacts", repo.OwnerName, repo.Name, run.ID))
resp := session.MakeRequest(t, req, http.StatusOK)
var runArtifactsResp api.ActionArtifactsResponse
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &runArtifactsResp))
require.Len(t, runArtifactsResp.Entries, 4)
runArtifactNames := make([]string, 0, len(runArtifactsResp.Entries))
for _, artifact := range runArtifactsResp.Entries {
runArtifactNames = append(runArtifactNames, artifact.Name)
}
assert.ElementsMatch(t, []string{"artifact-attempt-1", "artifact-shared", "artifact-attempt-2", "artifact-shared"}, runArtifactNames)
// the result should contain 2 artifacts when query by name=artifact-shared
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/artifacts?name=artifact-shared", repo.OwnerName, repo.Name, run.ID))
resp = session.MakeRequest(t, req, http.StatusOK)
var sharedArtifactsResp api.ActionArtifactsResponse
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &sharedArtifactsResp))
require.Len(t, sharedArtifactsResp.Entries, 2)
assert.Equal(t, strings.Repeat("C", 32), downloadRepoArtifactV4Content(t, session, sharedArtifactsResp.Entries[0].ArchiveDownloadURL))
assert.Equal(t, strings.Repeat("D", 32), downloadRepoArtifactV4Content(t, session, sharedArtifactsResp.Entries[1].ArchiveDownloadURL))
}
func uploadTestArtifactFileV4(t *testing.T, runID, jobID int64, authToken, artifactName, content string) {
t.Helper()
req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", toProtoJSON(&actions.CreateArtifactRequest{
Version: 4,
Name: artifactName,
WorkflowRunBackendId: strconv.FormatInt(runID, 10),
WorkflowJobRunBackendId: strconv.FormatInt(jobID, 10),
MimeType: wrapperspb.String("application/zip"),
})).AddTokenAuth(authToken)
resp := MakeRequest(t, req, http.StatusOK)
var uploadResp actions.CreateArtifactResponse
require.NoError(t, protojson.Unmarshal(resp.Body.Bytes(), &uploadResp))
require.True(t, uploadResp.Ok)
req = NewRequestWithBody(t, "PUT", uploadResp.SignedUploadUrl+"&comp=appendBlock", strings.NewReader(content))
MakeRequest(t, req, http.StatusCreated)
sum := sha256.Sum256([]byte(content))
req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact", toProtoJSON(&actions.FinalizeArtifactRequest{
Name: artifactName,
Size: int64(len(content)),
Hash: wrapperspb.String("sha256:" + hex.EncodeToString(sum[:])),
WorkflowRunBackendId: strconv.FormatInt(runID, 10),
WorkflowJobRunBackendId: strconv.FormatInt(jobID, 10),
})).AddTokenAuth(authToken)
resp = MakeRequest(t, req, http.StatusOK)
var finalizeResp actions.FinalizeArtifactResponse
require.NoError(t, protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp))
require.True(t, finalizeResp.Ok)
}
func listArtifactNamesForRunV4(t *testing.T, runID, jobID int64, taskToken string) []string {
t.Helper()
req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts", toProtoJSON(&actions.ListArtifactsRequest{
WorkflowRunBackendId: strconv.FormatInt(runID, 10),
WorkflowJobRunBackendId: strconv.FormatInt(jobID, 10),
})).AddTokenAuth(taskToken)
resp := MakeRequest(t, req, http.StatusOK)
var listResp actions.ListArtifactsResponse
require.NoError(t, protojson.Unmarshal(resp.Body.Bytes(), &listResp))
names := make([]string, 0, len(listResp.Artifacts))
for _, item := range listResp.Artifacts {
names = append(names, item.Name)
}
return names
}
func downloadRepoArtifactV4Content(t *testing.T, session *TestSession, archiveDownloadURL string) string {
t.Helper()
req := NewRequest(t, "GET", archiveDownloadURL)
resp := session.MakeRequest(t, req, http.StatusFound)
req = NewRequest(t, "GET", resp.Header().Get("Location"))
resp = MakeRequest(t, req, http.StatusOK)
return resp.Body.String()
}
+22 -13
View File
@@ -208,15 +208,18 @@ func TestAPIActionsRerunWorkflowRun(t *testing.T) {
assert.Equal(t, actions_model.StatusWaiting, run.Status)
assert.Equal(t, timeutil.TimeStamp(0), run.Started)
assert.Equal(t, timeutil.TimeStamp(0), run.Stopped)
job198, err := actions_model.GetRunJobByRunAndID(t.Context(), 795, 198)
latestAttempt, hasLatestAttempt, err := run.GetLatestAttempt(t.Context())
require.NoError(t, err)
require.True(t, hasLatestAttempt)
job198 := getLatestAttemptJobByTemplateJobID(t, 795, 198)
assert.Equal(t, actions_model.StatusWaiting, job198.Status)
assert.Equal(t, latestAttempt.Attempt, job198.Attempt)
assert.Equal(t, int64(0), job198.TaskID)
job199, err := actions_model.GetRunJobByRunAndID(t.Context(), 795, 199)
require.NoError(t, err)
job199 := getLatestAttemptJobByTemplateJobID(t, 795, 199)
assert.Equal(t, actions_model.StatusWaiting, job199.Status)
assert.Equal(t, latestAttempt.Attempt, job199.Attempt)
assert.Equal(t, int64(0), job199.TaskID)
})
@@ -262,22 +265,28 @@ func TestAPIActionsRerunWorkflowJob(t *testing.T) {
var rerunResp api.ActionWorkflowJob
err := json.Unmarshal(resp.Body.Bytes(), &rerunResp)
require.NoError(t, err)
assert.Equal(t, int64(199), rerunResp.ID)
job199Rerun := getLatestAttemptJobByTemplateJobID(t, 795, 199)
assert.Equal(t, job199Rerun.ID, rerunResp.ID)
assert.Equal(t, "queued", rerunResp.Status)
run, err := actions_model.GetRunByRepoAndID(t.Context(), repo.ID, 795)
require.NoError(t, err)
assert.Equal(t, actions_model.StatusWaiting, run.Status)
job198, err := actions_model.GetRunJobByRunAndID(t.Context(), 795, 198)
latestAttempt, hasLatestAttempt, err := run.GetLatestAttempt(t.Context())
require.NoError(t, err)
assert.Equal(t, actions_model.StatusSuccess, job198.Status)
assert.Equal(t, int64(53), job198.TaskID)
require.True(t, hasLatestAttempt)
job199, err := actions_model.GetRunJobByRunAndID(t.Context(), 795, 199)
require.NoError(t, err)
assert.Equal(t, actions_model.StatusWaiting, job199.Status)
assert.Equal(t, int64(0), job199.TaskID)
job198Rerun := getLatestAttemptJobByTemplateJobID(t, 795, 198)
assert.Equal(t, actions_model.StatusSuccess, job198Rerun.Status)
assert.Equal(t, latestAttempt.Attempt, job198Rerun.Attempt)
assert.Equal(t, int64(0), job198Rerun.TaskID)
assert.Equal(t, int64(53), job198Rerun.SourceTaskID)
job199Rerun = getLatestAttemptJobByTemplateJobID(t, 795, 199)
assert.Equal(t, actions_model.StatusWaiting, job199Rerun.Status)
assert.Equal(t, latestAttempt.Attempt, job199Rerun.Attempt)
assert.Equal(t, int64(0), job199Rerun.TaskID)
assert.Equal(t, int64(0), job199Rerun.SourceTaskID)
})
t.Run("ForbiddenWithoutWriteScope", func(t *testing.T) {
@@ -3,17 +3,19 @@ import {normalizeTestHtml} from '../utils/testhelper.ts';
describe('buildArtifactTooltipHtml', () => {
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(`<span class="flex-text-inline">
<span>Expires at </span>
<relative-time datetime="2026-03-20T12:00:00.000Z" threshold="P0Y" prefix="" weekday="" year="numeric" month="short" hour="numeric" minute="2-digit">
2026-03-20T12:00:00.000Z
<relative-time datetime="${expiresUnix}" threshold="P0Y" prefix="" weekday="" year="numeric" month="short" hour="numeric" minute="2-digit">
${expiresLocal}
</relative-time>
<span> (extra)</span>
<span class="inline-divider">,</span>
+3 -4
View File
@@ -7,15 +7,14 @@ export function buildArtifactTooltipHtml(artifact: ActionsArtifact, expiresAtLoc
if (artifact.expiresUnix <= 0) {
return html`<span class="flex-text-inline">${sizeText}</span>`; // use the same layout as below
}
const datetimeLocal = new Date(artifact.expiresUnix * 1000).toLocaleString();
// split so the <relative-time> 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`
<span class="flex-text-inline">
<span>${prefix}</span>
<relative-time datetime="${datetime}" threshold="P0Y" prefix="" weekday="" year="numeric" month="short" hour="numeric" minute="2-digit">
${datetime}
<relative-time datetime="${artifact.expiresUnix}" threshold="P0Y" prefix="" weekday="" year="numeric" month="short" hour="numeric" minute="2-digit">
${datetimeLocal}
</relative-time>
<span>${suffix}</span>
<span class="inline-divider">,</span>
+2 -4
View File
@@ -77,9 +77,8 @@ defineOptions({
const props = defineProps<{
store: ActionRunViewStore,
runId: number;
jobId: number;
actionsUrl: string;
actionsViewUrl: string;
locale: Record<string, any>;
}>();
const store = props.store;
@@ -270,8 +269,7 @@ async function fetchJobData(abortController: AbortController): Promise<JobData>
// 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},
});
+18 -4
View File
@@ -13,11 +13,18 @@ const props = defineProps<{
locale: Record<string, any>;
}>();
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(() => {
<div class="action-run-summary-view">
<div class="action-run-summary-block">
<div class="flex-text-block">
{{ locale.triggeredVia.replace('%s', run.triggerEvent) }} <relative-time :datetime="runTriggeredAtIso" prefix=""/>
<span>{{ isRerun ? locale.rerun : locale.triggeredVia.replace('%s', run.triggerEvent) }}</span>
<template v-if="triggerUser">
<span></span>
<a v-if="triggerUser.link" class="muted" :href="triggerUser.link">{{ triggerUser.name }}</a>
<span v-else class="muted">{{ triggerUser.name }}</span>
</template>
<span></span>
<relative-time :datetime="run.triggeredAt || ''" prefix=""/>
</div>
<div class="flex-text-block">
<ActionRunStatus :locale-status="locale.status[run.status]" :status="run.status" :size="16"/>
+5 -3
View File
@@ -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;
+59 -12
View File
@@ -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<string, any>;
}>();
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();
}
</script>
@@ -51,10 +64,10 @@ async function deleteArtifact(name: string) {
<button class="ui basic small compact button primary" @click="approveRun()" v-if="run.canApprove">
{{ locale.approve }}
</button>
<button class="ui basic small compact button red" @click="cancelRun()" v-else-if="run.canCancel">
<button class="ui small compact button tw-text-red" @click="cancelRun()" v-else-if="run.canCancel">
{{ locale.cancel }}
</button>
<template v-else-if="run.canRerun">
<template v-if="run.canRerun">
<div v-if="run.canRerunFailed" class="ui small compact buttons">
<button class="ui basic small compact button link-action" :data-url="`${run.link}/rerun-failed`">
{{ locale.rerun_failed }}
@@ -72,10 +85,45 @@ async function deleteArtifact(name: string) {
{{ locale.rerun_all }}
</button>
</template>
<div v-if="run.attempts.length > 1" class="ui dropdown basic small compact button">
<div class="flex-text-inline">
<SvgIcon name="octicon-history" :size="14"/>
<span>{{ formatCurrentAttemptTitle(run.attempts.find((attempt) => attempt.current)!) }}</span>
</div>
<SvgIcon name="octicon-triangle-down" :size="14" class="dropdown icon"/>
<div class="menu">
<a
v-for="attempt in run.attempts"
:key="attempt.attempt"
class="item tw-flex tw-flex-col tw-gap-2"
:class="attempt.current ? 'selected' : ''"
:href="attempt.link"
>
<div class="flex-text-block">
<SvgIcon name="octicon-check" :size="14" :class="{'tw-invisible': !Boolean(attempt.current)}"/>
<strong class="tw-text-sm gt-ellipsis">{{ formatAttemptTitle(attempt) }}</strong>
</div>
<div class="flex-text-block tw-pl-[20px]">
<span class="flex-text-inline tw-flex-shrink-0">
<ActionRunStatus :locale-status="locale.status[attempt.status]" :status="attempt.status" :size="14" class="flex-text-block"/>
<span>{{ locale.status[attempt.status] }}</span>
</span>
<span></span>
<relative-time :datetime="attempt.triggeredAt" prefix=""/>
<span></span>
<span class="gt-ellipsis">{{ attempt.triggerUserName }}</span>
</div>
</a>
</div>
</div>
</div>
</div>
<div class="action-commit-summary">
<span><a class="muted" :href="run.workflowLink"><b>{{ run.workflowID }}</b></a>:</span>
<span>
<a v-if="run.workflowLink" class="muted" :href="run.workflowLink"><b>{{ run.workflowID }}</b></a>
<b v-else>{{ run.workflowID }}</b>
:
</span>
<template v-if="run.isSchedule">
{{ locale.scheduled }}
</template>
@@ -94,7 +142,7 @@ async function deleteArtifact(name: string) {
<div class="action-view-body">
<div class="action-view-left">
<!-- summary -->
<a class="job-brief-item silenced" :href="run.link" :class="!props.jobId ? 'selected' : ''">
<a class="job-brief-item silenced" :href="run.viewLink" :class="!props.jobId ? 'selected' : ''">
<SvgIcon name="octicon-home"/>
<span class="gt-ellipsis">{{ locale.summary }}</span>
</a>
@@ -105,7 +153,7 @@ async function deleteArtifact(name: string) {
<!-- unlike other lists, the items have paddings already -->
<ul class="ui relaxed list flex-items-block tw-p-0">
<li class="item job-brief-item" v-for="job in run.jobs" :key="job.id" :class="props.jobId === job.id ? 'selected' : ''">
<a class="tw-contents silenced" :href="run.link+'/jobs/'+job.id">
<a class="tw-contents silenced" :href="job.link">
<ActionRunStatus :locale-status="locale.status[job.status]" :status="job.status"/>
<span class="tw-flex-1 gt-ellipsis">{{ job.name }}</span>
<SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="tw-cursor-pointer link-action interact-fg" :data-url="`${run.link}/jobs/${job.id}/rerun`" v-if="job.canRerun"/>
@@ -123,7 +171,7 @@ async function deleteArtifact(name: string) {
<template v-if="artifact.status !== 'expired'">
<a
class="tw-flex-1 flex-text-block muted" target="_blank"
:href="run.link+'/artifacts/'+encodeURIComponent(artifact.name)"
:href="buildArtifactLink(artifact.name)"
:data-tooltip-content="buildArtifactTooltipHtml(artifact, locale.artifactExpiresAt)"
data-tooltip-render="html"
data-tooltip-placement="top-end"
@@ -167,9 +215,8 @@ async function deleteArtifact(name: string) {
v-else
:store="store"
:locale="locale"
:run-id="props.runId"
:actions-view-url="props.actionsViewUrl"
:job-id="props.jobId"
:actions-url="props.actionsUrl"
/>
</div>
</div>
+5 -7
View File
@@ -4,8 +4,6 @@ import RepoActionView from '../components/RepoActionView.vue';
export function initRepositoryActionView() {
const el = document.querySelector('#repo-action-view');
if (!el) return;
const runId = parseInt(el.getAttribute('data-run-id')!);
const jobId = parseInt(el.getAttribute('data-job-id')!);
// TODO: the parent element's full height doesn't work well now,
// but we can not pollute the global style at the moment, only fix the height problem for pages with this component
@@ -13,25 +11,25 @@ export function initRepositoryActionView() {
if (parentFullHeight) parentFullHeight.classList.add('tw-pb-0');
const view = createApp(RepoActionView, {
runId,
jobId,
actionsUrl: el.getAttribute('data-actions-url'),
jobId: parseInt(el.getAttribute('data-job-id')!),
actionsViewUrl: el.getAttribute('data-actions-view-url'),
locale: {
approve: el.getAttribute('data-locale-approve'),
cancel: el.getAttribute('data-locale-cancel'),
rerun: el.getAttribute('data-locale-rerun'),
rerun_all: el.getAttribute('data-locale-rerun-all'),
rerun_failed: el.getAttribute('data-locale-rerun-failed'),
latest: el.getAttribute('data-locale-latest'),
latestAttempt: el.getAttribute('data-locale-latest-attempt'),
attempt: el.getAttribute('data-locale-attempt'),
scheduled: el.getAttribute('data-locale-runs-scheduled'),
commit: el.getAttribute('data-locale-runs-commit'),
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
workflowGraph: el.getAttribute('data-locale-runs-workflow-graph'),
summary: el.getAttribute('data-locale-summary'),
allJobs: el.getAttribute('data-locale-all-jobs'),
triggeredVia: el.getAttribute('data-locale-triggered-via'),
totalDuration: el.getAttribute('data-locale-total-duration'),
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
areYouSure: el.getAttribute('data-locale-are-you-sure'),
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
artifactExpiresAt: el.getAttribute('data-locale-artifact-expires-at'),
confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
+16
View File
@@ -5,6 +5,7 @@ export type ActionsArtifactStatus = 'expired' | 'completed';
export type ActionsRun = {
repoId: number,
link: string,
viewLink: string,
title: string,
titleHTML: string,
status: ActionsRunStatus,
@@ -17,6 +18,8 @@ export type ActionsRun = {
workflowID: string,
workflowLink: string,
isSchedule: boolean,
runAttempt: number,
attempts: Array<ActionsRunAttempt>,
duration: string,
triggeredAt: number,
triggerEvent: string,
@@ -38,8 +41,21 @@ export type ActionsRun = {
},
};
export type ActionsRunAttempt = {
attempt: number;
status: ActionsRunStatus;
done: boolean;
link: string;
current: boolean;
latest: boolean;
triggeredAt: number;
triggerUserName: string;
triggerUserLink: string;
};
export type ActionsJob = {
id: number;
link: string;
jobId: string;
name: string;
status: ActionsRunStatus;
+2
View File
@@ -45,6 +45,7 @@ import octiconGitPullRequestClosed from '../../public/assets/img/svg/octicon-git
import octiconGitPullRequestDraft from '../../public/assets/img/svg/octicon-git-pull-request-draft.svg';
import octiconGrabber from '../../public/assets/img/svg/octicon-grabber.svg';
import octiconHeading from '../../public/assets/img/svg/octicon-heading.svg';
import octiconHistory from '../../public/assets/img/svg/octicon-history.svg';
import octiconHorizontalRule from '../../public/assets/img/svg/octicon-horizontal-rule.svg';
import octiconHome from '../../public/assets/img/svg/octicon-home.svg';
import octiconImage from '../../public/assets/img/svg/octicon-image.svg';
@@ -131,6 +132,7 @@ const svgs = {
'octicon-git-pull-request-draft': octiconGitPullRequestDraft,
'octicon-grabber': octiconGrabber,
'octicon-heading': octiconHeading,
'octicon-history': octiconHistory,
'octicon-horizontal-rule': octiconHorizontalRule,
'octicon-home': octiconHome,
'octicon-image': octiconImage,
@@ -54,6 +54,26 @@ test('switches to datetime format after default threshold', async () => {
expect(getText(el)).toMatch(/on [A-Z][a-z]{2} \d{1,2}/);
});
test('accepts unix seconds as integer string', async () => {
const el = createRelativeTime(String(Math.floor(Date.now() / 1000) - 3 * 60));
await Promise.resolve();
expect(getText(el)).toBe('3 minutes ago');
});
test('ignores fractional unix seconds', async () => {
const el = createRelativeTime('1700000000.5');
el.shadowRoot!.textContent = 'fallback';
await Promise.resolve();
expect(getText(el)).toBe('fallback');
});
test('ignores negative unix seconds', async () => {
const el = createRelativeTime('-86400');
el.shadowRoot!.textContent = 'fallback';
await Promise.resolve();
expect(getText(el)).toBe('fallback');
});
test('ignores invalid datetime', async () => {
const el = createRelativeTime('bogus');
el.shadowRoot!.textContent = 'fallback';
@@ -61,6 +81,13 @@ test('ignores invalid datetime', async () => {
expect(getText(el)).toBe('fallback');
});
test('ignores partial numeric datetime', async () => {
const el = createRelativeTime('123abc');
el.shadowRoot!.textContent = 'fallback';
await Promise.resolve();
expect(getText(el)).toBe('fallback');
});
test('handles empty datetime', async () => {
const el = createRelativeTime('');
el.shadowRoot!.textContent = 'fallback';
+3 -1
View File
@@ -28,6 +28,7 @@ type FormatStyle = 'long' | 'short' | 'narrow';
const unitNames = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'] as const;
const durationRe = /^[-+]?P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
const unixSecondsRe = /^\d+$/;
function parseDurationMs(str: string): number {
const m = durationRe.exec(str);
@@ -364,7 +365,8 @@ class RelativeTime extends HTMLElement {
}
get date(): Date | null {
const parsed = Date.parse(this.datetime);
const dt = this.datetime;
const parsed = unixSecondsRe.test(dt) ? Number(dt) * 1000 : Date.parse(dt);
return Number.isNaN(parsed) ? null : new Date(parsed);
}