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

@@ -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")

View File

@@ -20,6 +20,7 @@ import (
"code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
)
@@ -257,10 +258,11 @@ func listOrderedChunksForArtifact(st storage.ObjectStorage, runID, artifactID in
return emptyListAsError(chunks)
}
func mergeChunksForRun(ctx *ArtifactContext, st storage.ObjectStorage, runID int64, artifactName string) error {
func mergeChunksForRun(ctx *ArtifactContext, st storage.ObjectStorage, runID, runAttemptID int64, artifactName string) error {
// read all db artifacts by name
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
RunID: runID,
RunAttemptID: optional.Some(runAttemptID),
ArtifactName: artifactName,
})
if err != nil {

View File

@@ -107,6 +107,7 @@ import (
"code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/util"
@@ -266,9 +267,9 @@ func (r *artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (*
return task, artifactName, true
}
func (r *artifactV4Routes) getArtifactByName(ctx *ArtifactContext, runID int64, name string) (*actions_model.ActionArtifact, error) {
func (r *artifactV4Routes) getArtifactByName(ctx *ArtifactContext, runID, runAttemptID int64, name string) (*actions_model.ActionArtifact, error) {
var art actions_model.ActionArtifact
has, err := db.GetEngine(ctx).Where(builder.Eq{"run_id": runID, "artifact_name": name}, builder.Like{"content_encoding", "%/%"}).Get(&art)
has, err := db.GetEngine(ctx).Where(builder.Eq{"run_id": runID, "run_attempt_id": runAttemptID, "artifact_name": name}, builder.Like{"content_encoding", "%/%"}).Get(&art)
if err != nil {
return nil, err
} else if !has {
@@ -388,7 +389,7 @@ func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) {
switch comp {
case "block", "appendBlock":
// get artifact by name
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, artifactName)
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName)
if err != nil {
log.Error("Error artifact not found: %v", err)
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
@@ -475,7 +476,7 @@ func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) {
}
// get artifact by name
artifact, err := r.getArtifactByName(ctx, runID, req.Name)
artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name)
if err != nil {
log.Error("Error artifact not found: %v", err)
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
@@ -589,6 +590,7 @@ func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) {
artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{
RunID: runID,
RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID),
Status: int(actions_model.ArtifactStatusUploadConfirmed),
FinalizedArtifactsV4: true,
})
@@ -642,7 +644,7 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) {
artifactName := req.Name
// get artifact by name
artifact, err := r.getArtifactByName(ctx, runID, artifactName)
artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, artifactName)
if err != nil {
log.Error("Error artifact not found: %v", err)
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
@@ -676,7 +678,7 @@ func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) {
}
// get artifact by name
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, artifactName)
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName)
if err != nil {
log.Error("Error artifact not found: %v", err)
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
@@ -707,14 +709,14 @@ func (r *artifactV4Routes) deleteArtifact(ctx *ArtifactContext) {
}
// get artifact by name
artifact, err := r.getArtifactByName(ctx, runID, req.Name)
artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name)
if err != nil {
log.Error("Error artifact not found: %v", err)
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
return
}
err = actions_model.SetArtifactNeedDelete(ctx, runID, req.Name)
err = actions_model.SetArtifactNeedDeleteByRunAttempt(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name)
if err != nil {
log.Error("Error deleting artifacts: %v", err)
ctx.HTTPError(http.StatusInternalServerError, err.Error())

View File

@@ -15,7 +15,6 @@ import (
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
actions_service "code.gitea.io/gitea/services/actions"
notify_service "code.gitea.io/gitea/services/notify"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"code.gitea.io/actions-proto-go/runner/v1/runnerv1connect"
@@ -224,7 +223,7 @@ func (s *Service) UpdateTask(
actions_service.CreateCommitStatusForRunJobs(ctx, task.Job.Run, task.Job)
if task.Status.IsDone() {
notify_service.WorkflowJobStatusUpdate(ctx, task.Job.Run.Repo, task.Job.Run.TriggerUser, task.Job, task)
actions_service.NotifyWorkflowJobStatusUpdateWithTask(ctx, task.Job, task)
}
if req.Msg.State.Result != runnerv1.Result_RESULT_UNSPECIFIED {
@@ -232,7 +231,7 @@ func (s *Service) UpdateTask(
log.Error("Emit ready jobs of run %d: %v", task.Job.RunID, err)
}
if task.Job.Run.Status.IsDone() {
actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, task.Job)
actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, task.Job.RepoID, task.Job.RunID)
}
}

View File

@@ -37,7 +37,7 @@ func ListWorkflowJobs(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"
shared.ListJobs(ctx, 0, 0, 0)
shared.ListJobs(ctx, 0, 0, 0, nil)
}
// ListWorkflowRuns Lists all runs

View File

@@ -1255,6 +1255,10 @@ func Routes() *web.Router {
m.Group("/runs", func() {
m.Group("/{run}", func() {
m.Get("", repo.GetWorkflowRun)
m.Group("/attempts/{attempt}", func() {
m.Get("", repo.GetWorkflowRunAttempt)
m.Get("/jobs", repo.ListWorkflowRunAttemptJobs)
})
m.Delete("", reqToken(), reqRepoWriter(unit.TypeActions), repo.DeleteActionRun)
m.Post("/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowRun)
m.Post("/rerun-failed-jobs", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunFailedWorkflowRun)

View File

@@ -624,7 +624,7 @@ func (Action) ListWorkflowJobs(ctx *context.APIContext) {
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
shared.ListJobs(ctx, ctx.Org.Organization.ID, 0, 0)
shared.ListJobs(ctx, ctx.Org.Organization.ID, 0, 0, nil)
}
func (Action) ListWorkflowRuns(ctx *context.APIContext) {

View File

@@ -23,6 +23,7 @@ import (
secret_model "code.gitea.io/gitea/models/secret"
"code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
@@ -676,7 +677,7 @@ func (Action) UpdateRunner(ctx *context.APIContext) {
shared.UpdateRunner(ctx, 0, ctx.Repo.Repository.ID, ctx.PathParamInt64("runner_id"))
}
// GetWorkflowRunJobs Lists all jobs for a workflow run.
// ListWorkflowJobs Lists all jobs for a repository.
func (Action) ListWorkflowJobs(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/actions/jobs repository listWorkflowJobs
// ---
@@ -717,7 +718,7 @@ func (Action) ListWorkflowJobs(ctx *context.APIContext) {
repoID := ctx.Repo.Repository.ID
shared.ListJobs(ctx, 0, repoID, 0)
shared.ListJobs(ctx, 0, repoID, 0, nil)
}
// ListWorkflowRuns Lists all runs for a repository run.
@@ -1163,7 +1164,7 @@ func getCurrentRepoActionRunJobsByID(ctx *context.APIContext) (*actions_model.Ac
return nil, nil
}
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID)
if err != nil {
ctx.APIErrorInternal(err)
return nil, nil
@@ -1171,6 +1172,24 @@ func getCurrentRepoActionRunJobsByID(ctx *context.APIContext) (*actions_model.Ac
return run, jobs
}
func getCurrentRepoActionRunAttemptByNumber(ctx *context.APIContext) (*actions_model.ActionRun, *actions_model.ActionRunAttempt) {
run := getCurrentRepoActionRunByID(ctx)
if ctx.Written() {
return nil, nil
}
attemptNum := ctx.PathParamInt64("attempt")
attempt, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, run.ID, attemptNum)
if errors.Is(err, util.ErrNotExist) {
ctx.APIErrorNotFound(err)
return nil, nil
} else if err != nil {
ctx.APIErrorInternal(err)
return nil, nil
}
return run, attempt
}
// GetWorkflowRun Gets a specific workflow run.
func GetWorkflowRun(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run} repository GetWorkflowRun
@@ -1207,7 +1226,56 @@ func GetWorkflowRun(ctx *context.APIContext) {
return
}
convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run)
convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run, nil)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, convertedRun)
}
// GetWorkflowRunAttempt Gets a specific workflow run attempt.
func GetWorkflowRunAttempt(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/attempts/{attempt} repository getWorkflowRunAttempt
// ---
// summary: Gets a specific workflow run attempt
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repository
// type: string
// required: true
// - name: run
// in: path
// description: id of the run
// type: integer
// required: true
// - name: attempt
// in: path
// description: logical attempt number of the run
// type: integer
// required: true
// responses:
// "200":
// "$ref": "#/responses/WorkflowRun"
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
run, attempt := getCurrentRepoActionRunAttemptByNumber(ctx)
if ctx.Written() {
return
}
convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run, attempt)
if err != nil {
ctx.APIErrorInternal(err)
return
@@ -1247,6 +1315,8 @@ func RerunWorkflowRun(ctx *context.APIContext) {
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "409":
// "$ref": "#/responses/error"
// "422":
// "$ref": "#/responses/validationError"
@@ -1255,12 +1325,12 @@ func RerunWorkflowRun(ctx *context.APIContext) {
return
}
if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs); err != nil {
if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, jobs); err != nil {
handleWorkflowRerunError(ctx, err)
return
}
convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run)
convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run, nil)
if err != nil {
ctx.APIErrorInternal(err)
return
@@ -1298,6 +1368,8 @@ func RerunFailedWorkflowRun(ctx *context.APIContext) {
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "409":
// "$ref": "#/responses/error"
// "422":
// "$ref": "#/responses/validationError"
@@ -1306,7 +1378,7 @@ func RerunFailedWorkflowRun(ctx *context.APIContext) {
return
}
if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, actions_service.GetFailedRerunJobs(jobs)); err != nil {
if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, actions_service.GetFailedJobsForRerun(jobs)); err != nil {
handleWorkflowRerunError(ctx, err)
return
}
@@ -1351,6 +1423,8 @@ func RerunWorkflowJob(ctx *context.APIContext) {
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "409":
// "$ref": "#/responses/error"
// "422":
// "$ref": "#/responses/validationError"
@@ -1367,12 +1441,28 @@ func RerunWorkflowJob(ctx *context.APIContext) {
}
targetJob := jobs[jobIdx]
if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, actions_service.GetAllRerunJobs(targetJob, jobs)); err != nil {
newAttempt, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, []*actions_model.ActionRunJob{targetJob})
if err != nil {
handleWorkflowRerunError(ctx, err)
return
}
convertedJob, err := convert.ToActionWorkflowJob(ctx, ctx.Repo.Repository, nil, targetJob)
// Legacy jobs had AttemptJobID=0 before the rerun; createOriginalAttemptForLegacyRun inside
// RerunWorkflowRunJobs has since backfilled it in the DB, so reload only in that case.
if targetJob.AttemptJobID == 0 {
targetJob, err = actions_model.GetRunJobByRepoAndID(ctx, run.RepoID, targetJob.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
}
rerunJob, err := actions_model.GetRunJobByAttemptJobID(ctx, run.ID, newAttempt.ID, targetJob.AttemptJobID)
if err != nil {
handleWorkflowRerunError(ctx, err)
return
}
convertedJob, err := convert.ToActionWorkflowJob(ctx, ctx.Repo.Repository, nil, rerunJob)
if err != nil {
ctx.APIErrorInternal(err)
return
@@ -1384,6 +1474,12 @@ func handleWorkflowRerunError(ctx *context.APIContext, err error) {
if errors.Is(err, util.ErrInvalidArgument) {
ctx.APIError(http.StatusBadRequest, err)
return
} else if errors.Is(err, util.ErrAlreadyExist) {
ctx.APIError(http.StatusConflict, err)
return
} else if errors.Is(err, util.ErrNotExist) {
ctx.APIError(http.StatusNotFound, err)
return
}
ctx.APIErrorInternal(err)
}
@@ -1440,9 +1536,75 @@ func ListWorkflowRunJobs(ctx *context.APIContext) {
return
}
run, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.APIErrorNotFound(err)
} else {
ctx.APIErrorInternal(err)
}
return
}
// runID is used as an additional filter next to repoID to ensure that we only list jobs for the specified repoID and runID.
// no additional checks for runID are needed here
shared.ListJobs(ctx, 0, repoID, runID)
shared.ListJobs(ctx, 0, repoID, runID, optional.Some(run.LatestAttemptID))
}
// ListWorkflowRunAttemptJobs Lists all jobs for a workflow run attempt.
func ListWorkflowRunAttemptJobs(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/attempts/{attempt}/jobs repository listWorkflowRunAttemptJobs
// ---
// summary: Lists all jobs for a workflow run attempt
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repository
// type: string
// required: true
// - name: run
// in: path
// description: id of the workflow run
// type: integer
// required: true
// - name: attempt
// in: path
// description: logical attempt number of the run
// type: integer
// required: true
// - name: status
// in: query
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
// type: string
// required: false
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/WorkflowJobsList"
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
run, attempt := getCurrentRepoActionRunAttemptByNumber(ctx)
if ctx.Written() {
return
}
shared.ListJobs(ctx, 0, run.RepoID, run.ID, optional.Some(attempt.ID))
}
// GetWorkflowJob Gets a specific workflow job for a workflow run.
@@ -1758,7 +1920,7 @@ func DeleteArtifact(ctx *context.APIContext) {
}
if actions.IsArtifactV4(art) {
if err := actions_model.SetArtifactNeedDelete(ctx, art.RunID, art.ArtifactName); err != nil {
if err := actions_model.SetArtifactNeedDeleteByID(ctx, art.ID); err != nil {
ctx.APIErrorInternal(err)
return
}

View File

@@ -12,6 +12,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/webhook"
@@ -27,8 +28,9 @@ import (
// ownerID != 0 and repoID != 0 undefined behavior
// runID == 0 means all jobs
// runID is used as an additional filter together with ownerID and repoID to only return jobs for the given run
// runAttemptID, when set, additionally limits the result to jobs of the specified run attempt. Only takes effect when runID > 0.
// Access rights are checked at the API route level
func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64) {
func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64, runAttemptID optional.Option[int64]) {
if ownerID != 0 && repoID != 0 {
setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
}
@@ -39,6 +41,9 @@ func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64) {
RunID: runID,
ListOptions: listOptions,
}
if runID > 0 {
opts.RunAttemptID = runAttemptID
}
for _, status := range ctx.FormStrings("status") {
values, err := convertToInternal(status)
if err != nil {
@@ -178,7 +183,7 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64) {
}
}
convertedRun, err := convert.ToActionWorkflowRun(ctx, repository, runs[i])
convertedRun, err := convert.ToActionWorkflowRun(ctx, repository, runs[i], nil)
if err != nil {
ctx.APIErrorInternal(err)
return

View File

@@ -439,5 +439,5 @@ func ListWorkflowJobs(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"
shared.ListJobs(ctx, ctx.Doer.ID, 0, 0)
shared.ListJobs(ctx, ctx.Doer.ID, 0, 0, nil)
}

View File

@@ -31,7 +31,8 @@ func DownloadActionsRunJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository
return util.NewNotExistErrorf("job not found")
}
if curJob.TaskID == 0 {
taskID := curJob.EffectiveTaskID()
if taskID == 0 {
return util.NewNotExistErrorf("job not started")
}
@@ -39,7 +40,7 @@ func DownloadActionsRunJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository
return fmt.Errorf("LoadRun: %w", err)
}
task, err := actions_model.GetTaskByID(ctx, curJob.TaskID)
task, err := actions_model.GetTaskByID(ctx, taskID)
if err != nil {
return fmt.Errorf("GetTaskByID: %w", err)
}

View File

@@ -4,6 +4,7 @@
package devtest
import (
"fmt"
mathRand "math/rand/v2"
"net/http"
"slices"
@@ -12,7 +13,9 @@ import (
"time"
actions_model "code.gitea.io/gitea/models/actions"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/web/repo/actions"
@@ -59,13 +62,18 @@ func generateMockStepsLog(logCur actions.LogCursor, opts generateMockStepsLogOpt
}
func MockActionsView(ctx *context.Context) {
ctx.Data["RunID"] = ctx.PathParamInt64("run")
if runID := ctx.PathParamInt64("run"); runID == 0 {
ctx.Redirect("/repo-action-view/runs/10")
return
}
ctx.Data["JobID"] = ctx.PathParamInt64("job")
ctx.Data["ActionsViewURL"] = ctx.Req.URL.Path
ctx.HTML(http.StatusOK, "devtest/repo-action-view")
}
func MockActionsRunsJobs(ctx *context.Context) {
runID := ctx.PathParamInt64("run")
attemptID := ctx.PathParamInt64("attempt")
alignTime := func(v, unit int64) int64 {
return (v + unit) / unit * unit
@@ -74,16 +82,9 @@ func MockActionsRunsJobs(ctx *context.Context) {
resp.State.Run.RepoID = 12345
resp.State.Run.TitleHTML = `mock run title <a href="/">link</a>`
resp.State.Run.Link = setting.AppSubURL + "/devtest/repo-action-view/runs/" + strconv.FormatInt(runID, 10)
resp.State.Run.Status = actions_model.StatusRunning.String()
resp.State.Run.CanCancel = runID == 10
resp.State.Run.CanApprove = runID == 20
resp.State.Run.CanRerun = runID == 30
resp.State.Run.CanRerunFailed = runID == 30
resp.State.Run.CanDeleteArtifact = true
resp.State.Run.WorkflowID = "workflow-id"
resp.State.Run.WorkflowLink = "./workflow-link"
resp.State.Run.Duration = "1h 23m 45s"
resp.State.Run.TriggeredAt = time.Now().Add(-time.Hour).Unix()
resp.State.Run.TriggerEvent = "push"
resp.State.Run.Commit = actions.ViewCommit{
ShortSha: "ccccdddd",
@@ -98,6 +99,88 @@ func MockActionsRunsJobs(ctx *context.Context) {
IsDeleted: false,
},
}
now := time.Now()
currentAttemptNum := int64(1)
if attemptID > 0 {
currentAttemptNum = attemptID
}
user2 := &user_model.User{Name: "user2"}
user3 := &user_model.User{Name: "user3"}
attempts := []*actions_model.ActionRunAttempt{{
Attempt: 1,
Status: actions_model.StatusSuccess,
Created: timeutil.TimeStamp(now.Add(-time.Hour).Unix()),
TriggerUserID: 2,
TriggerUser: user2,
}}
if runID == 10 {
attempts = []*actions_model.ActionRunAttempt{
{
Attempt: 3,
Status: actions_model.StatusSuccess,
Created: timeutil.TimeStamp(alignTime(now.Add(-time.Hour).Unix(), 3600)),
TriggerUserID: 2,
TriggerUser: user2,
},
{
Attempt: 2,
Status: actions_model.StatusFailure,
Created: timeutil.TimeStamp(alignTime(now.Add(-2*time.Hour).Unix(), 3600)),
TriggerUserID: 1,
TriggerUser: user3,
},
{
Attempt: 1,
Status: actions_model.StatusSuccess,
Created: timeutil.TimeStamp(alignTime(now.Add(-3*time.Hour).Unix(), 3600)),
TriggerUserID: 2,
TriggerUser: user2,
},
}
if attemptID == 0 {
currentAttemptNum = 3
}
}
latestAttempt := attempts[0]
resp.State.Run.RunAttempt = currentAttemptNum
resp.State.Run.Done = latestAttempt.Status.IsDone()
resp.State.Run.Status = latestAttempt.Status.String()
resp.State.Run.Duration = "1h 23m 45s"
resp.State.Run.TriggeredAt = latestAttempt.Created.AsTime().Unix()
resp.State.Run.ViewLink = resp.State.Run.Link
for _, attempt := range attempts {
link := resp.State.Run.Link
if attempt.Attempt != latestAttempt.Attempt {
link = fmt.Sprintf("%s/attempts/%d", resp.State.Run.Link, attempt.Attempt)
}
current := attempt.Attempt == currentAttemptNum
if current {
resp.State.Run.Status = attempt.Status.String()
resp.State.Run.Done = attempt.Status.IsDone()
resp.State.Run.TriggeredAt = attempt.Created.AsTime().Unix()
if attempt.Attempt != latestAttempt.Attempt {
resp.State.Run.ViewLink = link
}
}
resp.State.Run.Attempts = append(resp.State.Run.Attempts, &actions.ViewRunAttempt{
Attempt: attempt.Attempt,
Status: attempt.Status.String(),
Done: attempt.Status.IsDone(),
Link: link,
Current: current,
Latest: attempt.Attempt == latestAttempt.Attempt,
TriggeredAt: attempt.Created.AsTime().Unix(),
TriggerUserName: attempt.TriggerUser.GetDisplayName(),
TriggerUserLink: attempt.TriggerUser.HomeLink(),
})
}
isLatestAttempt := currentAttemptNum == latestAttempt.Attempt
resp.State.Run.CanCancel = runID == 10 && isLatestAttempt
resp.State.Run.CanApprove = runID == 20 && isLatestAttempt
resp.State.Run.CanRerun = runID == 30 && isLatestAttempt
resp.State.Run.CanRerunFailed = runID == 30 && isLatestAttempt
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
Name: "artifact-a",
Size: 100 * 1024,
@@ -123,8 +206,13 @@ func MockActionsRunsJobs(ctx *context.Context) {
ExpiresUnix: 0,
})
jobLink := func(jobID int64) string {
return fmt.Sprintf("%s/jobs/%d", resp.State.Run.Link, jobID)
}
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
ID: runID * 10,
Link: jobLink(runID * 10),
JobID: "job-100",
Name: "job 100 (testsubname)",
Status: actions_model.StatusRunning.String(),
@@ -133,6 +221,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
})
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
ID: runID*10 + 1,
Link: jobLink(runID*10 + 1),
JobID: "job-101",
Name: "job 101",
Status: actions_model.StatusWaiting.String(),
@@ -142,6 +231,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
})
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
ID: runID*10 + 2,
Link: jobLink(runID*10 + 2),
JobID: "job-102",
Name: "ULTRA LOOOOOOOOOOOONG job name 102 that exceeds the limit",
Status: actions_model.StatusFailure.String(),
@@ -151,6 +241,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
})
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
ID: runID*10 + 3,
Link: jobLink(runID*10 + 3),
JobID: "job-103",
Name: "job 103",
Status: actions_model.StatusCancelled.String(),
@@ -162,8 +253,10 @@ func MockActionsRunsJobs(ctx *context.Context) {
// add more jobs to a run for UI testing
if resp.State.Run.CanCancel {
for i := range 10 {
jobID := runID*1000 + int64(i)
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
ID: runID*1000 + int64(i),
ID: jobID,
Link: jobLink(jobID),
JobID: "job-dup-test-" + strconv.Itoa(i),
Name: "job dup test " + strconv.Itoa(i),
Status: actions_model.StatusSuccess.String(),
@@ -184,6 +277,14 @@ func fillViewRunResponseCurrentJob(ctx *context.Context, resp *actions.ViewRespo
return
}
for _, job := range resp.State.Run.Jobs {
if job.ID == jobID {
resp.State.CurrentJob.Title = job.Name
resp.State.CurrentJob.Detail = job.Status
break
}
}
req := web.GetForm(ctx).(*actions.ViewRequest)
var mockLogOptions []generateMockStepsLogOptions
resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{

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
}

View File

@@ -1539,6 +1539,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Combo("").
Get(actions.View).
Post(web.Bind(actions.ViewRequest{}), actions.ViewPost)
m.Group("/attempts/{attempt}", func() {
m.Combo("").
Get(actions.View).
Post(web.Bind(actions.ViewRequest{}), actions.ViewPost)
})
m.Group("/jobs/{job}", func() {
m.Combo("").
Get(actions.View).
@@ -1754,8 +1759,10 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Any("/mail-preview/*", devtest.MailPreviewRender)
m.Any("/{sub}", devtest.TmplCommon)
m.Get("/repo-action-view/runs/{run}", devtest.MockActionsView)
m.Get("/repo-action-view/runs/{run}/attempts/{attempt}", devtest.MockActionsView)
m.Get("/repo-action-view/runs/{run}/jobs/{job}", devtest.MockActionsView)
m.Post("/repo-action-view/runs/{run}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)
m.Post("/repo-action-view/runs/{run}/attempts/{attempt}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)
m.Post("/repo-action-view/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)
})
}