Introduce ActionRunAttempt to represent each execution of a run (#37119)

This PR introduces a new `ActionRunAttempt` model and makes Actions
execution attempt-scoped.

**Main Changes**

- Each workflow run trigger generates a new `ActionRunAttempt`. The
triggered jobs are then associated with this new `ActionRunAttempt`
record.
- Each rerun now creates:
  - a new `ActionRunAttempt` record for the workflow run
- a full new set of `ActionRunJob` records for the new
`ActionRunAttempt`
- For jobs that need to be rerun, the new job records are created as
runnable jobs in the new attempt.
- For jobs that do not need to be rerun, new job records are still
created in the new attempt, but they reuse the result of the previous
attempt instead of executing again.
- Introduce `rerunPlan` to manage each rerun and refactored rerun flow
into a two-phase plan-based model:
  - `buildRerunPlan`
  - `execRerunPlan`
- `RerunFailedWorkflowRun` and `RerunFailed` no longer directly derives
all jobs that need to be rerun; this step is now handled by
`buildRerunPlan`.
- Converted artifacts from run-scoped to attempt-scoped:
  - uploads are now associated with `RunAttemptID`
  - listing, download, and deletion resolve against the current attempt
- Added attempt-aware web Actions views:
- the default run page shows the latest attempt
(`/actions/runs/{run_id}`)
- previous attempt pages show jobs and artifacts for that attempt
(`/actions/runs/{run_id}/attempts/{attempt_num}`)
- New APIs:
  - `/repos/{owner}/{repo}/actions/runs/{run}/attempts/{attempt}`
  - `/repos/{owner}/{repo}/actions/runs/{run}/attempts/{attempt}/jobs`
- New configuration `MAX_RERUN_ATTEMPTS`
  - https://gitea.com/gitea/docs/pulls/383

**Compatibility**

- Existing legacy runs use `LatestAttemptID = 0` and legacy jobs use
`RunAttemptID = 0`. Therefore, these fields can be used to identify
legacy runs and jobs and provide backward compatibility.
- If a legacy run is rerun, an `ActionRunAttempt` with `attempt=1` will
be created to represent the original execution. Then a new
`ActionRunAttempt` with `attempt=2` will be created for the real rerun.
- Existing artifact records are not backfilled; legacy artifacts
continue to use `RunAttemptID = 0`.

**Improvements**

- It is now easier to inspect and download logs from previous attempts.
-
[`run_attempt`](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#github-context)
semantics are now aligned with GitHub.
- > A unique number for each attempt of a particular workflow run in a
repository. This number begins at 1 for the workflow run's first
attempt, and increments with each re-run.
- Rerun behavior is now clearer and more explicit.
- Instead of mutating the status of previous jobs in place, each rerun
creates a new attempt with a full new set of job records.
- Artifacts produced by different reruns can now be listed separately.

Signed-off-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
Zettat123
2026-04-23 17:33:41 -06:00
committed by GitHub
parent aedf4e84f5
commit 899ede1d55
74 changed files with 3838 additions and 848 deletions

View File

@@ -311,7 +311,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo) {
if !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning) {
continue
}
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID)
if err != nil {
ctx.ServerError("GetRunJobsByRunID", err)
return

View File

@@ -34,7 +34,6 @@ import (
"code.gitea.io/gitea/routers/common"
actions_service "code.gitea.io/gitea/services/actions"
context_module "code.gitea.io/gitea/services/context"
notify_service "code.gitea.io/gitea/services/notify"
"github.com/nektos/act/pkg/model"
)
@@ -166,7 +165,7 @@ func resolveCurrentRunForView(ctx *context_module.Context) *actions_model.Action
return nil
}
if run != nil {
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID)
if err != nil {
ctx.ServerError("GetRunJobsByRunID", err)
return nil
@@ -203,9 +202,23 @@ func View(ctx *context_module.Context) {
if ctx.Written() {
return
}
ctx.Data["RunID"] = run.ID
ctx.Data["JobID"] = ctx.PathParamInt64("job") // it can be 0 when no job (e.g.: run summary view)
ctx.Data["ActionsURL"] = ctx.Repo.RepoLink + "/actions"
run.Repo = ctx.Repo.Repository
jobID := ctx.PathParamInt64("job")
ctx.Data["JobID"] = jobID // it can be 0 when no job (e.g.: run summary view)
attemptNum := ctx.PathParamInt64("attempt")
// ActionsViewURL is the endpoint for viewing a run (job summary), a job, or a job attempt.
// It's POST method handler can provide the state data for the frontend rendering.
switch {
case attemptNum > 0:
ctx.Data["ActionsViewURL"] = fmt.Sprintf("%s/attempts/%d", run.Link(), attemptNum)
case jobID > 0:
ctx.Data["ActionsViewURL"] = fmt.Sprintf("%s/jobs/%d", run.Link(), jobID)
default:
ctx.Data["ActionsViewURL"] = run.Link()
}
ctx.HTML(http.StatusOK, tplViewActions)
}
@@ -259,22 +272,30 @@ type ViewResponse struct {
State struct {
Run struct {
RepoID int64 `json:"repoId"`
Link string `json:"link"`
Title string `json:"title"`
TitleHTML template.HTML `json:"titleHTML"`
Status string `json:"status"`
CanCancel bool `json:"canCancel"`
CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve
CanRerun bool `json:"canRerun"`
CanRerunFailed bool `json:"canRerunFailed"`
CanDeleteArtifact bool `json:"canDeleteArtifact"`
Done bool `json:"done"`
WorkflowID string `json:"workflowID"`
WorkflowLink string `json:"workflowLink"`
IsSchedule bool `json:"isSchedule"`
Jobs []*ViewJob `json:"jobs"`
Commit ViewCommit `json:"commit"`
RepoID int64 `json:"repoId"`
// Link is the canonical HTML URL of the run, e.g. "/owner/repo/actions/runs/123".
// Used as the base for composing sub-resource URLs (cancel, rerun, artifacts, jobs) that are not attempt-scoped.
Link string `json:"link"`
// ViewLink is the attempt-aware URL for navigation, e.g. "/owner/repo/actions/runs/123" for the latest attempt
// or "/owner/repo/actions/runs/123/attempts/2" for a historical attempt.
// Use this when the target should reflect the currently-viewed attempt.
ViewLink string `json:"viewLink"`
Title string `json:"title"`
TitleHTML template.HTML `json:"titleHTML"`
Status string `json:"status"`
CanCancel bool `json:"canCancel"`
CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve
CanRerun bool `json:"canRerun"`
CanRerunFailed bool `json:"canRerunFailed"`
CanDeleteArtifact bool `json:"canDeleteArtifact"`
Done bool `json:"done"`
WorkflowID string `json:"workflowID"`
WorkflowLink string `json:"workflowLink"`
IsSchedule bool `json:"isSchedule"`
RunAttempt int64 `json:"runAttempt"`
Attempts []*ViewRunAttempt `json:"attempts"`
Jobs []*ViewJob `json:"jobs"`
Commit ViewCommit `json:"commit"`
// Summary view: run duration and trigger time/event
Duration string `json:"duration"`
TriggeredAt int64 `json:"triggeredAt"` // unix seconds for relative time
@@ -293,6 +314,7 @@ type ViewResponse struct {
type ViewJob struct {
ID int64 `json:"id"`
Link string `json:"link"`
JobID string `json:"jobId,omitempty"`
Name string `json:"name"`
Status string `json:"status"`
@@ -301,6 +323,18 @@ type ViewJob struct {
Needs []string `json:"needs,omitempty"`
}
type ViewRunAttempt struct {
Attempt int64 `json:"attempt"`
Status string `json:"status"`
Done bool `json:"done"`
Link string `json:"link"`
Current bool `json:"current"`
Latest bool `json:"latest"`
TriggeredAt int64 `json:"triggeredAt"`
TriggerUserName string `json:"triggerUserName"`
TriggerUserLink string `json:"triggerUserLink"`
}
type ViewCommit struct {
ShortSha string `json:"shortSHA"`
Link string `json:"link"`
@@ -338,24 +372,8 @@ type ViewStepLogLine struct {
Timestamp float64 `json:"timestamp"`
}
func getActionsViewArtifacts(ctx context.Context, repoID, runID int64) (artifactsViewItems []*ArtifactsViewItem, err error) {
artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, repoID, runID)
if err != nil {
return nil, err
}
for _, art := range artifacts {
artifactsViewItems = append(artifactsViewItems, &ArtifactsViewItem{
Name: art.ArtifactName,
Size: art.FileSize,
Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"),
ExpiresUnix: int64(art.ExpiredUnix),
})
}
return artifactsViewItems, nil
}
func ViewPost(ctx *context_module.Context) {
run, jobs := getCurrentRunJobsByPathParam(ctx)
run, attempt, jobs := getCurrentRunJobsByPathParam(ctx)
if ctx.Written() {
return
}
@@ -365,7 +383,7 @@ func ViewPost(ctx *context_module.Context) {
}
resp := &ViewResponse{}
fillViewRunResponseSummary(ctx, resp, run, jobs)
fillViewRunResponseSummary(ctx, resp, run, attempt, jobs)
if ctx.Written() {
return
}
@@ -376,23 +394,33 @@ func ViewPost(ctx *context_module.Context) {
ctx.JSON(http.StatusOK, resp)
}
func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) {
var err error
resp.Artifacts, err = getActionsViewArtifacts(ctx, ctx.Repo.Repository.ID, run.ID)
if err != nil {
ctx.ServerError("getActionsViewArtifacts", err)
return
}
func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, jobs []*actions_model.ActionRunJob) {
// Latest when the run has no attempts yet (legacy) or the viewed attempt is the run's latest.
isLatestAttempt := run.LatestAttemptID == 0 || (attempt != nil && attempt.ID == run.LatestAttemptID)
resp.State.Run.RepoID = ctx.Repo.Repository.ID
// the title for the "run" is from the commit message
resp.State.Run.Title = run.Title
resp.State.Run.TitleHTML = templates.NewRenderUtils(ctx).RenderCommitMessage(run.Title, ctx.Repo.Repository)
resp.State.Run.Link = run.Link()
resp.State.Run.CanCancel = !run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions)
resp.State.Run.CanApprove = run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions)
resp.State.Run.CanRerun = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions)
resp.State.Run.CanDeleteArtifact = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions)
resp.State.Run.ViewLink = getRunViewLink(run, attempt)
resp.State.Run.Attempts = make([]*ViewRunAttempt, 0)
if attempt != nil {
resp.State.Run.RunAttempt = attempt.Attempt
resp.State.Run.Status = attempt.Status.String()
resp.State.Run.Done = attempt.Status.IsDone()
resp.State.Run.Duration = attempt.Duration().String()
resp.State.Run.TriggeredAt = attempt.Created.AsTime().Unix()
} else {
resp.State.Run.Status = run.Status.String()
resp.State.Run.Done = run.Status.IsDone()
resp.State.Run.Duration = run.Duration().String()
resp.State.Run.TriggeredAt = run.Created.AsTime().Unix()
}
resp.State.Run.CanCancel = isLatestAttempt && !resp.State.Run.Done && ctx.Repo.CanWrite(unit.TypeActions)
resp.State.Run.CanApprove = isLatestAttempt && run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions)
resp.State.Run.CanRerun = isLatestAttempt && resp.State.Run.Done && ctx.Repo.CanWrite(unit.TypeActions)
resp.State.Run.CanDeleteArtifact = resp.State.Run.Done && ctx.Repo.CanWrite(unit.TypeActions)
if resp.State.Run.CanRerun {
for _, job := range jobs {
if job.Status == actions_model.StatusFailure || job.Status == actions_model.StatusCancelled {
@@ -401,15 +429,16 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
}
}
}
resp.State.Run.Done = run.Status.IsDone()
resp.State.Run.WorkflowID = run.WorkflowID
resp.State.Run.WorkflowLink = run.WorkflowLink()
if isLatestAttempt {
resp.State.Run.WorkflowLink = run.WorkflowLink()
}
resp.State.Run.IsSchedule = run.IsSchedule()
resp.State.Run.Jobs = make([]*ViewJob, 0, len(jobs)) // marshal to '[]' instead fo 'null' in json
resp.State.Run.Status = run.Status.String()
for _, v := range jobs {
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &ViewJob{
ID: v.ID,
Link: fmt.Sprintf("%s/jobs/%d", run.Link(), v.ID),
JobID: v.JobID,
Name: v.Name,
Status: v.Status.String(),
@@ -419,6 +448,29 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
})
}
attempts, err := actions_model.ListRunAttemptsByRunID(ctx, run.ID)
if err != nil {
ctx.ServerError("ListRunAttemptsByRunID", err)
return
}
if err := attempts.LoadTriggerUser(ctx); err != nil {
ctx.ServerError("LoadTriggerUser", err)
return
}
for _, runAttempt := range attempts {
resp.State.Run.Attempts = append(resp.State.Run.Attempts, &ViewRunAttempt{
Attempt: runAttempt.Attempt,
Status: runAttempt.Status.String(),
Done: runAttempt.Status.IsDone(),
Link: getRunViewLink(run, runAttempt),
Current: runAttempt.ID == attempt.ID,
Latest: runAttempt.ID == run.LatestAttemptID,
TriggeredAt: runAttempt.Created.AsTime().Unix(),
TriggerUserName: runAttempt.TriggerUser.GetDisplayName(),
TriggerUserLink: runAttempt.TriggerUser.HomeLink(),
})
}
pusher := ViewUser{
DisplayName: run.TriggerUser.GetDisplayName(),
Link: run.TriggerUser.HomeLink(),
@@ -443,9 +495,27 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
Pusher: pusher,
Branch: branch,
}
resp.State.Run.Duration = run.Duration().String()
resp.State.Run.TriggeredAt = run.Created.AsTime().Unix()
resp.State.Run.TriggerEvent = run.TriggerEvent
// Legacy runs (LatestAttemptID == 0) have no attempt; their artifacts all share run_attempt_id=0,
// so passing 0 here scopes to this run's legacy artifacts only.
var runAttemptID int64
if attempt != nil {
runAttemptID = attempt.ID
}
arts, err := actions_model.ListUploadedArtifactsMetaByRunAttempt(ctx, ctx.Repo.Repository.ID, run.ID, runAttemptID)
if err != nil {
ctx.ServerError("ListUploadedArtifactsMetaByRunAttempt", err)
return
}
resp.Artifacts = make([]*ArtifactsViewItem, 0, len(arts))
for _, art := range arts {
resp.Artifacts = append(resp.Artifacts, &ArtifactsViewItem{
Name: art.ArtifactName,
Size: art.FileSize,
Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"),
})
}
}
func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) {
@@ -459,9 +529,9 @@ func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewRespon
}
var task *actions_model.ActionTask
if current.TaskID > 0 {
if effectiveTaskID := current.EffectiveTaskID(); effectiveTaskID > 0 {
var err error
task, err = actions_model.GetTaskByID(ctx, current.TaskID)
task, err = actions_model.GetTaskByID(ctx, effectiveTaskID)
if err != nil {
ctx.ServerError("actions_model.GetTaskByID", err)
return
@@ -589,13 +659,24 @@ func checkRunRerunAllowed(ctx *context_module.Context, run *actions_model.Action
return true
}
func checkLatestAttempt(ctx *context_module.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt) bool {
if attempt != nil && run.LatestAttemptID != attempt.ID {
ctx.NotFound(nil)
return false
}
return true
}
// Rerun will rerun jobs in the given run
// If jobIDStr is a blank string, it means rerun all jobs
func Rerun(ctx *context_module.Context) {
run, jobs := getCurrentRunJobsByPathParam(ctx)
run, attempt, jobs := getCurrentRunJobsByPathParam(ctx)
if ctx.Written() {
return
}
if !checkLatestAttempt(ctx, run, attempt) {
return
}
if !checkRunRerunAllowed(ctx, run) {
return
}
@@ -608,35 +689,48 @@ func Rerun(ctx *context_module.Context) {
var jobsToRerun []*actions_model.ActionRunJob
if currentJob != nil {
jobsToRerun = actions_service.GetAllRerunJobs(currentJob, jobs)
} else {
jobsToRerun = jobs
jobsToRerun = []*actions_model.ActionRunJob{currentJob}
}
if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobsToRerun); err != nil {
ctx.ServerError("RerunWorkflowRunJobs", err)
if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, jobsToRerun); err != nil {
handleWorkflowRerunError(ctx, err)
return
}
ctx.JSONOK()
ctx.JSONRedirect(run.Link())
}
// RerunFailed reruns all failed jobs in the given run
func RerunFailed(ctx *context_module.Context) {
run, jobs := getCurrentRunJobsByPathParam(ctx)
run, attempt, jobs := getCurrentRunJobsByPathParam(ctx)
if ctx.Written() {
return
}
if !checkLatestAttempt(ctx, run, attempt) {
return
}
if !checkRunRerunAllowed(ctx, run) {
return
}
if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, actions_service.GetFailedRerunJobs(jobs)); err != nil {
ctx.ServerError("RerunWorkflowRunJobs", err)
if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, actions_service.GetFailedJobsForRerun(jobs)); err != nil {
handleWorkflowRerunError(ctx, err)
return
}
ctx.JSONOK()
ctx.JSONRedirect(run.Link())
}
func handleWorkflowRerunError(ctx *context_module.Context, err error) {
if errors.Is(err, util.ErrAlreadyExist) {
ctx.JSON(http.StatusConflict, map[string]any{"message": err.Error()})
return
}
if errors.Is(err, util.ErrInvalidArgument) {
ctx.JSON(http.StatusBadRequest, map[string]any{"message": err.Error()})
return
}
ctx.ServerError("RerunWorkflowRunJobs", err)
}
func Logs(ctx *context_module.Context) {
@@ -654,10 +748,13 @@ func Logs(ctx *context_module.Context) {
}
func Cancel(ctx *context_module.Context) {
run, jobs := getCurrentRunJobsByPathParam(ctx)
run, attempt, jobs := getCurrentRunJobsByPathParam(ctx)
if ctx.Written() {
return
}
if !checkLatestAttempt(ctx, run, attempt) {
return
}
var updatedJobs []*actions_model.ActionRunJob
@@ -676,13 +773,9 @@ func Cancel(ctx *context_module.Context) {
actions_service.CreateCommitStatusForRunJobs(ctx, run, jobs...)
actions_service.EmitJobsIfReadyByJobs(updatedJobs)
for _, job := range updatedJobs {
_ = job.LoadAttributes(ctx)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
}
actions_service.NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...)
if len(updatedJobs) > 0 {
job := updatedJobs[0]
actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job)
actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, run.RepoID, run.ID)
}
ctx.JSONOK()
}
@@ -692,78 +785,14 @@ func Approve(ctx *context_module.Context) {
if ctx.Written() {
return
}
approveRuns(ctx, []int64{run.ID})
if ctx.Written() {
return
}
ctx.JSONOK()
}
func approveRuns(ctx *context_module.Context, runIDs []int64) {
doer := ctx.Doer
repo := ctx.Repo.Repository
updatedJobs := make([]*actions_model.ActionRunJob, 0)
runMap := make(map[int64]*actions_model.ActionRun, len(runIDs))
runJobs := make(map[int64][]*actions_model.ActionRunJob, len(runIDs))
err := db.WithTx(ctx, func(ctx context.Context) (err error) {
for _, runID := range runIDs {
run, err := actions_model.GetRunByRepoAndID(ctx, repo.ID, runID)
if err != nil {
return err
}
runMap[run.ID] = run
run.Repo = repo
run.NeedApproval = false
run.ApprovedBy = doer.ID
if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil {
return err
}
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
if err != nil {
return err
}
runJobs[run.ID] = jobs
for _, job := range jobs {
job.Status, err = actions_service.PrepareToStartJobWithConcurrency(ctx, job)
if err != nil {
return err
}
if job.Status == actions_model.StatusWaiting {
n, err := actions_model.UpdateRunJob(ctx, job, nil, "status")
if err != nil {
return err
}
if n > 0 {
updatedJobs = append(updatedJobs, job)
}
}
}
}
return nil
})
if err != nil {
ctx.NotFoundOrServerError("approveRuns", func(err error) bool {
if err := actions_service.ApproveRuns(ctx, ctx.Repo.Repository, ctx.Doer, []int64{run.ID}); err != nil {
ctx.NotFoundOrServerError("ApproveRuns", func(err error) bool {
return errors.Is(err, util.ErrNotExist)
}, err)
return
}
for runID, run := range runMap {
actions_service.CreateCommitStatusForRunJobs(ctx, run, runJobs[runID]...)
}
if len(updatedJobs) > 0 {
job := updatedJobs[0]
actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job)
}
for _, job := range updatedJobs {
_ = job.LoadAttributes(ctx)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
}
ctx.JSONOK()
}
func Delete(ctx *context_module.Context) {
@@ -785,28 +814,108 @@ func Delete(ctx *context_module.Context) {
ctx.JSONOK()
}
// getRunJobs loads the run and its jobs for runID
func getRunViewLink(run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt) string {
if attempt == nil || run.LatestAttemptID == attempt.ID {
return run.Link()
}
return fmt.Sprintf("%s/attempts/%d", run.Link(), attempt.Attempt)
}
// getCurrentRunJobsByPathParam resolves the current run view context from path parameters, including the run, optional attempt, and jobs to render.
// Any error will be written to the ctx, empty jobs will also result in 404 error, then the return values are all nil.
func getCurrentRunJobsByPathParam(ctx *context_module.Context) (*actions_model.ActionRun, []*actions_model.ActionRunJob) {
func getCurrentRunJobsByPathParam(ctx *context_module.Context) (*actions_model.ActionRun, *actions_model.ActionRunAttempt, []*actions_model.ActionRunJob) {
run := getCurrentRunByPathParam(ctx)
if ctx.Written() {
return nil, nil
return nil, nil, nil
}
run.Repo = ctx.Repo.Repository
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
var err error
var selectedJob *actions_model.ActionRunJob
if ctx.PathParam("job") != "" {
jobID := ctx.PathParamInt64("job")
selectedJob, err = actions_model.GetRunJobByRunAndID(ctx, run.ID, jobID)
if err != nil {
ctx.NotFoundOrServerError("GetRunJobByRepoAndID", func(err error) bool {
return errors.Is(err, util.ErrNotExist)
}, err)
return nil, nil, nil
}
}
// Resolve the attempt to display.
// Priority: explicit path param (/attempts/:num) > job's attempt (when navigating to a specific job) > latest attempt.
// attempt may be nil for legacy runs that pre-date ActionRunAttempt; callers must handle that case.
attemptNum := ctx.PathParamInt64("attempt")
var attempt *actions_model.ActionRunAttempt
switch {
case attemptNum > 0:
// Explicit attempt number in the URL — user is viewing a historical attempt.
attempt, err = actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, run.ID, attemptNum)
if err != nil {
ctx.NotFoundOrServerError("GetRunAttemptByRunIDAndAttempt", func(err error) bool {
return errors.Is(err, util.ErrNotExist)
}, err)
return nil, nil, nil
}
case selectedJob != nil && selectedJob.RunAttemptID > 0:
// No explicit attempt in the URL, but the requested job belongs to a known attempt — resolve via the job.
attempt, err = actions_model.GetRunAttemptByRepoAndID(ctx, selectedJob.RepoID, selectedJob.RunAttemptID)
if err != nil {
ctx.NotFoundOrServerError("GetRunAttemptByRepoAndID", func(err error) bool {
return errors.Is(err, util.ErrNotExist)
}, err)
return nil, nil, nil
}
default:
// No attempt context at all — show the latest attempt (nil for legacy runs).
attempt, _, err = run.GetLatestAttempt(ctx)
if err != nil {
ctx.NotFoundOrServerError("GetLatestAttempt", func(err error) bool {
return errors.Is(err, util.ErrNotExist)
}, err)
return nil, nil, nil
}
}
// Resolve the jobs for the resolved attempt.
// When attempt is nil (legacy run or legacy job), jobs are stored with run_attempt_id=0.
var resolvedAttemptID int64
if attempt != nil {
resolvedAttemptID = attempt.ID
}
jobs, err := actions_model.GetRunJobsByRunAndAttemptID(ctx, run.ID, resolvedAttemptID)
if err != nil {
ctx.ServerError("GetRunJobsByRunID", err)
return nil, nil
ctx.ServerError("get current jobs", err)
return nil, nil, nil
}
if len(jobs) == 0 {
ctx.NotFound(nil)
return nil, nil
return nil, nil, nil
}
for _, job := range jobs {
job.Run = run
}
return run, jobs
return run, attempt, jobs
}
// resolveArtifactAttemptIDFromQuery resolves the run_attempt_id used to scope artifact lookups.
// If the `attempt` query parameter is present and valid, it returns the matching attempt's ID.
// Otherwise it falls back to run.LatestAttemptID, which is 0 only for legacy runs created before ActionRunAttempt existed.
func resolveArtifactAttemptIDFromQuery(ctx *context_module.Context, run *actions_model.ActionRun) (int64, error) {
if ctx.FormString("attempt") == "" {
return run.LatestAttemptID, nil
}
attemptNum := ctx.FormInt64("attempt")
if attemptNum <= 0 {
return 0, util.ErrNotExist
}
attempt, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, run.ID, attemptNum)
if err != nil {
return 0, err
}
return attempt.ID, nil
}
func ArtifactsDeleteView(ctx *context_module.Context) {
@@ -814,9 +923,16 @@ func ArtifactsDeleteView(ctx *context_module.Context) {
if ctx.Written() {
return
}
resolvedAttemptID, err := resolveArtifactAttemptIDFromQuery(ctx, run)
if err != nil {
ctx.NotFoundOrServerError("resolveArtifactAttemptIDFromQuery", func(err error) bool {
return errors.Is(err, util.ErrNotExist)
}, err)
return
}
artifactName := ctx.PathParam("artifact_name")
if err := actions_model.SetArtifactNeedDelete(ctx, run.ID, artifactName); err != nil {
ctx.ServerError("SetArtifactNeedDelete", err)
if err := actions_model.SetArtifactNeedDeleteByRunAttempt(ctx, run.ID, resolvedAttemptID, artifactName); err != nil {
ctx.ServerError("SetArtifactNeedDeleteByRunAttempt", err)
return
}
ctx.JSON(http.StatusOK, struct{}{})
@@ -827,14 +943,17 @@ func ArtifactsDownloadView(ctx *context_module.Context) {
if ctx.Written() {
return
}
artifactName := ctx.PathParam("artifact_name")
artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{
RunID: run.ID,
ArtifactName: artifactName,
})
resolvedAttemptID, err := resolveArtifactAttemptIDFromQuery(ctx, run)
if err != nil {
ctx.ServerError("FindArtifacts", err)
ctx.NotFoundOrServerError("resolveArtifactAttemptIDFromQuery", func(err error) bool {
return errors.Is(err, util.ErrNotExist)
}, err)
return
}
artifactName := ctx.PathParam("artifact_name")
artifacts, err := actions_model.GetArtifactsByRunAttemptAndName(ctx, run.ID, resolvedAttemptID, artifactName)
if err != nil {
ctx.ServerError("GetArtifactsByRunAttemptAndName", err)
return
}
if len(artifacts) == 0 {
@@ -931,8 +1050,10 @@ func ApproveAllChecks(ctx *context_module.Context) {
return
}
approveRuns(ctx, runIDs)
if ctx.Written() {
if err := actions_service.ApproveRuns(ctx, repo, ctx.Doer, runIDs); err != nil {
ctx.NotFoundOrServerError("ApproveRuns", func(err error) bool {
return errors.Is(err, util.ErrNotExist)
}, err)
return
}