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:
@@ -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
@@ -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
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -272,7 +272,6 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
|
||||
}
|
||||
|
||||
now := timeutil.TimeStampNow()
|
||||
job.Attempt++
|
||||
job.Started = now
|
||||
job.Status = StatusRunning
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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" .}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"}}"
|
||||
|
||||
Generated
+139
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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++ {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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},
|
||||
});
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user