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

@@ -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
}

View File

@@ -136,7 +136,7 @@ jobs:
runID = run.ID
}
jobs, err := actions_model.GetRunJobsByRunID(t.Context(), runID)
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(t.Context(), apiRepo.ID, runID)
assert.NoError(t, err)
for i := 0; i < len(testCase.outcomes); i++ {

View File

@@ -15,6 +15,7 @@ import (
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
@@ -22,6 +23,7 @@ import (
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/timeutil"
actions_service "code.gitea.io/gitea/services/actions"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
@@ -759,3 +761,161 @@ func getTaskJobNameByTaskID(t *testing.T, authToken, ownerName, repoName string,
}
return ""
}
// TestLegacyRunsInCronTasks verifies that the background cron tasks correctly handle runs/jobs
// created before migration v331 (legacy data with LatestAttemptID=0 and jobs with RunAttemptID=0).
func TestLegacyRunsInCronTasks(t *testing.T) {
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiRepo := createActionsTestRepo(t, token, "actions-legacy-cron", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(httpContext)(t)
// Far-past timestamp so the queries match regardless of the configured timeouts.
oldTS := timeutil.TimeStamp(time.Now().Add(-30 * 24 * time.Hour).Unix())
// insertLegacyRunJob inserts a run + job without an ActionRunAttempt record, simulating data created before migration v331 (LatestAttemptID=0, job.RunAttemptID=0, job.AttemptJobID=0).
insertLegacyRunJob := func(t *testing.T, index int64, runStatus, jobStatus actions_model.Status) (*actions_model.ActionRun, *actions_model.ActionRunJob) {
t.Helper()
run := &actions_model.ActionRun{
Title: fmt.Sprintf("legacy run %d", index),
RepoID: repo.ID,
OwnerID: repo.OwnerID,
WorkflowID: fmt.Sprintf("legacy-%d.yml", index),
Index: index,
TriggerUserID: user2.ID,
Ref: "refs/heads/" + repo.DefaultBranch,
CommitSHA: "0000000000000000000000000000000000000000",
Event: "workflow_dispatch",
TriggerEvent: "workflow_dispatch",
EventPayload: "{}",
Status: runStatus,
}
require.NoError(t, db.Insert(t.Context(), run))
job := &actions_model.ActionRunJob{
RunID: run.ID,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: run.CommitSHA,
Name: "legacy-job",
Attempt: 1,
JobID: "legacy-job",
RunsOn: []string{"ubuntu-latest"},
Status: jobStatus,
RunAttemptID: 0,
AttemptJobID: 0,
}
require.NoError(t, db.Insert(t.Context(), job))
// backfill timestamps so the cron task queries can match them.
_, err := db.GetEngine(t.Context()).Exec("UPDATE action_run SET created=?, updated=? WHERE id=?", int64(oldTS), int64(oldTS), run.ID)
require.NoError(t, err)
_, err = db.GetEngine(t.Context()).Exec("UPDATE action_run_job SET created=?, updated=? WHERE id=?", int64(oldTS), int64(oldTS), job.ID)
require.NoError(t, err)
run.Created, run.Updated = oldTS, oldTS
job.Created, job.Updated = oldTS, oldTS
return run, job
}
t.Run("StopZombieTasks", func(t *testing.T) {
run, job := insertLegacyRunJob(t, 10, actions_model.StatusRunning, actions_model.StatusRunning)
task := &actions_model.ActionTask{
JobID: job.ID,
Attempt: 1,
Status: actions_model.StatusRunning,
Started: oldTS,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: run.CommitSHA,
}
task.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), task))
_, err := db.GetEngine(t.Context()).Exec("UPDATE action_task SET updated=? WHERE id=?", int64(oldTS), task.ID)
require.NoError(t, err)
job.TaskID = task.ID
_, err = db.GetEngine(t.Context()).ID(job.ID).Cols("task_id").Update(job)
require.NoError(t, err)
require.NoError(t, actions_service.StopZombieTasks(t.Context()))
gotTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.ID})
assert.Equal(t, actions_model.StatusFailure, gotTask.Status)
gotJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
assert.Equal(t, actions_model.StatusFailure, gotJob.Status)
gotRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.Equal(t, actions_model.StatusFailure, gotRun.Status)
})
t.Run("StopEndlessTasks", func(t *testing.T) {
run, job := insertLegacyRunJob(t, 20, actions_model.StatusRunning, actions_model.StatusRunning)
task := &actions_model.ActionTask{
JobID: job.ID,
Attempt: 1,
Status: actions_model.StatusRunning,
Started: oldTS,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: run.CommitSHA,
}
task.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), task))
job.TaskID = task.ID
_, err := db.GetEngine(t.Context()).ID(job.ID).Cols("task_id").Update(job)
require.NoError(t, err)
require.NoError(t, actions_service.StopEndlessTasks(t.Context()))
gotTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.ID})
assert.Equal(t, actions_model.StatusFailure, gotTask.Status)
gotJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
assert.Equal(t, actions_model.StatusFailure, gotJob.Status)
gotRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.Equal(t, actions_model.StatusFailure, gotRun.Status)
})
t.Run("CancelAbandonedJobs", func(t *testing.T) {
run, job := insertLegacyRunJob(t, 30, actions_model.StatusWaiting, actions_model.StatusWaiting)
require.NoError(t, actions_service.CancelAbandonedJobs(t.Context()))
gotJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
assert.Equal(t, actions_model.StatusCancelled, gotJob.Status)
gotRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.Equal(t, actions_model.StatusCancelled, gotRun.Status)
})
t.Run("Cleanup", func(t *testing.T) {
run, _ := insertLegacyRunJob(t, 40, actions_model.StatusSuccess, actions_model.StatusSuccess)
expiredArtifact := &actions_model.ActionArtifact{
RunID: run.ID,
RunAttemptID: 0, // legacy artifact
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: run.CommitSHA,
StoragePath: fmt.Sprintf("artifacts/legacy-expired-%d.zip", run.ID),
FileSize: 1,
FileCompressedSize: 1,
ContentEncodingOrType: actions_model.ContentTypeZip,
ArtifactPath: "legacy-expired.zip",
ArtifactName: "legacy-expired",
Status: actions_model.ArtifactStatusUploadConfirmed,
ExpiredUnix: oldTS,
}
require.NoError(t, db.Insert(t.Context(), expiredArtifact))
require.NoError(t, actions_service.Cleanup(t.Context()))
gotArtifact := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionArtifact{ID: expiredArtifact.ID})
assert.Equal(t, actions_model.ArtifactStatusExpired, gotArtifact.Status)
})
})
}

View File

@@ -11,6 +11,7 @@ import (
"testing"
"time"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
@@ -214,5 +215,95 @@ jobs:
resetFunc()
})
}
t.Run("DownloadRerunTaskLogs", func(t *testing.T) {
treePath := ".gitea/workflows/download-rerun-logs.yml"
fileContent := `name: download-rerun-logs
on:
push:
paths:
- '.gitea/workflows/download-rerun-logs.yml'
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo job1
job2:
runs-on: ubuntu-latest
needs: [job1]
steps:
- run: echo job2
`
// create the workflow file
opts := getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "create "+treePath, fileContent)
createWorkflowFile(t, token, user2.Name, repo.Name, treePath, opts)
// first run
job1Task1 := runner.fetchTask(t)
_, job1, _ := getTaskAndJobAndRunByTaskID(t, job1Task1.Id)
runner.execTask(t, job1Task1, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
logRows: []*runnerv1.LogRow{
{
Time: timestamppb.New(now.Add(1 * time.Second)),
Content: "job1 first run",
},
},
})
job2Task1 := runner.fetchTask(t)
_, job2, run := getTaskAndJobAndRunByTaskID(t, job2Task1.Id)
runner.execTask(t, job2Task1, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
logRows: []*runnerv1.LogRow{
{
Time: timestamppb.New(now.Add(1 * time.Second)),
Content: "job2 first run",
},
},
})
// check job1 log
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job1.ID)).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "job1 first run")
// check job2 log
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job2.ID)).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "job2 first run")
// only rerun job2
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, run.ID, job2.ID))
session.MakeRequest(t, req, http.StatusOK)
job2TaskRerun := runner.fetchTask(t)
runner.execTask(t, job2TaskRerun, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
logRows: []*runnerv1.LogRow{
{
Time: timestamppb.New(now.Add(1 * time.Second)),
Content: "job2 rerun",
},
},
})
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
job1Rerun := getLatestAttemptJobByTemplateJobID(t, run.ID, job1.ID)
assert.Equal(t, run.LatestAttemptID, job1Rerun.RunAttemptID)
job2Rerun := getLatestAttemptJobByTemplateJobID(t, run.ID, job2.ID)
assert.Equal(t, run.LatestAttemptID, job2Rerun.RunAttemptID)
// check job1 rerun log
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job1Rerun.ID)).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "job1 first run") // should return the log of first run because job1 didn't rerun
// check job2 rerun log
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job2Rerun.ID)).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "job2 rerun")
})
})
}

View File

@@ -7,18 +7,33 @@ import (
"fmt"
"net/http"
"net/url"
"strings"
"testing"
"time"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/actions/jobparser"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/timeutil"
actions_web "code.gitea.io/gitea/routers/web/repo/actions"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestActionsRerun(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
userAdmin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
sessionAdmin := loginUser(t, userAdmin.Name)
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
@@ -54,6 +69,7 @@ jobs:
// fetch and exec job1
job1Task := runner.fetchTask(t)
assert.Equal(t, "1", job1Task.Context.GetFields()["run_attempt"].GetStringValue())
_, job1, run := getTaskAndJobAndRunByTaskID(t, job1Task.Id)
runner.execTask(t, job1Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
@@ -67,45 +83,453 @@ jobs:
runner.execTask(t, job2Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
assert.EqualValues(t, 1, getRunLatestAttemptNum(t, run.ID))
// RERUN-1: rerun the run
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, run.ID))
session.MakeRequest(t, req, http.StatusOK)
sessionAdmin.MakeRequest(t, req, http.StatusOK) // triggered by admin user
// fetch and exec job1
job1TaskR1 := runner.fetchTask(t)
assert.Equal(t, "2", job1TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
_, job1R1, _ := getTaskAndJobAndRunByTaskID(t, job1TaskR1.Id)
assert.Equal(t, job1.AttemptJobID, job1R1.AttemptJobID)
runner.execTask(t, job1TaskR1, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
// fetch and exec job2
job2TaskR1 := runner.fetchTask(t)
assert.Equal(t, "2", job2TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
_, job2R1, _ := getTaskAndJobAndRunByTaskID(t, job2TaskR1.Id)
assert.Equal(t, job2.AttemptJobID, job2R1.AttemptJobID)
runner.execTask(t, job2TaskR1, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
assert.EqualValues(t, 2, getRunLatestAttemptNum(t, run.ID))
// RERUN-2: rerun job1
job1 = getLatestAttemptJobByTemplateJobID(t, run.ID, job1.ID)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, run.ID, job1.ID))
session.MakeRequest(t, req, http.StatusOK)
// job2 needs job1, so rerunning job1 will also rerun job2
// fetch and exec job1
job1TaskR2 := runner.fetchTask(t)
assert.Equal(t, "3", job1TaskR2.Context.GetFields()["run_attempt"].GetStringValue())
runner.execTask(t, job1TaskR2, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
// fetch and exec job2
job2TaskR2 := runner.fetchTask(t)
assert.Equal(t, "3", job2TaskR2.Context.GetFields()["run_attempt"].GetStringValue())
runner.execTask(t, job2TaskR2, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
assert.EqualValues(t, 3, getRunLatestAttemptNum(t, run.ID))
// RERUN-3: rerun job2
job2 = getLatestAttemptJobByTemplateJobID(t, run.ID, job2.ID)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, run.ID, job2.ID))
session.MakeRequest(t, req, http.StatusOK)
// only job2 will rerun
// fetch and exec job2
job2TaskR3 := runner.fetchTask(t)
assert.Equal(t, "4", job2TaskR3.Context.GetFields()["run_attempt"].GetStringValue())
runner.execTask(t, job2TaskR3, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
runner.fetchNoTask(t)
assert.EqualValues(t, 4, getRunLatestAttemptNum(t, run.ID))
runLatestAttempt := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
job2LatestAttempt := getLatestAttemptJobByTemplateJobID(t, run.ID, job2.ID)
assert.Equal(t, runLatestAttempt.LatestAttemptID, job2LatestAttempt.RunAttemptID)
t.Run("AttemptAPI", func(t *testing.T) {
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/attempts/2", user2.Name, repo.Name, run.ID)).
AddTokenAuth(token)
attemptResp := MakeRequest(t, req, http.StatusOK)
apiAttempt := DecodeJSON(t, attemptResp, &api.ActionWorkflowRun{})
assert.Equal(t, run.ID, apiAttempt.ID)
assert.EqualValues(t, 2, apiAttempt.RunAttempt)
assert.Equal(t, "completed", apiAttempt.Status)
assert.Equal(t, "success", apiAttempt.Conclusion)
assert.NotNil(t, apiAttempt.PreviousAttemptURL)
assert.True(t, strings.HasSuffix(*apiAttempt.PreviousAttemptURL, fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/attempts/1", user2.Name, repo.Name, run.ID)))
assert.Equal(t, user2.Name, apiAttempt.Actor.UserName)
assert.Equal(t, userAdmin.Name, apiAttempt.TriggerActor.UserName)
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/attempts/2/jobs", user2.Name, repo.Name, run.ID)).
AddTokenAuth(token)
attemptJobsResp := MakeRequest(t, req, http.StatusOK)
apiAttemptJobs := DecodeJSON(t, attemptJobsResp, &api.ActionWorkflowJobsResponse{})
assert.Len(t, apiAttemptJobs.Entries, 2)
assert.ElementsMatch(t, []int64{job1R1.ID, job2R1.ID}, []int64{apiAttemptJobs.Entries[0].ID, apiAttemptJobs.Entries[1].ID})
})
t.Run("MaxRerunAttempts", func(t *testing.T) {
// The run has 4 attempts after the previous reruns. Lower the cap to 4 to hit the limit.
defer test.MockVariableValue(&setting.Actions.MaxRerunAttempts, int64(4))()
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, run.ID))
resp := session.MakeRequest(t, req, http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "workflow run has reached the maximum")
assert.EqualValues(t, 4, getRunLatestAttemptNum(t, run.ID))
// Raising the cap lets rerun proceed again.
defer test.MockVariableValue(&setting.Actions.MaxRerunAttempts, int64(5))()
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, run.ID))
session.MakeRequest(t, req, http.StatusOK)
// fetch and exec job1
job1TaskR4 := runner.fetchTask(t)
assert.Equal(t, "5", job1TaskR4.Context.GetFields()["run_attempt"].GetStringValue())
runner.execTask(t, job1TaskR4, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
job2TaskR4 := runner.fetchTask(t)
assert.Equal(t, "5", job2TaskR4.Context.GetFields()["run_attempt"].GetStringValue())
runner.execTask(t, job2TaskR4, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
assert.EqualValues(t, 5, getRunLatestAttemptNum(t, run.ID))
})
})
}
func TestActionsRerunLegacyNoAttemptRun(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiRepo := createActionsTestRepo(t, token, "actions-rerun-legacy", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(httpContext)(t)
runner := newMockRunner()
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
wfTreePath := ".gitea/workflows/actions-rerun-legacy.yml"
wfFileContent := `name: actions-rerun-legacy
on:
workflow_dispatch:
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo 'job1'
job2:
runs-on: ubuntu-latest
needs: [job1]
steps:
- run: echo 'job2'
`
opts := getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "create "+wfTreePath, wfFileContent)
fileResp := createWorkflowFile(t, token, user2.Name, repo.Name, wfTreePath, opts)
require.NotNil(t, fileResp)
// Start preparing legacy data
payloads := mustParseSingleWorkflowPayloads(t, wfFileContent)
now := timeutil.TimeStamp(time.Now().Unix())
started := now - 20
stopped := now - 10
legacyRun := &actions_model.ActionRun{
Title: "legacy rerun test",
RepoID: repo.ID,
OwnerID: repo.OwnerID,
WorkflowID: "actions-rerun-legacy.yml",
Index: 1,
TriggerUserID: user2.ID,
Ref: "refs/heads/" + repo.DefaultBranch,
CommitSHA: fileResp.Commit.SHA,
Event: "workflow_dispatch",
TriggerEvent: "workflow_dispatch",
EventPayload: "{}",
Status: actions_model.StatusSuccess,
Started: started,
Stopped: stopped,
Created: started - 5,
Updated: stopped,
}
require.NoError(t, db.Insert(t.Context(), legacyRun))
// xorm does not update "created"-tagged fields via ORM methods; use raw SQL to backfill historical timestamps.
_, err := db.GetEngine(t.Context()).Exec("UPDATE action_run SET created=?, updated=? WHERE id=?", int64(started-5), int64(stopped), legacyRun.ID)
require.NoError(t, err)
legacyRun.Created = started - 5
legacyRun.Updated = stopped
legacyJob1 := &actions_model.ActionRunJob{
RunID: legacyRun.ID,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
Name: payloads["job1"].name,
Attempt: 1,
WorkflowPayload: payloads["job1"].payload,
JobID: "job1",
Needs: payloads["job1"].needs,
RunsOn: payloads["job1"].runsOn,
Status: actions_model.StatusSuccess,
RunAttemptID: 0,
AttemptJobID: 0,
Started: started,
Stopped: stopped,
IsForkPullRequest: false,
}
legacyJob2 := &actions_model.ActionRunJob{
RunID: legacyRun.ID,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
Name: payloads["job2"].name,
Attempt: 1,
WorkflowPayload: payloads["job2"].payload,
JobID: "job2",
Needs: payloads["job2"].needs,
RunsOn: payloads["job2"].runsOn,
Status: actions_model.StatusSuccess,
RunAttemptID: 0,
AttemptJobID: 0,
Started: started,
Stopped: stopped,
IsForkPullRequest: false,
}
require.NoError(t, db.Insert(t.Context(), legacyJob1, legacyJob2))
legacyTask1 := &actions_model.ActionTask{
JobID: legacyJob1.ID,
Attempt: 1,
Status: actions_model.StatusSuccess,
Started: started,
Stopped: stopped,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
IsForkPullRequest: false,
}
legacyTask1.GenerateAndFillToken()
legacyTask2 := &actions_model.ActionTask{
JobID: legacyJob2.ID,
Attempt: 1,
Status: actions_model.StatusSuccess,
Started: started,
Stopped: stopped,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
IsForkPullRequest: false,
}
legacyTask2.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), legacyTask1, legacyTask2))
legacyJob1.TaskID = legacyTask1.ID
legacyJob2.TaskID = legacyTask2.ID
_, err = db.GetEngine(t.Context()).ID(legacyJob1.ID).Cols("task_id").Update(legacyJob1)
require.NoError(t, err)
_, err = db.GetEngine(t.Context()).ID(legacyJob2.ID).Cols("task_id").Update(legacyJob2)
require.NoError(t, err)
legacyArtifact := &actions_model.ActionArtifact{
RunID: legacyRun.ID,
RunAttemptID: 0,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
StoragePath: "artifacts/legacy-artifact.zip",
FileSize: 123,
FileCompressedSize: 123,
ContentEncodingOrType: actions_model.ContentTypeZip,
ArtifactPath: "legacy-artifact.zip",
ArtifactName: "legacy-artifact",
Status: actions_model.ArtifactStatusUploadConfirmed,
ExpiredUnix: now + timeutil.Day,
}
require.NoError(t, db.Insert(t.Context(), legacyArtifact))
// Done preparing legacy data
// assert the web view for the legacy run before rerun
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, legacyRun.ID))
legacyResp := session.MakeRequest(t, req, http.StatusOK)
legacyView := DecodeJSON(t, legacyResp, &actions_web.ViewResponse{})
// legacy run has no attempt records, so RunAttempt is 0 and Attempts list is empty
assert.EqualValues(t, 0, legacyView.State.Run.RunAttempt)
assert.Empty(t, legacyView.State.Run.Attempts)
assert.Equal(t, "success", legacyView.State.Run.Status)
assert.True(t, legacyView.State.Run.Done)
// isLatestAttempt=true, done=true: can rerun but not cancel
assert.False(t, legacyView.State.Run.CanCancel)
assert.False(t, legacyView.State.Run.CanApprove)
assert.True(t, legacyView.State.Run.CanRerun)
assert.False(t, legacyView.State.Run.CanRerunFailed) // all jobs succeeded
assert.True(t, legacyView.State.Run.CanDeleteArtifact)
if assert.Len(t, legacyView.State.Run.Jobs, 2) {
assert.Equal(t, legacyJob1.ID, legacyView.State.Run.Jobs[0].ID)
assert.Equal(t, legacyJob2.ID, legacyView.State.Run.Jobs[1].ID)
}
if assert.Len(t, legacyView.Artifacts, 1) {
assert.Equal(t, legacyArtifact.ArtifactName, legacyView.Artifacts[0].Name)
assert.Equal(t, "completed", legacyView.Artifacts[0].Status)
}
// rerun the legacy run
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, legacyRun.ID))
session.MakeRequest(t, req, http.StatusOK)
runAfterRerun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
assert.EqualValues(t, 2, getRunLatestAttemptNum(t, legacyRun.ID))
jobsAfterRerun, err := actions_model.GetRunJobsByRunAndAttemptID(t.Context(), legacyRun.ID, runAfterRerun.LatestAttemptID)
require.NoError(t, err)
require.Len(t, jobsAfterRerun, 2)
rerunJobsByJobID := map[string]*actions_model.ActionRunJob{}
for _, job := range jobsAfterRerun {
rerunJobsByJobID[job.JobID] = job
}
require.Contains(t, rerunJobsByJobID, "job1")
require.Contains(t, rerunJobsByJobID, "job2")
assert.Equal(t, actions_model.StatusWaiting, rerunJobsByJobID["job1"].Status)
assert.Equal(t, actions_model.StatusBlocked, rerunJobsByJobID["job2"].Status)
// fetch job1 rerun task
job1TaskR1 := runner.fetchTask(t)
assert.Equal(t, "2", job1TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
rerunJob1Task, rerunJob1, rerunRun := getTaskAndJobAndRunByTaskID(t, job1TaskR1.Id)
assert.Equal(t, legacyRun.ID, rerunRun.ID)
assert.Equal(t, rerunJob1.RunAttemptID, rerunRun.LatestAttemptID)
runner.execTask(t, job1TaskR1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
// fetch job2 rerun task
job2TaskR1 := runner.fetchTask(t)
assert.Equal(t, "2", job2TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
rerunJob2Task, rerunJob2, _ := getTaskAndJobAndRunByTaskID(t, job2TaskR1.Id)
runner.execTask(t, job2TaskR1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
runner.fetchNoTask(t)
// query the 2 attempts
runAfterRerun = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
attempt1, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(t.Context(), legacyRun.ID, 1)
require.NoError(t, err)
assert.Equal(t, legacyRun.Created, attempt1.Created)
assert.Equal(t, legacyRun.Started, attempt1.Started)
assert.Equal(t, legacyRun.Stopped, attempt1.Stopped)
attempt2, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(t.Context(), legacyRun.ID, 2)
require.NoError(t, err)
assert.Equal(t, attempt2.ID, runAfterRerun.LatestAttemptID)
assert.Equal(t, runAfterRerun.Created, attempt1.Created)
assert.Equal(t, runAfterRerun.Started, attempt2.Started)
assert.Equal(t, runAfterRerun.Stopped, attempt2.Stopped)
// assert legacy jobs
legacyJob1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: legacyJob1.ID})
legacyJob2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: legacyJob2.ID})
assert.Equal(t, attempt1.ID, legacyJob1.RunAttemptID)
assert.Equal(t, attempt1.ID, legacyJob2.RunAttemptID)
assert.EqualValues(t, 1, legacyJob1.Attempt)
assert.EqualValues(t, 1, legacyJob2.Attempt)
assert.EqualValues(t, 1, legacyJob1.AttemptJobID)
assert.EqualValues(t, 2, legacyJob2.AttemptJobID)
legacyTask1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: legacyTask1.ID})
legacyTask2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: legacyTask2.ID})
assert.EqualValues(t, 1, legacyTask1.Attempt)
assert.EqualValues(t, 1, legacyTask2.Attempt)
// assert legacy artifacts
legacyArtifact = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionArtifact{ID: legacyArtifact.ID})
assert.Equal(t, attempt1.ID, legacyArtifact.RunAttemptID)
// assert jobs of the latest rerun
rerunJob1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: rerunJob1.ID})
rerunJob2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: rerunJob2.ID})
assert.Equal(t, attempt2.ID, rerunJob1.RunAttemptID)
assert.Equal(t, attempt2.ID, rerunJob2.RunAttemptID)
assert.Equal(t, legacyJob1.AttemptJobID, rerunJob1.AttemptJobID)
assert.Equal(t, legacyJob2.AttemptJobID, rerunJob2.AttemptJobID)
assert.EqualValues(t, 2, rerunJob1Task.Attempt)
assert.EqualValues(t, 2, rerunJob2Task.Attempt)
// assert the web view for the original attempt
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/attempts/1", user2.Name, repo.Name, legacyRun.ID))
attempt1Resp := session.MakeRequest(t, req, http.StatusOK)
attempt1View := DecodeJSON(t, attempt1Resp, &actions_web.ViewResponse{})
assert.EqualValues(t, 1, attempt1View.State.Run.RunAttempt)
if assert.Len(t, attempt1View.State.Run.Attempts, 2) {
// attempts ordered by attempt DESC: index 0 = attempt #2 (latest), index 1 = attempt #1 (current)
assert.False(t, attempt1View.State.Run.Attempts[0].Current)
assert.True(t, attempt1View.State.Run.Attempts[0].Latest)
assert.True(t, attempt1View.State.Run.Attempts[1].Current)
assert.False(t, attempt1View.State.Run.Attempts[1].Latest)
}
// isLatestAttempt=false: all write operations disabled
assert.False(t, attempt1View.State.Run.CanCancel)
assert.False(t, attempt1View.State.Run.CanApprove)
assert.False(t, attempt1View.State.Run.CanRerun)
assert.False(t, attempt1View.State.Run.CanRerunFailed)
assert.True(t, attempt1View.State.Run.CanDeleteArtifact)
assert.Equal(t, legacyJob1.ID, attempt1View.State.Run.Jobs[0].ID)
assert.Equal(t, legacyJob2.ID, attempt1View.State.Run.Jobs[1].ID)
if assert.Len(t, attempt1View.Artifacts, 1) {
assert.Equal(t, attempt1View.Artifacts[0].Name, legacyArtifact.ArtifactName)
}
// assert the web view for the latest attempt
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, legacyRun.ID))
attempt2Resp := session.MakeRequest(t, req, http.StatusOK)
attempt2View := DecodeJSON(t, attempt2Resp, &actions_web.ViewResponse{})
assert.EqualValues(t, 2, attempt2View.State.Run.RunAttempt)
if assert.Len(t, attempt2View.State.Run.Attempts, 2) {
// attempts ordered by attempt DESC: index 0 = attempt #2 (latest, current), index 1 = attempt #1
assert.True(t, attempt2View.State.Run.Attempts[0].Current)
assert.True(t, attempt2View.State.Run.Attempts[0].Latest)
assert.False(t, attempt2View.State.Run.Attempts[1].Current)
assert.False(t, attempt2View.State.Run.Attempts[1].Latest)
}
// isLatestAttempt=true, done=true: can rerun but not cancel
assert.False(t, attempt2View.State.Run.CanCancel)
assert.False(t, attempt2View.State.Run.CanApprove)
assert.True(t, attempt2View.State.Run.CanRerun)
assert.False(t, attempt2View.State.Run.CanRerunFailed) // all jobs succeeded
assert.True(t, attempt2View.State.Run.CanDeleteArtifact)
assert.Equal(t, rerunJob1.ID, attempt2View.State.Run.Jobs[0].ID)
assert.Equal(t, rerunJob2.ID, attempt2View.State.Run.Jobs[1].ID)
assert.Empty(t, attempt2View.Artifacts)
})
}
type workflowJobPayload struct {
name string
payload []byte
needs []string
runsOn []string
}
func mustParseSingleWorkflowPayloads(t *testing.T, workflowContent string) map[string]workflowJobPayload {
t.Helper()
workflows, err := jobparser.Parse([]byte(workflowContent))
require.NoError(t, err)
payloads := make(map[string]workflowJobPayload, len(workflows))
for _, workflow := range workflows {
id, job := workflow.Job()
needs := job.Needs()
require.NoError(t, workflow.SetJob(id, job.EraseNeeds()))
payload, err := workflow.Marshal()
require.NoError(t, err)
payloads[id] = workflowJobPayload{
name: job.Name,
payload: payload,
needs: needs,
runsOn: job.RunsOn(),
}
}
return payloads
}
func getRunLatestAttemptNum(t *testing.T, runID int64) int64 {
t.Helper()
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
attempt := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{ID: run.LatestAttemptID})
return attempt.Attempt
}

View File

@@ -207,7 +207,7 @@ func testActionsRouteForLegacyIndexBasedURL(t *testing.T) {
// Best-effort compatibility prefers the run ID when the same number also exists as a legacy run index.
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, collisionRun.Index))
resp = user2Session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), fmt.Sprintf(`data-run-id="%d"`, normalRun.ID)) // because collisionRun.Index == normalRun.ID
assert.Contains(t, resp.Body.String(), fmt.Sprintf(`data-actions-view-url="/%s/%s/actions/runs/%d"`, user2.Name, repo.Name, normalRun.ID))
// by_index=1 should force the summary page to use the legacy run index interpretation.
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d?by_index=1", user2.Name, repo.Name, collisionRun.Index))

View File

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

View File

@@ -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()
}

View File

@@ -208,15 +208,18 @@ func TestAPIActionsRerunWorkflowRun(t *testing.T) {
assert.Equal(t, actions_model.StatusWaiting, run.Status)
assert.Equal(t, timeutil.TimeStamp(0), run.Started)
assert.Equal(t, timeutil.TimeStamp(0), run.Stopped)
job198, err := actions_model.GetRunJobByRunAndID(t.Context(), 795, 198)
latestAttempt, hasLatestAttempt, err := run.GetLatestAttempt(t.Context())
require.NoError(t, err)
require.True(t, hasLatestAttempt)
job198 := getLatestAttemptJobByTemplateJobID(t, 795, 198)
assert.Equal(t, actions_model.StatusWaiting, job198.Status)
assert.Equal(t, latestAttempt.Attempt, job198.Attempt)
assert.Equal(t, int64(0), job198.TaskID)
job199, err := actions_model.GetRunJobByRunAndID(t.Context(), 795, 199)
require.NoError(t, err)
job199 := getLatestAttemptJobByTemplateJobID(t, 795, 199)
assert.Equal(t, actions_model.StatusWaiting, job199.Status)
assert.Equal(t, latestAttempt.Attempt, job199.Attempt)
assert.Equal(t, int64(0), job199.TaskID)
})
@@ -262,22 +265,28 @@ func TestAPIActionsRerunWorkflowJob(t *testing.T) {
var rerunResp api.ActionWorkflowJob
err := json.Unmarshal(resp.Body.Bytes(), &rerunResp)
require.NoError(t, err)
assert.Equal(t, int64(199), rerunResp.ID)
job199Rerun := getLatestAttemptJobByTemplateJobID(t, 795, 199)
assert.Equal(t, job199Rerun.ID, rerunResp.ID)
assert.Equal(t, "queued", rerunResp.Status)
run, err := actions_model.GetRunByRepoAndID(t.Context(), repo.ID, 795)
require.NoError(t, err)
assert.Equal(t, actions_model.StatusWaiting, run.Status)
job198, err := actions_model.GetRunJobByRunAndID(t.Context(), 795, 198)
latestAttempt, hasLatestAttempt, err := run.GetLatestAttempt(t.Context())
require.NoError(t, err)
assert.Equal(t, actions_model.StatusSuccess, job198.Status)
assert.Equal(t, int64(53), job198.TaskID)
require.True(t, hasLatestAttempt)
job199, err := actions_model.GetRunJobByRunAndID(t.Context(), 795, 199)
require.NoError(t, err)
assert.Equal(t, actions_model.StatusWaiting, job199.Status)
assert.Equal(t, int64(0), job199.TaskID)
job198Rerun := getLatestAttemptJobByTemplateJobID(t, 795, 198)
assert.Equal(t, actions_model.StatusSuccess, job198Rerun.Status)
assert.Equal(t, latestAttempt.Attempt, job198Rerun.Attempt)
assert.Equal(t, int64(0), job198Rerun.TaskID)
assert.Equal(t, int64(53), job198Rerun.SourceTaskID)
job199Rerun = getLatestAttemptJobByTemplateJobID(t, 795, 199)
assert.Equal(t, actions_model.StatusWaiting, job199Rerun.Status)
assert.Equal(t, latestAttempt.Attempt, job199Rerun.Attempt)
assert.Equal(t, int64(0), job199Rerun.TaskID)
assert.Equal(t, int64(0), job199Rerun.SourceTaskID)
})
t.Run("ForbiddenWithoutWriteScope", func(t *testing.T) {