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:
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
@@ -20,10 +21,12 @@ import (
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
webhook_module "code.gitea.io/gitea/modules/webhook"
|
||||
actions_web "code.gitea.io/gitea/routers/web/repo/actions"
|
||||
actions_service "code.gitea.io/gitea/services/actions"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWorkflowConcurrency(t *testing.T) {
|
||||
@@ -96,7 +99,7 @@ jobs:
|
||||
// fetch and exec workflow1
|
||||
task := runner.fetchTask(t)
|
||||
_, _, run := getTaskAndJobAndRunByTaskID(t, task.Id)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
|
||||
assert.Equal(t, "concurrent-workflow-1.yml", run.WorkflowID)
|
||||
runner.fetchNoTask(t)
|
||||
runner.execTask(t, task, &mockTaskOutcome{
|
||||
@@ -109,7 +112,7 @@ jobs:
|
||||
// fetch workflow2
|
||||
task = runner.fetchTask(t)
|
||||
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
|
||||
assert.Equal(t, "concurrent-workflow-2.yml", run.WorkflowID)
|
||||
|
||||
// push workflow3
|
||||
@@ -125,7 +128,7 @@ jobs:
|
||||
// fetch and exec workflow3
|
||||
task = runner.fetchTask(t)
|
||||
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
|
||||
assert.Equal(t, "concurrent-workflow-3.yml", run.WorkflowID)
|
||||
runner.fetchNoTask(t)
|
||||
runner.execTask(t, task, &mockTaskOutcome{
|
||||
@@ -201,7 +204,7 @@ jobs:
|
||||
// fetch and exec workflow1
|
||||
task := runner.fetchTask(t)
|
||||
_, _, run := getTaskAndJobAndRunByTaskID(t, task.Id)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
|
||||
assert.Equal(t, "concurrent-workflow-1.yml", run.WorkflowID)
|
||||
runner.fetchNoTask(t)
|
||||
runner.execTask(t, task, &mockTaskOutcome{
|
||||
@@ -214,7 +217,7 @@ jobs:
|
||||
// fetch workflow2
|
||||
task = runner.fetchTask(t)
|
||||
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
|
||||
assert.Equal(t, "concurrent-workflow-2.yml", run.WorkflowID)
|
||||
|
||||
// push workflow3
|
||||
@@ -230,7 +233,7 @@ jobs:
|
||||
// fetch and exec workflow3
|
||||
task = runner.fetchTask(t)
|
||||
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
|
||||
assert.Equal(t, "concurrent-workflow-3.yml", run.WorkflowID)
|
||||
runner.fetchNoTask(t)
|
||||
runner.execTask(t, task, &mockTaskOutcome{
|
||||
@@ -318,7 +321,7 @@ jobs:
|
||||
// fetch and exec workflow1
|
||||
task := runner.fetchTask(t)
|
||||
_, _, run := getTaskAndJobAndRunByTaskID(t, task.Id)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
|
||||
assert.Equal(t, "concurrent-workflow-1.yml", run.WorkflowID)
|
||||
runner.fetchNoTask(t)
|
||||
runner.execTask(t, task, &mockTaskOutcome{
|
||||
@@ -331,7 +334,7 @@ jobs:
|
||||
// fetch workflow2
|
||||
task = runner.fetchTask(t)
|
||||
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
|
||||
assert.Equal(t, "concurrent-workflow-2.yml", run.WorkflowID)
|
||||
|
||||
// push workflow3
|
||||
@@ -347,7 +350,7 @@ jobs:
|
||||
// fetch and exec workflow3
|
||||
task = runner.fetchTask(t)
|
||||
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
|
||||
assert.Equal(t, "concurrent-workflow-3.yml", run.WorkflowID)
|
||||
runner.fetchNoTask(t)
|
||||
runner.execTask(t, task, &mockTaskOutcome{
|
||||
@@ -412,8 +415,8 @@ jobs:
|
||||
doAPICreatePullRequest(user2APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, "bugfix/aaa")(t)
|
||||
pr1Task1 := runner.fetchTask(t)
|
||||
_, _, pr1Run1 := getTaskAndJobAndRunByTaskID(t, pr1Task1.Id)
|
||||
assert.Equal(t, "pull-request-test", pr1Run1.ConcurrencyGroup)
|
||||
assert.True(t, pr1Run1.ConcurrencyCancel)
|
||||
assert.Equal(t, "pull-request-test", getRunConcurrencyGroup(t, pr1Run1))
|
||||
assert.True(t, getRunConcurrencyCancel(t, pr1Run1))
|
||||
assert.Equal(t, actions_model.StatusRunning, pr1Run1.Status)
|
||||
|
||||
// user4 forks the repo
|
||||
@@ -458,8 +461,8 @@ jobs:
|
||||
// fetch the task and the previous task has been cancelled
|
||||
pr2Task1 := runner.fetchTask(t)
|
||||
_, _, pr2Run1 = getTaskAndJobAndRunByTaskID(t, pr2Task1.Id)
|
||||
assert.Equal(t, "pull-request-test", pr2Run1.ConcurrencyGroup)
|
||||
assert.True(t, pr2Run1.ConcurrencyCancel)
|
||||
assert.Equal(t, "pull-request-test", getRunConcurrencyGroup(t, pr2Run1))
|
||||
assert.True(t, getRunConcurrencyCancel(t, pr2Run1))
|
||||
assert.Equal(t, actions_model.StatusRunning, pr2Run1.Status)
|
||||
pr1Run1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: pr1Run1.ID})
|
||||
assert.Equal(t, actions_model.StatusCancelled, pr1Run1.Status)
|
||||
@@ -495,8 +498,8 @@ jobs:
|
||||
// fetch the task
|
||||
pr3Task1 := runner.fetchTask(t)
|
||||
_, _, pr3Run1 := getTaskAndJobAndRunByTaskID(t, pr3Task1.Id)
|
||||
assert.Equal(t, "pull-request-test", pr3Run1.ConcurrencyGroup)
|
||||
assert.False(t, pr3Run1.ConcurrencyCancel)
|
||||
assert.Equal(t, "pull-request-test", getRunConcurrencyGroup(t, pr3Run1))
|
||||
assert.False(t, getRunConcurrencyCancel(t, pr3Run1))
|
||||
assert.Equal(t, actions_model.StatusRunning, pr3Run1.Status)
|
||||
})
|
||||
}
|
||||
@@ -643,6 +646,7 @@ jobs:
|
||||
assert.Equal(t, "job-main-v1.24.0", wf2Job2Rerun1Job.ConcurrencyGroup)
|
||||
|
||||
// rerun wf2-job2
|
||||
wf2Job2ActionJob = getLatestAttemptJobByTemplateJobID(t, wf2Run.ID, wf2Job2ActionJob.ID)
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, wf2Run.ID, wf2Job2ActionJob.ID))
|
||||
_ = session.MakeRequest(t, req, http.StatusOK)
|
||||
// (rerun2) fetch and exec wf2-job2
|
||||
@@ -803,7 +807,7 @@ jobs:
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
task1 := runner.fetchTask(t)
|
||||
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
|
||||
assert.Equal(t, "workflow-dispatch-v1.21", run1.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.21", getRunConcurrencyGroup(t, run1))
|
||||
|
||||
// run the workflow with appVersion=v1.22 and cancel=false
|
||||
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
|
||||
@@ -813,7 +817,7 @@ jobs:
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
task2 := runner.fetchTask(t)
|
||||
_, _, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", run2.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run2))
|
||||
|
||||
// run the workflow with appVersion=v1.22 and cancel=false again
|
||||
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
|
||||
@@ -832,7 +836,7 @@ jobs:
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
task4 := runner.fetchTask(t)
|
||||
_, _, run4 := getTaskAndJobAndRunByTaskID(t, task4.Id)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", run4.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4))
|
||||
_, _, run2 = getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
assert.Equal(t, actions_model.StatusCancelled, run2.Status)
|
||||
})
|
||||
@@ -893,7 +897,7 @@ jobs:
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
task1 := runner.fetchTask(t)
|
||||
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
|
||||
assert.Equal(t, "workflow-dispatch-v1.21", run1.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.21", getRunConcurrencyGroup(t, run1))
|
||||
|
||||
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
|
||||
"ref": "refs/heads/main",
|
||||
@@ -902,7 +906,7 @@ jobs:
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
task2 := runner.fetchTask(t)
|
||||
_, _, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", run2.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run2))
|
||||
|
||||
// run the workflow with appVersion=v1.22 and cancel=false again
|
||||
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
|
||||
@@ -927,7 +931,7 @@ jobs:
|
||||
task4 := runner.fetchTask(t)
|
||||
_, _, run4 := getTaskAndJobAndRunByTaskID(t, task4.Id)
|
||||
assert.Equal(t, actions_model.StatusRunning, run4.Status)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", run4.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4))
|
||||
_, _, run2 = getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
assert.Equal(t, actions_model.StatusCancelled, run2.Status)
|
||||
|
||||
@@ -945,7 +949,7 @@ jobs:
|
||||
|
||||
task5 := runner.fetchTask(t)
|
||||
_, _, run4_1 := getTaskAndJobAndRunByTaskID(t, task5.Id)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", run4_1.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4_1))
|
||||
assert.Equal(t, run4.ID, run4_1.ID)
|
||||
_, _, run2_1 := getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
assert.Equal(t, actions_model.StatusCancelled, run2_1.Status)
|
||||
@@ -969,7 +973,7 @@ jobs:
|
||||
_, _, run3_2 := getTaskAndJobAndRunByTaskID(t, task6.Id)
|
||||
assert.Equal(t, run3.ID, run3_2.ID)
|
||||
assert.Equal(t, actions_model.StatusRunning, run3_2.Status)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", run3.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run3))
|
||||
|
||||
run2_2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run2_2.ID})
|
||||
assert.Equal(t, actions_model.StatusCancelled, run2_2.Status) // cancelled by run3
|
||||
@@ -1031,7 +1035,7 @@ jobs:
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
task1 := runner.fetchTask(t)
|
||||
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
|
||||
assert.Equal(t, "workflow-dispatch-v1.21", run1.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.21", getRunConcurrencyGroup(t, run1))
|
||||
|
||||
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
|
||||
"ref": "refs/heads/main",
|
||||
@@ -1040,7 +1044,7 @@ jobs:
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
task2 := runner.fetchTask(t)
|
||||
_, job2, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", run2.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run2))
|
||||
|
||||
// run the workflow with appVersion=v1.22 and cancel=false again
|
||||
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
|
||||
@@ -1065,7 +1069,7 @@ jobs:
|
||||
task4 := runner.fetchTask(t)
|
||||
_, job4, run4 := getTaskAndJobAndRunByTaskID(t, task4.Id)
|
||||
assert.Equal(t, actions_model.StatusRunning, run4.Status)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", run4.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4))
|
||||
_, _, run2 = getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
assert.Equal(t, actions_model.StatusCancelled, run2.Status)
|
||||
|
||||
@@ -1074,15 +1078,17 @@ jobs:
|
||||
})
|
||||
|
||||
// rerun cancel true scenario
|
||||
job2 = getLatestAttemptJobByTemplateJobID(t, run2.ID, job2.ID)
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run2.ID, job2.ID))
|
||||
_ = session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
job4 = getLatestAttemptJobByTemplateJobID(t, run4.ID, job4.ID)
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run4.ID, job4.ID))
|
||||
_ = session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
task5 := runner.fetchTask(t)
|
||||
_, _, run4_1 := getTaskAndJobAndRunByTaskID(t, task5.Id)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", run4_1.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4_1))
|
||||
assert.Equal(t, run4.ID, run4_1.ID)
|
||||
_, _, run2_1 := getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
assert.Equal(t, actions_model.StatusCancelled, run2_1.Status)
|
||||
@@ -1093,18 +1099,20 @@ jobs:
|
||||
|
||||
// rerun cancel false scenario
|
||||
|
||||
job2 = getLatestAttemptJobByTemplateJobID(t, run2.ID, job2.ID)
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run2.ID, job2.ID))
|
||||
_ = session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
run2_2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run2.ID})
|
||||
assert.Equal(t, actions_model.StatusWaiting, run2_2.Status)
|
||||
|
||||
job3 = getLatestAttemptJobByTemplateJobID(t, run3.ID, job3.ID)
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run3.ID, job3.ID))
|
||||
_ = session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
task6 := runner.fetchTask(t)
|
||||
_, _, run3 = getTaskAndJobAndRunByTaskID(t, task6.Id)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", run3.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run3))
|
||||
|
||||
run2_2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run2_2.ID})
|
||||
assert.Equal(t, actions_model.StatusCancelled, run2_2.Status) // cancelled by run3
|
||||
@@ -1147,8 +1155,8 @@ jobs:
|
||||
// fetch the task triggered by push
|
||||
task1 := runner.fetchTask(t)
|
||||
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
|
||||
assert.Equal(t, "schedule-concurrency", run1.ConcurrencyGroup)
|
||||
assert.True(t, run1.ConcurrencyCancel)
|
||||
assert.Equal(t, "schedule-concurrency", getRunConcurrencyGroup(t, run1))
|
||||
assert.True(t, getRunConcurrencyCancel(t, run1))
|
||||
assert.Equal(t, string(webhook_module.HookEventPush), run1.TriggerEvent)
|
||||
assert.Equal(t, actions_model.StatusRunning, run1.Status)
|
||||
|
||||
@@ -1165,8 +1173,8 @@ jobs:
|
||||
assert.Equal(t, actions_model.StatusSuccess, run1.Status)
|
||||
task2 := runner.fetchTask(t)
|
||||
_, _, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
assert.Equal(t, "schedule-concurrency", run2.ConcurrencyGroup)
|
||||
assert.False(t, run2.ConcurrencyCancel)
|
||||
assert.Equal(t, "schedule-concurrency", getRunConcurrencyGroup(t, run2))
|
||||
assert.False(t, getRunConcurrencyCancel(t, run2))
|
||||
assert.Equal(t, string(webhook_module.HookEventSchedule), run2.TriggerEvent)
|
||||
assert.Equal(t, actions_model.StatusRunning, run2.Status)
|
||||
|
||||
@@ -1177,8 +1185,8 @@ jobs:
|
||||
assert.NoError(t, actions_service.StartScheduleTasks(t.Context()))
|
||||
runner.fetchNoTask(t) // cannot fetch because task2 is not completed
|
||||
run3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID, Status: actions_model.StatusBlocked})
|
||||
assert.Equal(t, "schedule-concurrency", run3.ConcurrencyGroup)
|
||||
assert.False(t, run3.ConcurrencyCancel)
|
||||
assert.Equal(t, "schedule-concurrency", getRunConcurrencyGroup(t, run3))
|
||||
assert.False(t, getRunConcurrencyCancel(t, run3))
|
||||
assert.Equal(t, string(webhook_module.HookEventSchedule), run3.TriggerEvent)
|
||||
|
||||
// trigger the task by push
|
||||
@@ -1204,8 +1212,8 @@ jobs:
|
||||
|
||||
task4 := runner.fetchTask(t)
|
||||
_, _, run4 := getTaskAndJobAndRunByTaskID(t, task4.Id)
|
||||
assert.Equal(t, "schedule-concurrency", run4.ConcurrencyGroup)
|
||||
assert.True(t, run4.ConcurrencyCancel)
|
||||
assert.Equal(t, "schedule-concurrency", getRunConcurrencyGroup(t, run4))
|
||||
assert.True(t, getRunConcurrencyCancel(t, run4))
|
||||
assert.Equal(t, string(webhook_module.HookEventPush), run4.TriggerEvent)
|
||||
run3 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run3.ID})
|
||||
assert.Equal(t, actions_model.StatusCancelled, run3.Status)
|
||||
@@ -1317,7 +1325,7 @@ jobs:
|
||||
w1j2Task := runner2.fetchTask(t)
|
||||
_, w1j1Job, w1Run := getTaskAndJobAndRunByTaskID(t, w1j1Task.Id)
|
||||
assert.Equal(t, "job-group-1", w1j1Job.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-group-1", w1Run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-group-1", getRunConcurrencyGroup(t, w1Run))
|
||||
assert.Equal(t, "concurrent-workflow-1.yml", w1Run.WorkflowID)
|
||||
assert.Equal(t, actions_model.StatusRunning, w1j1Job.Status)
|
||||
_, w1j2Job, _ := getTaskAndJobAndRunByTaskID(t, w1j2Task.Id)
|
||||
@@ -1358,7 +1366,7 @@ jobs:
|
||||
w3j1Task := runner1.fetchTask(t)
|
||||
_, w3j1Job, w3Run = getTaskAndJobAndRunByTaskID(t, w3j1Task.Id)
|
||||
assert.Equal(t, "job-group-1", w3j1Job.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-group-2", w3Run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-group-2", getRunConcurrencyGroup(t, w3Run))
|
||||
assert.Equal(t, "concurrent-workflow-3.yml", w3Run.WorkflowID)
|
||||
|
||||
// exec wf1-job2
|
||||
@@ -1370,7 +1378,7 @@ jobs:
|
||||
w2j2Task := runner2.fetchTask(t)
|
||||
_, w2j2Job, w2Run := getTaskAndJobAndRunByTaskID(t, w2j2Task.Id)
|
||||
assert.Equal(t, "job-group-2", w2j2Job.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-group-1", w2Run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-group-1", getRunConcurrencyGroup(t, w2Run))
|
||||
assert.Equal(t, "concurrent-workflow-2.yml", w2Run.WorkflowID)
|
||||
assert.Equal(t, actions_model.StatusRunning, w2j2Job.Status)
|
||||
|
||||
@@ -1397,7 +1405,7 @@ jobs:
|
||||
assert.Equal(t, actions_model.StatusCancelled, w2Run.Status)
|
||||
_, w4j1Job, w4Run := getTaskAndJobAndRunByTaskID(t, w4j1Task.Id)
|
||||
assert.Equal(t, "job-group-2", w4j1Job.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-group-2", w4Run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-group-2", getRunConcurrencyGroup(t, w4Run))
|
||||
assert.Equal(t, "concurrent-workflow-4.yml", w4Run.WorkflowID)
|
||||
})
|
||||
}
|
||||
@@ -1435,8 +1443,8 @@ jobs:
|
||||
// fetch and check the first task
|
||||
task1 := runner.fetchTask(t)
|
||||
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
|
||||
assert.Equal(t, "cancel-run-group", run1.ConcurrencyGroup)
|
||||
assert.False(t, run1.ConcurrencyCancel)
|
||||
assert.Equal(t, "cancel-run-group", getRunConcurrencyGroup(t, run1))
|
||||
assert.False(t, getRunConcurrencyCancel(t, run1))
|
||||
assert.Equal(t, actions_model.StatusRunning, run1.Status)
|
||||
|
||||
// push another file to trigger the workflow again
|
||||
@@ -1473,8 +1481,8 @@ jobs:
|
||||
// fetch and check the second task
|
||||
task2 := runner.fetchTask(t)
|
||||
_, _, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
assert.Equal(t, "cancel-run-group", run2.ConcurrencyGroup)
|
||||
assert.False(t, run2.ConcurrencyCancel)
|
||||
assert.Equal(t, "cancel-run-group", getRunConcurrencyGroup(t, run2))
|
||||
assert.False(t, getRunConcurrencyCancel(t, run2))
|
||||
assert.Equal(t, actions_model.StatusRunning, run2.Status)
|
||||
})
|
||||
}
|
||||
@@ -1533,7 +1541,7 @@ jobs:
|
||||
// fetch wf1-job1
|
||||
w1j1Task := runner.fetchTask(t)
|
||||
_, _, run1 := getTaskAndJobAndRunByTaskID(t, w1j1Task.Id)
|
||||
assert.Equal(t, "test-group", run1.ConcurrencyGroup)
|
||||
assert.Equal(t, "test-group", getRunConcurrencyGroup(t, run1))
|
||||
assert.Equal(t, actions_model.StatusRunning, run1.Status)
|
||||
// query wf1-job2 from db and check its status
|
||||
w1j2Job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run1.ID, JobID: "wf1-job2"})
|
||||
@@ -1571,7 +1579,7 @@ jobs:
|
||||
// fetch wf2-job1 and check
|
||||
w2j1Task := runner.fetchTask(t)
|
||||
_, w2j1Job, run2 := getTaskAndJobAndRunByTaskID(t, w2j1Task.Id)
|
||||
assert.Equal(t, "test-group", run2.ConcurrencyGroup)
|
||||
assert.Equal(t, "test-group", getRunConcurrencyGroup(t, run2))
|
||||
assert.Equal(t, "wf2-job1", w2j1Job.JobID)
|
||||
assert.Equal(t, actions_model.StatusRunning, run2.Status)
|
||||
assert.Equal(t, actions_model.StatusRunning, w2j1Job.Status)
|
||||
@@ -1650,7 +1658,7 @@ jobs:
|
||||
// cannot fetch run2 because run1 is still running
|
||||
runner.fetchNoTask(t)
|
||||
run2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID, WorkflowID: "concurrent-workflow-2.yml"})
|
||||
assert.Equal(t, "test-group", run2.ConcurrencyGroup)
|
||||
assert.Equal(t, "test-group", getRunConcurrencyGroup(t, run2))
|
||||
assert.Equal(t, actions_model.StatusBlocked, run2.Status)
|
||||
|
||||
// exec run1
|
||||
@@ -1677,3 +1685,164 @@ jobs:
|
||||
assert.Equal(t, actions_model.StatusCancelled, run2.Status)
|
||||
})
|
||||
}
|
||||
|
||||
// TestCancelLegacyRunBlockedByConcurrency simulates a workflow run created before migration v331:
|
||||
// it has no ActionRunAttempt record (LatestAttemptID == 0) and was blocked by workflow-level concurrency.
|
||||
// Migration v331 drops action_run.concurrency_group / concurrency_cancel, so the run ends up "stuck" with no way for the job emitter to naturally unblock it.
|
||||
// The test verifies the user can still:
|
||||
// 1. view the stuck legacy run correctly (web view renders)
|
||||
// 2. cancel it from the UI, which transitions the run and all its jobs to Cancelled
|
||||
// 3. rerun the (now cancelled) legacy run successfully
|
||||
func TestCancelLegacyRunBlockedByConcurrency(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
session := loginUser(t, user2.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
||||
|
||||
apiRepo := createActionsTestRepo(t, token, "actions-legacy-concurrency", false)
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
|
||||
httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository)
|
||||
defer doAPIDeleteRepository(httpContext)(t)
|
||||
|
||||
runner := newMockRunner()
|
||||
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
|
||||
|
||||
// Manually insert a "legacy" run blocked by workflow-level concurrency: no ActionRunAttempt, LatestAttemptID=0.
|
||||
// Its workflow-level concurrency info would have been stored on action_run.concurrency_group pre-v331;
|
||||
// after the migration that column is gone, so we simply mark the run (and its jobs) as Blocked.
|
||||
legacyWfContent := `name: legacy-blocked
|
||||
on:
|
||||
workflow_dispatch:
|
||||
concurrency:
|
||||
group: test-group
|
||||
jobs:
|
||||
legacy-job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo 'legacy-job1'
|
||||
legacy-job2:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo 'legacy-job2'
|
||||
`
|
||||
payloads := mustParseSingleWorkflowPayloads(t, legacyWfContent)
|
||||
now := timeutil.TimeStamp(time.Now().Unix())
|
||||
legacyRun := &actions_model.ActionRun{
|
||||
Title: "legacy blocked run",
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
WorkflowID: "legacy-blocked.yml",
|
||||
Index: 1,
|
||||
TriggerUserID: user2.ID,
|
||||
Ref: "refs/heads/" + repo.DefaultBranch,
|
||||
CommitSHA: "0000000000000000000000000000000000000000",
|
||||
Event: "workflow_dispatch",
|
||||
TriggerEvent: "workflow_dispatch",
|
||||
EventPayload: "{}",
|
||||
Status: actions_model.StatusBlocked,
|
||||
Created: now - 1,
|
||||
Updated: now - 1,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), legacyRun))
|
||||
|
||||
legacyJob1 := &actions_model.ActionRunJob{
|
||||
RunID: legacyRun.ID,
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
CommitSHA: legacyRun.CommitSHA,
|
||||
Name: payloads["legacy-job1"].name,
|
||||
Attempt: 1,
|
||||
WorkflowPayload: payloads["legacy-job1"].payload,
|
||||
JobID: "legacy-job1",
|
||||
Needs: payloads["legacy-job1"].needs,
|
||||
RunsOn: payloads["legacy-job1"].runsOn,
|
||||
Status: actions_model.StatusBlocked,
|
||||
RunAttemptID: 0,
|
||||
AttemptJobID: 0,
|
||||
}
|
||||
legacyJob2 := &actions_model.ActionRunJob{
|
||||
RunID: legacyRun.ID,
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
CommitSHA: legacyRun.CommitSHA,
|
||||
Name: payloads["legacy-job2"].name,
|
||||
Attempt: 1,
|
||||
WorkflowPayload: payloads["legacy-job2"].payload,
|
||||
JobID: "legacy-job2",
|
||||
Needs: payloads["legacy-job2"].needs,
|
||||
RunsOn: payloads["legacy-job2"].runsOn,
|
||||
Status: actions_model.StatusBlocked,
|
||||
RunAttemptID: 0,
|
||||
AttemptJobID: 0,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), legacyJob1, legacyJob2))
|
||||
|
||||
// 1) User visits the legacy run's web view - it renders without error.
|
||||
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, legacyRun.ID))
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
viewResp := DecodeJSON(t, resp, &actions_web.ViewResponse{})
|
||||
// Legacy run has no attempt record, so RunAttempt is 0 and Attempts is empty.
|
||||
assert.EqualValues(t, 0, viewResp.State.Run.RunAttempt)
|
||||
assert.Empty(t, viewResp.State.Run.Attempts)
|
||||
assert.Equal(t, actions_model.StatusBlocked.String(), viewResp.State.Run.Status)
|
||||
assert.False(t, viewResp.State.Run.Done)
|
||||
// Legacy workflow-level concurrency info is gone (columns dropped by v331), so GetEffectiveConcurrency returns "": the run cannot self-unblock via job_emitter.
|
||||
afterLoadRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
|
||||
assert.Empty(t, getRunConcurrencyGroup(t, afterLoadRun))
|
||||
// Still Blocked, not Done, but user should be able to cancel.
|
||||
assert.True(t, viewResp.State.Run.CanCancel)
|
||||
assert.False(t, viewResp.State.Run.CanRerun)
|
||||
if assert.Len(t, viewResp.State.Run.Jobs, 2) {
|
||||
assert.Equal(t, actions_model.StatusBlocked.String(), viewResp.State.Run.Jobs[0].Status)
|
||||
assert.Equal(t, actions_model.StatusBlocked.String(), viewResp.State.Run.Jobs[1].Status)
|
||||
}
|
||||
|
||||
// 2) User cancels the legacy run to clean it up.
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/cancel", user2.Name, repo.Name, legacyRun.ID))
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
// Run and all its jobs transition to Cancelled.
|
||||
cancelledRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
|
||||
assert.Equal(t, actions_model.StatusCancelled, cancelledRun.Status)
|
||||
cancelledJob1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: legacyJob1.ID})
|
||||
assert.Equal(t, actions_model.StatusCancelled, cancelledJob1.Status)
|
||||
cancelledJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: legacyJob2.ID})
|
||||
assert.Equal(t, actions_model.StatusCancelled, cancelledJob2.Status)
|
||||
|
||||
// 3) User reruns the now-cancelled legacy run.
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, legacyRun.ID))
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
rerunRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
|
||||
assert.Positive(t, rerunRun.LatestAttemptID)
|
||||
assert.EqualValues(t, 2, getRunLatestAttemptNum(t, legacyRun.ID))
|
||||
// Both jobs run successfully on the registered runner.
|
||||
for range 2 {
|
||||
task := runner.fetchTask(t)
|
||||
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
}
|
||||
finalRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
|
||||
assert.Equal(t, actions_model.StatusSuccess, finalRun.Status)
|
||||
})
|
||||
}
|
||||
|
||||
func getRunConcurrencyGroup(t *testing.T, run *actions_model.ActionRun) string {
|
||||
cg, _, err := run.GetEffectiveConcurrency(t.Context())
|
||||
assert.NoError(t, err)
|
||||
return cg
|
||||
}
|
||||
|
||||
func getRunConcurrencyCancel(t *testing.T, run *actions_model.ActionRun) bool {
|
||||
_, cc, err := run.GetEffectiveConcurrency(t.Context())
|
||||
assert.NoError(t, err)
|
||||
return cc
|
||||
}
|
||||
|
||||
func getLatestAttemptJobByTemplateJobID(t *testing.T, runID, templateJobID int64) *actions_model.ActionRunJob {
|
||||
t.Helper()
|
||||
|
||||
templateJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: templateJobID, RunID: runID})
|
||||
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
|
||||
job, err := actions_model.GetRunJobByAttemptJobID(t.Context(), run.ID, run.LatestAttemptID, templateJob.AttemptJobID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
return job
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
runID = run.ID
|
||||
}
|
||||
|
||||
jobs, err := actions_model.GetRunJobsByRunID(t.Context(), runID)
|
||||
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(t.Context(), apiRepo.ID, runID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
for i := 0; i < len(testCase.outcomes); i++ {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
actions_service "code.gitea.io/gitea/services/actions"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
@@ -759,3 +761,161 @@ func getTaskJobNameByTaskID(t *testing.T, authToken, ownerName, repoName string,
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestLegacyRunsInCronTasks verifies that the background cron tasks correctly handle runs/jobs
|
||||
// created before migration v331 (legacy data with LatestAttemptID=0 and jobs with RunAttemptID=0).
|
||||
func TestLegacyRunsInCronTasks(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
session := loginUser(t, user2.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
||||
|
||||
apiRepo := createActionsTestRepo(t, token, "actions-legacy-cron", false)
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
|
||||
httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository)
|
||||
defer doAPIDeleteRepository(httpContext)(t)
|
||||
|
||||
// Far-past timestamp so the queries match regardless of the configured timeouts.
|
||||
oldTS := timeutil.TimeStamp(time.Now().Add(-30 * 24 * time.Hour).Unix())
|
||||
|
||||
// insertLegacyRunJob inserts a run + job without an ActionRunAttempt record, simulating data created before migration v331 (LatestAttemptID=0, job.RunAttemptID=0, job.AttemptJobID=0).
|
||||
insertLegacyRunJob := func(t *testing.T, index int64, runStatus, jobStatus actions_model.Status) (*actions_model.ActionRun, *actions_model.ActionRunJob) {
|
||||
t.Helper()
|
||||
|
||||
run := &actions_model.ActionRun{
|
||||
Title: fmt.Sprintf("legacy run %d", index),
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
WorkflowID: fmt.Sprintf("legacy-%d.yml", index),
|
||||
Index: index,
|
||||
TriggerUserID: user2.ID,
|
||||
Ref: "refs/heads/" + repo.DefaultBranch,
|
||||
CommitSHA: "0000000000000000000000000000000000000000",
|
||||
Event: "workflow_dispatch",
|
||||
TriggerEvent: "workflow_dispatch",
|
||||
EventPayload: "{}",
|
||||
Status: runStatus,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
job := &actions_model.ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "legacy-job",
|
||||
Attempt: 1,
|
||||
JobID: "legacy-job",
|
||||
RunsOn: []string{"ubuntu-latest"},
|
||||
Status: jobStatus,
|
||||
RunAttemptID: 0,
|
||||
AttemptJobID: 0,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
|
||||
// backfill timestamps so the cron task queries can match them.
|
||||
_, err := db.GetEngine(t.Context()).Exec("UPDATE action_run SET created=?, updated=? WHERE id=?", int64(oldTS), int64(oldTS), run.ID)
|
||||
require.NoError(t, err)
|
||||
_, err = db.GetEngine(t.Context()).Exec("UPDATE action_run_job SET created=?, updated=? WHERE id=?", int64(oldTS), int64(oldTS), job.ID)
|
||||
require.NoError(t, err)
|
||||
run.Created, run.Updated = oldTS, oldTS
|
||||
job.Created, job.Updated = oldTS, oldTS
|
||||
return run, job
|
||||
}
|
||||
|
||||
t.Run("StopZombieTasks", func(t *testing.T) {
|
||||
run, job := insertLegacyRunJob(t, 10, actions_model.StatusRunning, actions_model.StatusRunning)
|
||||
|
||||
task := &actions_model.ActionTask{
|
||||
JobID: job.ID,
|
||||
Attempt: 1,
|
||||
Status: actions_model.StatusRunning,
|
||||
Started: oldTS,
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
}
|
||||
task.GenerateAndFillToken()
|
||||
require.NoError(t, db.Insert(t.Context(), task))
|
||||
_, err := db.GetEngine(t.Context()).Exec("UPDATE action_task SET updated=? WHERE id=?", int64(oldTS), task.ID)
|
||||
require.NoError(t, err)
|
||||
job.TaskID = task.ID
|
||||
_, err = db.GetEngine(t.Context()).ID(job.ID).Cols("task_id").Update(job)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, actions_service.StopZombieTasks(t.Context()))
|
||||
|
||||
gotTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.ID})
|
||||
assert.Equal(t, actions_model.StatusFailure, gotTask.Status)
|
||||
gotJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, actions_model.StatusFailure, gotJob.Status)
|
||||
gotRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||
assert.Equal(t, actions_model.StatusFailure, gotRun.Status)
|
||||
})
|
||||
|
||||
t.Run("StopEndlessTasks", func(t *testing.T) {
|
||||
run, job := insertLegacyRunJob(t, 20, actions_model.StatusRunning, actions_model.StatusRunning)
|
||||
|
||||
task := &actions_model.ActionTask{
|
||||
JobID: job.ID,
|
||||
Attempt: 1,
|
||||
Status: actions_model.StatusRunning,
|
||||
Started: oldTS,
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
}
|
||||
task.GenerateAndFillToken()
|
||||
require.NoError(t, db.Insert(t.Context(), task))
|
||||
job.TaskID = task.ID
|
||||
_, err := db.GetEngine(t.Context()).ID(job.ID).Cols("task_id").Update(job)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, actions_service.StopEndlessTasks(t.Context()))
|
||||
|
||||
gotTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.ID})
|
||||
assert.Equal(t, actions_model.StatusFailure, gotTask.Status)
|
||||
gotJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, actions_model.StatusFailure, gotJob.Status)
|
||||
gotRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||
assert.Equal(t, actions_model.StatusFailure, gotRun.Status)
|
||||
})
|
||||
|
||||
t.Run("CancelAbandonedJobs", func(t *testing.T) {
|
||||
run, job := insertLegacyRunJob(t, 30, actions_model.StatusWaiting, actions_model.StatusWaiting)
|
||||
|
||||
require.NoError(t, actions_service.CancelAbandonedJobs(t.Context()))
|
||||
|
||||
gotJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, actions_model.StatusCancelled, gotJob.Status)
|
||||
gotRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||
assert.Equal(t, actions_model.StatusCancelled, gotRun.Status)
|
||||
})
|
||||
|
||||
t.Run("Cleanup", func(t *testing.T) {
|
||||
run, _ := insertLegacyRunJob(t, 40, actions_model.StatusSuccess, actions_model.StatusSuccess)
|
||||
|
||||
expiredArtifact := &actions_model.ActionArtifact{
|
||||
RunID: run.ID,
|
||||
RunAttemptID: 0, // legacy artifact
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
StoragePath: fmt.Sprintf("artifacts/legacy-expired-%d.zip", run.ID),
|
||||
FileSize: 1,
|
||||
FileCompressedSize: 1,
|
||||
ContentEncodingOrType: actions_model.ContentTypeZip,
|
||||
ArtifactPath: "legacy-expired.zip",
|
||||
ArtifactName: "legacy-expired",
|
||||
Status: actions_model.ArtifactStatusUploadConfirmed,
|
||||
ExpiredUnix: oldTS,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), expiredArtifact))
|
||||
|
||||
require.NoError(t, actions_service.Cleanup(t.Context()))
|
||||
|
||||
gotArtifact := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionArtifact{ID: expiredArtifact.ID})
|
||||
assert.Equal(t, actions_model.ArtifactStatusExpired, gotArtifact.Status)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
@@ -214,5 +215,95 @@ jobs:
|
||||
resetFunc()
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("DownloadRerunTaskLogs", func(t *testing.T) {
|
||||
treePath := ".gitea/workflows/download-rerun-logs.yml"
|
||||
fileContent := `name: download-rerun-logs
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.gitea/workflows/download-rerun-logs.yml'
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo job1
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [job1]
|
||||
steps:
|
||||
- run: echo job2
|
||||
`
|
||||
|
||||
// create the workflow file
|
||||
opts := getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "create "+treePath, fileContent)
|
||||
createWorkflowFile(t, token, user2.Name, repo.Name, treePath, opts)
|
||||
|
||||
// first run
|
||||
job1Task1 := runner.fetchTask(t)
|
||||
_, job1, _ := getTaskAndJobAndRunByTaskID(t, job1Task1.Id)
|
||||
runner.execTask(t, job1Task1, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
logRows: []*runnerv1.LogRow{
|
||||
{
|
||||
Time: timestamppb.New(now.Add(1 * time.Second)),
|
||||
Content: "job1 first run",
|
||||
},
|
||||
},
|
||||
})
|
||||
job2Task1 := runner.fetchTask(t)
|
||||
_, job2, run := getTaskAndJobAndRunByTaskID(t, job2Task1.Id)
|
||||
runner.execTask(t, job2Task1, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
logRows: []*runnerv1.LogRow{
|
||||
{
|
||||
Time: timestamppb.New(now.Add(1 * time.Second)),
|
||||
Content: "job2 first run",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// check job1 log
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job1.ID)).
|
||||
AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), "job1 first run")
|
||||
// check job2 log
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job2.ID)).
|
||||
AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), "job2 first run")
|
||||
|
||||
// only rerun job2
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, run.ID, job2.ID))
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
job2TaskRerun := runner.fetchTask(t)
|
||||
runner.execTask(t, job2TaskRerun, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
logRows: []*runnerv1.LogRow{
|
||||
{
|
||||
Time: timestamppb.New(now.Add(1 * time.Second)),
|
||||
Content: "job2 rerun",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||
job1Rerun := getLatestAttemptJobByTemplateJobID(t, run.ID, job1.ID)
|
||||
assert.Equal(t, run.LatestAttemptID, job1Rerun.RunAttemptID)
|
||||
job2Rerun := getLatestAttemptJobByTemplateJobID(t, run.ID, job2.ID)
|
||||
assert.Equal(t, run.LatestAttemptID, job2Rerun.RunAttemptID)
|
||||
|
||||
// check job1 rerun log
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job1Rerun.ID)).
|
||||
AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), "job1 first run") // should return the log of first run because job1 didn't rerun
|
||||
// check job2 rerun log
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job2Rerun.ID)).
|
||||
AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), "job2 rerun")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,18 +7,33 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/actions/jobparser"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/test"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
actions_web "code.gitea.io/gitea/routers/web/repo/actions"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestActionsRerun(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
userAdmin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
sessionAdmin := loginUser(t, userAdmin.Name)
|
||||
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
session := loginUser(t, user2.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
||||
@@ -54,6 +69,7 @@ jobs:
|
||||
|
||||
// fetch and exec job1
|
||||
job1Task := runner.fetchTask(t)
|
||||
assert.Equal(t, "1", job1Task.Context.GetFields()["run_attempt"].GetStringValue())
|
||||
_, job1, run := getTaskAndJobAndRunByTaskID(t, job1Task.Id)
|
||||
runner.execTask(t, job1Task, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
@@ -67,45 +83,453 @@ jobs:
|
||||
runner.execTask(t, job2Task, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
})
|
||||
assert.EqualValues(t, 1, getRunLatestAttemptNum(t, run.ID))
|
||||
|
||||
// RERUN-1: rerun the run
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, run.ID))
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
sessionAdmin.MakeRequest(t, req, http.StatusOK) // triggered by admin user
|
||||
// fetch and exec job1
|
||||
job1TaskR1 := runner.fetchTask(t)
|
||||
assert.Equal(t, "2", job1TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
|
||||
_, job1R1, _ := getTaskAndJobAndRunByTaskID(t, job1TaskR1.Id)
|
||||
assert.Equal(t, job1.AttemptJobID, job1R1.AttemptJobID)
|
||||
runner.execTask(t, job1TaskR1, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
})
|
||||
// fetch and exec job2
|
||||
job2TaskR1 := runner.fetchTask(t)
|
||||
assert.Equal(t, "2", job2TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
|
||||
_, job2R1, _ := getTaskAndJobAndRunByTaskID(t, job2TaskR1.Id)
|
||||
assert.Equal(t, job2.AttemptJobID, job2R1.AttemptJobID)
|
||||
runner.execTask(t, job2TaskR1, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
})
|
||||
assert.EqualValues(t, 2, getRunLatestAttemptNum(t, run.ID))
|
||||
|
||||
// RERUN-2: rerun job1
|
||||
job1 = getLatestAttemptJobByTemplateJobID(t, run.ID, job1.ID)
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, run.ID, job1.ID))
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
// job2 needs job1, so rerunning job1 will also rerun job2
|
||||
// fetch and exec job1
|
||||
job1TaskR2 := runner.fetchTask(t)
|
||||
assert.Equal(t, "3", job1TaskR2.Context.GetFields()["run_attempt"].GetStringValue())
|
||||
runner.execTask(t, job1TaskR2, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
})
|
||||
// fetch and exec job2
|
||||
job2TaskR2 := runner.fetchTask(t)
|
||||
assert.Equal(t, "3", job2TaskR2.Context.GetFields()["run_attempt"].GetStringValue())
|
||||
runner.execTask(t, job2TaskR2, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
})
|
||||
assert.EqualValues(t, 3, getRunLatestAttemptNum(t, run.ID))
|
||||
|
||||
// RERUN-3: rerun job2
|
||||
job2 = getLatestAttemptJobByTemplateJobID(t, run.ID, job2.ID)
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, run.ID, job2.ID))
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
// only job2 will rerun
|
||||
// fetch and exec job2
|
||||
job2TaskR3 := runner.fetchTask(t)
|
||||
assert.Equal(t, "4", job2TaskR3.Context.GetFields()["run_attempt"].GetStringValue())
|
||||
runner.execTask(t, job2TaskR3, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
})
|
||||
runner.fetchNoTask(t)
|
||||
assert.EqualValues(t, 4, getRunLatestAttemptNum(t, run.ID))
|
||||
|
||||
runLatestAttempt := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||
job2LatestAttempt := getLatestAttemptJobByTemplateJobID(t, run.ID, job2.ID)
|
||||
assert.Equal(t, runLatestAttempt.LatestAttemptID, job2LatestAttempt.RunAttemptID)
|
||||
|
||||
t.Run("AttemptAPI", func(t *testing.T) {
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/attempts/2", user2.Name, repo.Name, run.ID)).
|
||||
AddTokenAuth(token)
|
||||
attemptResp := MakeRequest(t, req, http.StatusOK)
|
||||
apiAttempt := DecodeJSON(t, attemptResp, &api.ActionWorkflowRun{})
|
||||
assert.Equal(t, run.ID, apiAttempt.ID)
|
||||
assert.EqualValues(t, 2, apiAttempt.RunAttempt)
|
||||
assert.Equal(t, "completed", apiAttempt.Status)
|
||||
assert.Equal(t, "success", apiAttempt.Conclusion)
|
||||
assert.NotNil(t, apiAttempt.PreviousAttemptURL)
|
||||
assert.True(t, strings.HasSuffix(*apiAttempt.PreviousAttemptURL, fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/attempts/1", user2.Name, repo.Name, run.ID)))
|
||||
assert.Equal(t, user2.Name, apiAttempt.Actor.UserName)
|
||||
assert.Equal(t, userAdmin.Name, apiAttempt.TriggerActor.UserName)
|
||||
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/attempts/2/jobs", user2.Name, repo.Name, run.ID)).
|
||||
AddTokenAuth(token)
|
||||
attemptJobsResp := MakeRequest(t, req, http.StatusOK)
|
||||
apiAttemptJobs := DecodeJSON(t, attemptJobsResp, &api.ActionWorkflowJobsResponse{})
|
||||
assert.Len(t, apiAttemptJobs.Entries, 2)
|
||||
assert.ElementsMatch(t, []int64{job1R1.ID, job2R1.ID}, []int64{apiAttemptJobs.Entries[0].ID, apiAttemptJobs.Entries[1].ID})
|
||||
})
|
||||
|
||||
t.Run("MaxRerunAttempts", func(t *testing.T) {
|
||||
// The run has 4 attempts after the previous reruns. Lower the cap to 4 to hit the limit.
|
||||
defer test.MockVariableValue(&setting.Actions.MaxRerunAttempts, int64(4))()
|
||||
|
||||
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, run.ID))
|
||||
resp := session.MakeRequest(t, req, http.StatusBadRequest)
|
||||
assert.Contains(t, resp.Body.String(), "workflow run has reached the maximum")
|
||||
assert.EqualValues(t, 4, getRunLatestAttemptNum(t, run.ID))
|
||||
|
||||
// Raising the cap lets rerun proceed again.
|
||||
defer test.MockVariableValue(&setting.Actions.MaxRerunAttempts, int64(5))()
|
||||
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, run.ID))
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
// fetch and exec job1
|
||||
job1TaskR4 := runner.fetchTask(t)
|
||||
assert.Equal(t, "5", job1TaskR4.Context.GetFields()["run_attempt"].GetStringValue())
|
||||
runner.execTask(t, job1TaskR4, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
})
|
||||
job2TaskR4 := runner.fetchTask(t)
|
||||
assert.Equal(t, "5", job2TaskR4.Context.GetFields()["run_attempt"].GetStringValue())
|
||||
runner.execTask(t, job2TaskR4, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
})
|
||||
assert.EqualValues(t, 5, getRunLatestAttemptNum(t, run.ID))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestActionsRerunLegacyNoAttemptRun(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
session := loginUser(t, user2.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
||||
|
||||
apiRepo := createActionsTestRepo(t, token, "actions-rerun-legacy", false)
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
|
||||
httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository)
|
||||
defer doAPIDeleteRepository(httpContext)(t)
|
||||
runner := newMockRunner()
|
||||
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
|
||||
|
||||
wfTreePath := ".gitea/workflows/actions-rerun-legacy.yml"
|
||||
wfFileContent := `name: actions-rerun-legacy
|
||||
on:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo 'job1'
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [job1]
|
||||
steps:
|
||||
- run: echo 'job2'
|
||||
`
|
||||
|
||||
opts := getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "create "+wfTreePath, wfFileContent)
|
||||
fileResp := createWorkflowFile(t, token, user2.Name, repo.Name, wfTreePath, opts)
|
||||
require.NotNil(t, fileResp)
|
||||
|
||||
// Start preparing legacy data
|
||||
|
||||
payloads := mustParseSingleWorkflowPayloads(t, wfFileContent)
|
||||
now := timeutil.TimeStamp(time.Now().Unix())
|
||||
started := now - 20
|
||||
stopped := now - 10
|
||||
|
||||
legacyRun := &actions_model.ActionRun{
|
||||
Title: "legacy rerun test",
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
WorkflowID: "actions-rerun-legacy.yml",
|
||||
Index: 1,
|
||||
TriggerUserID: user2.ID,
|
||||
Ref: "refs/heads/" + repo.DefaultBranch,
|
||||
CommitSHA: fileResp.Commit.SHA,
|
||||
Event: "workflow_dispatch",
|
||||
TriggerEvent: "workflow_dispatch",
|
||||
EventPayload: "{}",
|
||||
Status: actions_model.StatusSuccess,
|
||||
Started: started,
|
||||
Stopped: stopped,
|
||||
Created: started - 5,
|
||||
Updated: stopped,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), legacyRun))
|
||||
// xorm does not update "created"-tagged fields via ORM methods; use raw SQL to backfill historical timestamps.
|
||||
_, err := db.GetEngine(t.Context()).Exec("UPDATE action_run SET created=?, updated=? WHERE id=?", int64(started-5), int64(stopped), legacyRun.ID)
|
||||
require.NoError(t, err)
|
||||
legacyRun.Created = started - 5
|
||||
legacyRun.Updated = stopped
|
||||
|
||||
legacyJob1 := &actions_model.ActionRunJob{
|
||||
RunID: legacyRun.ID,
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
CommitSHA: legacyRun.CommitSHA,
|
||||
Name: payloads["job1"].name,
|
||||
Attempt: 1,
|
||||
WorkflowPayload: payloads["job1"].payload,
|
||||
JobID: "job1",
|
||||
Needs: payloads["job1"].needs,
|
||||
RunsOn: payloads["job1"].runsOn,
|
||||
Status: actions_model.StatusSuccess,
|
||||
RunAttemptID: 0,
|
||||
AttemptJobID: 0,
|
||||
Started: started,
|
||||
Stopped: stopped,
|
||||
IsForkPullRequest: false,
|
||||
}
|
||||
legacyJob2 := &actions_model.ActionRunJob{
|
||||
RunID: legacyRun.ID,
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
CommitSHA: legacyRun.CommitSHA,
|
||||
Name: payloads["job2"].name,
|
||||
Attempt: 1,
|
||||
WorkflowPayload: payloads["job2"].payload,
|
||||
JobID: "job2",
|
||||
Needs: payloads["job2"].needs,
|
||||
RunsOn: payloads["job2"].runsOn,
|
||||
Status: actions_model.StatusSuccess,
|
||||
RunAttemptID: 0,
|
||||
AttemptJobID: 0,
|
||||
Started: started,
|
||||
Stopped: stopped,
|
||||
IsForkPullRequest: false,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), legacyJob1, legacyJob2))
|
||||
|
||||
legacyTask1 := &actions_model.ActionTask{
|
||||
JobID: legacyJob1.ID,
|
||||
Attempt: 1,
|
||||
Status: actions_model.StatusSuccess,
|
||||
Started: started,
|
||||
Stopped: stopped,
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
CommitSHA: legacyRun.CommitSHA,
|
||||
IsForkPullRequest: false,
|
||||
}
|
||||
legacyTask1.GenerateAndFillToken()
|
||||
legacyTask2 := &actions_model.ActionTask{
|
||||
JobID: legacyJob2.ID,
|
||||
Attempt: 1,
|
||||
Status: actions_model.StatusSuccess,
|
||||
Started: started,
|
||||
Stopped: stopped,
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
CommitSHA: legacyRun.CommitSHA,
|
||||
IsForkPullRequest: false,
|
||||
}
|
||||
legacyTask2.GenerateAndFillToken()
|
||||
require.NoError(t, db.Insert(t.Context(), legacyTask1, legacyTask2))
|
||||
|
||||
legacyJob1.TaskID = legacyTask1.ID
|
||||
legacyJob2.TaskID = legacyTask2.ID
|
||||
_, err = db.GetEngine(t.Context()).ID(legacyJob1.ID).Cols("task_id").Update(legacyJob1)
|
||||
require.NoError(t, err)
|
||||
_, err = db.GetEngine(t.Context()).ID(legacyJob2.ID).Cols("task_id").Update(legacyJob2)
|
||||
require.NoError(t, err)
|
||||
|
||||
legacyArtifact := &actions_model.ActionArtifact{
|
||||
RunID: legacyRun.ID,
|
||||
RunAttemptID: 0,
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
CommitSHA: legacyRun.CommitSHA,
|
||||
StoragePath: "artifacts/legacy-artifact.zip",
|
||||
FileSize: 123,
|
||||
FileCompressedSize: 123,
|
||||
ContentEncodingOrType: actions_model.ContentTypeZip,
|
||||
ArtifactPath: "legacy-artifact.zip",
|
||||
ArtifactName: "legacy-artifact",
|
||||
Status: actions_model.ArtifactStatusUploadConfirmed,
|
||||
ExpiredUnix: now + timeutil.Day,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), legacyArtifact))
|
||||
|
||||
// Done preparing legacy data
|
||||
|
||||
// assert the web view for the legacy run before rerun
|
||||
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, legacyRun.ID))
|
||||
legacyResp := session.MakeRequest(t, req, http.StatusOK)
|
||||
legacyView := DecodeJSON(t, legacyResp, &actions_web.ViewResponse{})
|
||||
// legacy run has no attempt records, so RunAttempt is 0 and Attempts list is empty
|
||||
assert.EqualValues(t, 0, legacyView.State.Run.RunAttempt)
|
||||
assert.Empty(t, legacyView.State.Run.Attempts)
|
||||
assert.Equal(t, "success", legacyView.State.Run.Status)
|
||||
assert.True(t, legacyView.State.Run.Done)
|
||||
// isLatestAttempt=true, done=true: can rerun but not cancel
|
||||
assert.False(t, legacyView.State.Run.CanCancel)
|
||||
assert.False(t, legacyView.State.Run.CanApprove)
|
||||
assert.True(t, legacyView.State.Run.CanRerun)
|
||||
assert.False(t, legacyView.State.Run.CanRerunFailed) // all jobs succeeded
|
||||
assert.True(t, legacyView.State.Run.CanDeleteArtifact)
|
||||
if assert.Len(t, legacyView.State.Run.Jobs, 2) {
|
||||
assert.Equal(t, legacyJob1.ID, legacyView.State.Run.Jobs[0].ID)
|
||||
assert.Equal(t, legacyJob2.ID, legacyView.State.Run.Jobs[1].ID)
|
||||
}
|
||||
if assert.Len(t, legacyView.Artifacts, 1) {
|
||||
assert.Equal(t, legacyArtifact.ArtifactName, legacyView.Artifacts[0].Name)
|
||||
assert.Equal(t, "completed", legacyView.Artifacts[0].Status)
|
||||
}
|
||||
|
||||
// rerun the legacy run
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, legacyRun.ID))
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
runAfterRerun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
|
||||
assert.EqualValues(t, 2, getRunLatestAttemptNum(t, legacyRun.ID))
|
||||
jobsAfterRerun, err := actions_model.GetRunJobsByRunAndAttemptID(t.Context(), legacyRun.ID, runAfterRerun.LatestAttemptID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, jobsAfterRerun, 2)
|
||||
rerunJobsByJobID := map[string]*actions_model.ActionRunJob{}
|
||||
for _, job := range jobsAfterRerun {
|
||||
rerunJobsByJobID[job.JobID] = job
|
||||
}
|
||||
require.Contains(t, rerunJobsByJobID, "job1")
|
||||
require.Contains(t, rerunJobsByJobID, "job2")
|
||||
assert.Equal(t, actions_model.StatusWaiting, rerunJobsByJobID["job1"].Status)
|
||||
assert.Equal(t, actions_model.StatusBlocked, rerunJobsByJobID["job2"].Status)
|
||||
|
||||
// fetch job1 rerun task
|
||||
job1TaskR1 := runner.fetchTask(t)
|
||||
assert.Equal(t, "2", job1TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
|
||||
rerunJob1Task, rerunJob1, rerunRun := getTaskAndJobAndRunByTaskID(t, job1TaskR1.Id)
|
||||
assert.Equal(t, legacyRun.ID, rerunRun.ID)
|
||||
assert.Equal(t, rerunJob1.RunAttemptID, rerunRun.LatestAttemptID)
|
||||
runner.execTask(t, job1TaskR1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
|
||||
// fetch job2 rerun task
|
||||
job2TaskR1 := runner.fetchTask(t)
|
||||
assert.Equal(t, "2", job2TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
|
||||
rerunJob2Task, rerunJob2, _ := getTaskAndJobAndRunByTaskID(t, job2TaskR1.Id)
|
||||
runner.execTask(t, job2TaskR1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
runner.fetchNoTask(t)
|
||||
|
||||
// query the 2 attempts
|
||||
runAfterRerun = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
|
||||
attempt1, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(t.Context(), legacyRun.ID, 1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, legacyRun.Created, attempt1.Created)
|
||||
assert.Equal(t, legacyRun.Started, attempt1.Started)
|
||||
assert.Equal(t, legacyRun.Stopped, attempt1.Stopped)
|
||||
attempt2, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(t.Context(), legacyRun.ID, 2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, attempt2.ID, runAfterRerun.LatestAttemptID)
|
||||
assert.Equal(t, runAfterRerun.Created, attempt1.Created)
|
||||
assert.Equal(t, runAfterRerun.Started, attempt2.Started)
|
||||
assert.Equal(t, runAfterRerun.Stopped, attempt2.Stopped)
|
||||
|
||||
// assert legacy jobs
|
||||
legacyJob1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: legacyJob1.ID})
|
||||
legacyJob2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: legacyJob2.ID})
|
||||
assert.Equal(t, attempt1.ID, legacyJob1.RunAttemptID)
|
||||
assert.Equal(t, attempt1.ID, legacyJob2.RunAttemptID)
|
||||
assert.EqualValues(t, 1, legacyJob1.Attempt)
|
||||
assert.EqualValues(t, 1, legacyJob2.Attempt)
|
||||
assert.EqualValues(t, 1, legacyJob1.AttemptJobID)
|
||||
assert.EqualValues(t, 2, legacyJob2.AttemptJobID)
|
||||
legacyTask1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: legacyTask1.ID})
|
||||
legacyTask2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: legacyTask2.ID})
|
||||
assert.EqualValues(t, 1, legacyTask1.Attempt)
|
||||
assert.EqualValues(t, 1, legacyTask2.Attempt)
|
||||
|
||||
// assert legacy artifacts
|
||||
legacyArtifact = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionArtifact{ID: legacyArtifact.ID})
|
||||
assert.Equal(t, attempt1.ID, legacyArtifact.RunAttemptID)
|
||||
|
||||
// assert jobs of the latest rerun
|
||||
rerunJob1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: rerunJob1.ID})
|
||||
rerunJob2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: rerunJob2.ID})
|
||||
assert.Equal(t, attempt2.ID, rerunJob1.RunAttemptID)
|
||||
assert.Equal(t, attempt2.ID, rerunJob2.RunAttemptID)
|
||||
assert.Equal(t, legacyJob1.AttemptJobID, rerunJob1.AttemptJobID)
|
||||
assert.Equal(t, legacyJob2.AttemptJobID, rerunJob2.AttemptJobID)
|
||||
assert.EqualValues(t, 2, rerunJob1Task.Attempt)
|
||||
assert.EqualValues(t, 2, rerunJob2Task.Attempt)
|
||||
|
||||
// assert the web view for the original attempt
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/attempts/1", user2.Name, repo.Name, legacyRun.ID))
|
||||
attempt1Resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
attempt1View := DecodeJSON(t, attempt1Resp, &actions_web.ViewResponse{})
|
||||
assert.EqualValues(t, 1, attempt1View.State.Run.RunAttempt)
|
||||
if assert.Len(t, attempt1View.State.Run.Attempts, 2) {
|
||||
// attempts ordered by attempt DESC: index 0 = attempt #2 (latest), index 1 = attempt #1 (current)
|
||||
assert.False(t, attempt1View.State.Run.Attempts[0].Current)
|
||||
assert.True(t, attempt1View.State.Run.Attempts[0].Latest)
|
||||
assert.True(t, attempt1View.State.Run.Attempts[1].Current)
|
||||
assert.False(t, attempt1View.State.Run.Attempts[1].Latest)
|
||||
}
|
||||
// isLatestAttempt=false: all write operations disabled
|
||||
assert.False(t, attempt1View.State.Run.CanCancel)
|
||||
assert.False(t, attempt1View.State.Run.CanApprove)
|
||||
assert.False(t, attempt1View.State.Run.CanRerun)
|
||||
assert.False(t, attempt1View.State.Run.CanRerunFailed)
|
||||
assert.True(t, attempt1View.State.Run.CanDeleteArtifact)
|
||||
assert.Equal(t, legacyJob1.ID, attempt1View.State.Run.Jobs[0].ID)
|
||||
assert.Equal(t, legacyJob2.ID, attempt1View.State.Run.Jobs[1].ID)
|
||||
if assert.Len(t, attempt1View.Artifacts, 1) {
|
||||
assert.Equal(t, attempt1View.Artifacts[0].Name, legacyArtifact.ArtifactName)
|
||||
}
|
||||
|
||||
// assert the web view for the latest attempt
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, legacyRun.ID))
|
||||
attempt2Resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
attempt2View := DecodeJSON(t, attempt2Resp, &actions_web.ViewResponse{})
|
||||
assert.EqualValues(t, 2, attempt2View.State.Run.RunAttempt)
|
||||
if assert.Len(t, attempt2View.State.Run.Attempts, 2) {
|
||||
// attempts ordered by attempt DESC: index 0 = attempt #2 (latest, current), index 1 = attempt #1
|
||||
assert.True(t, attempt2View.State.Run.Attempts[0].Current)
|
||||
assert.True(t, attempt2View.State.Run.Attempts[0].Latest)
|
||||
assert.False(t, attempt2View.State.Run.Attempts[1].Current)
|
||||
assert.False(t, attempt2View.State.Run.Attempts[1].Latest)
|
||||
}
|
||||
// isLatestAttempt=true, done=true: can rerun but not cancel
|
||||
assert.False(t, attempt2View.State.Run.CanCancel)
|
||||
assert.False(t, attempt2View.State.Run.CanApprove)
|
||||
assert.True(t, attempt2View.State.Run.CanRerun)
|
||||
assert.False(t, attempt2View.State.Run.CanRerunFailed) // all jobs succeeded
|
||||
assert.True(t, attempt2View.State.Run.CanDeleteArtifact)
|
||||
assert.Equal(t, rerunJob1.ID, attempt2View.State.Run.Jobs[0].ID)
|
||||
assert.Equal(t, rerunJob2.ID, attempt2View.State.Run.Jobs[1].ID)
|
||||
assert.Empty(t, attempt2View.Artifacts)
|
||||
})
|
||||
}
|
||||
|
||||
type workflowJobPayload struct {
|
||||
name string
|
||||
payload []byte
|
||||
needs []string
|
||||
runsOn []string
|
||||
}
|
||||
|
||||
func mustParseSingleWorkflowPayloads(t *testing.T, workflowContent string) map[string]workflowJobPayload {
|
||||
t.Helper()
|
||||
|
||||
workflows, err := jobparser.Parse([]byte(workflowContent))
|
||||
require.NoError(t, err)
|
||||
|
||||
payloads := make(map[string]workflowJobPayload, len(workflows))
|
||||
for _, workflow := range workflows {
|
||||
id, job := workflow.Job()
|
||||
needs := job.Needs()
|
||||
require.NoError(t, workflow.SetJob(id, job.EraseNeeds()))
|
||||
payload, err := workflow.Marshal()
|
||||
require.NoError(t, err)
|
||||
payloads[id] = workflowJobPayload{
|
||||
name: job.Name,
|
||||
payload: payload,
|
||||
needs: needs,
|
||||
runsOn: job.RunsOn(),
|
||||
}
|
||||
}
|
||||
return payloads
|
||||
}
|
||||
|
||||
func getRunLatestAttemptNum(t *testing.T, runID int64) int64 {
|
||||
t.Helper()
|
||||
|
||||
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
|
||||
attempt := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{ID: run.LatestAttemptID})
|
||||
return attempt.Attempt
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ func testActionsRouteForLegacyIndexBasedURL(t *testing.T) {
|
||||
// Best-effort compatibility prefers the run ID when the same number also exists as a legacy run index.
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, collisionRun.Index))
|
||||
resp = user2Session.MakeRequest(t, req, http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), fmt.Sprintf(`data-run-id="%d"`, normalRun.ID)) // because collisionRun.Index == normalRun.ID
|
||||
assert.Contains(t, resp.Body.String(), fmt.Sprintf(`data-actions-view-url="/%s/%s/actions/runs/%d"`, user2.Name, repo.Name, normalRun.ID))
|
||||
|
||||
// by_index=1 should force the summary page to use the legacy run index interpretation.
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d?by_index=1", user2.Name, repo.Name, collisionRun.Index))
|
||||
|
||||
@@ -4,13 +4,27 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/tests"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type uploadArtifactResponse struct {
|
||||
@@ -393,3 +407,156 @@ func TestActionsArtifactOverwrite(t *testing.T) {
|
||||
assert.Equal(t, resp.Body.String(), body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRunAttemptArtifact(t *testing.T) {
|
||||
defer prepareTestEnvActionsArtifacts(t)()
|
||||
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
session := loginUser(t, user2.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
||||
|
||||
apiRepo := createActionsTestRepo(t, token, "actions-run-attempt-artifact", false)
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
|
||||
httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository)
|
||||
defer doAPIDeleteRepository(httpContext)(t)
|
||||
|
||||
runner := newMockRunner()
|
||||
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
|
||||
|
||||
wfTreePath := ".gitea/workflows/run-attempt-artifact.yml"
|
||||
wfFileContent := `name: run-attempt-artifact
|
||||
on:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo 'job1'
|
||||
`
|
||||
opts := getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "create "+wfTreePath, wfFileContent)
|
||||
createWorkflowFile(t, token, user2.Name, repo.Name, wfTreePath, opts)
|
||||
|
||||
urlStr := fmt.Sprintf("/%s/%s/actions/run?workflow=%s", user2.Name, repo.Name, "run-attempt-artifact.yml")
|
||||
req := NewRequestWithValues(t, "POST", urlStr, map[string]string{
|
||||
"ref": "refs/heads/main",
|
||||
})
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
|
||||
t.Run("testActionRunAttemptArtifactV3", func(t *testing.T) {
|
||||
testActionRunAttemptArtifactV3(t, repo, session, runner)
|
||||
})
|
||||
|
||||
t.Run("testActionRunAttemptArtifactV4", func(t *testing.T) {
|
||||
testActionRunAttemptArtifactV4(t, repo, session, runner)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func testActionRunAttemptArtifactV3(t *testing.T, repo *repo_model.Repository, session *TestSession, runner *mockRunner) {
|
||||
// first run
|
||||
task1 := runner.fetchTask(t)
|
||||
_, job1, run := getTaskAndJobAndRunByTaskID(t, task1.Id)
|
||||
require.NotZero(t, job1.RunAttemptID)
|
||||
taskToken1 := task1.Context.GetFields()["gitea_runtime_token"].GetStringValue()
|
||||
require.NotEmpty(t, taskToken1)
|
||||
uploadTestArtifactFile(t, run.ID, taskToken1, "artifact-attempt-1", "attempt-1.txt", strings.Repeat("A", 32))
|
||||
uploadTestArtifactFile(t, run.ID, taskToken1, "artifact-shared", "shared.txt", strings.Repeat("C", 32))
|
||||
attempt1Names := listArtifactNamesForRun(t, run.ID, taskToken1)
|
||||
assert.ElementsMatch(t, []string{"artifact-attempt-1", "artifact-shared"}, attempt1Names)
|
||||
|
||||
runner.execTask(t, task1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS}) // complete first run
|
||||
|
||||
// rerun
|
||||
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", repo.OwnerName, repo.Name, run.ID))
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
task2 := runner.fetchTask(t)
|
||||
_, job2, _ := getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
require.NotZero(t, job2.RunAttemptID)
|
||||
assert.NotEqual(t, job1.RunAttemptID, job2.RunAttemptID)
|
||||
taskToken2 := task2.Context.GetFields()["gitea_runtime_token"].GetStringValue()
|
||||
require.NotEmpty(t, taskToken2)
|
||||
uploadTestArtifactFile(t, run.ID, taskToken2, "artifact-attempt-2", "attempt-2.txt", strings.Repeat("B", 32))
|
||||
uploadTestArtifactFile(t, run.ID, taskToken2, "artifact-shared", "shared.txt", strings.Repeat("D", 32))
|
||||
attempt2Names := listArtifactNamesForRun(t, run.ID, taskToken2)
|
||||
assert.ElementsMatch(t, []string{"artifact-attempt-2", "artifact-shared"}, attempt2Names)
|
||||
assert.NotContains(t, attempt2Names, "artifact-attempt-1")
|
||||
|
||||
// "artifact-attempt-1" belongs to the first attempt, so the rerun token cannot access it
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts/%x/download_url?itemPath=artifact-attempt-1", run.ID, md5.Sum([]byte("artifact-attempt-1")))).
|
||||
AddTokenAuth(taskToken2)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
|
||||
// "artifact-shared" for each attempt has different content
|
||||
sharedContent1 := downloadArtifactFileContentByAttempt(t, session, repo.OwnerName, repo.Name, run.ID, "artifact-shared", 1, "shared.txt")
|
||||
assert.Equal(t, strings.Repeat("C", 32), sharedContent1)
|
||||
sharedContent2 := downloadArtifactFileContentByAttempt(t, session, repo.OwnerName, repo.Name, run.ID, "artifact-shared", 2, "shared.txt")
|
||||
assert.Equal(t, strings.Repeat("D", 32), sharedContent2)
|
||||
}
|
||||
|
||||
func uploadTestArtifactFile(t *testing.T, runID int64, authToken, artifactName, fileName, content string) {
|
||||
t.Helper()
|
||||
|
||||
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts", runID), getUploadArtifactRequest{
|
||||
Type: "actions_storage",
|
||||
Name: artifactName,
|
||||
}).AddTokenAuth(authToken)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var uploadResp uploadArtifactResponse
|
||||
DecodeJSON(t, resp, &uploadResp)
|
||||
|
||||
idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
|
||||
uploadURL := uploadResp.FileContainerResourceURL[idx:] + "?itemPath=" + artifactName + "/" + fileName
|
||||
contentLen := strconv.Itoa(len(content))
|
||||
contentMD5 := md5.Sum([]byte(content))
|
||||
req = NewRequestWithBody(t, "PUT", uploadURL, strings.NewReader(content)).
|
||||
AddTokenAuth(authToken).
|
||||
SetHeader("Content-Range", fmt.Sprintf("bytes 0-%d/%d", len(content)-1, len(content))).
|
||||
SetHeader("x-tfs-filelength", contentLen).
|
||||
SetHeader("x-actions-results-md5", base64.StdEncoding.EncodeToString(contentMD5[:]))
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
req = NewRequest(t, "PATCH", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts?artifactName=%s", runID, artifactName)).
|
||||
AddTokenAuth(authToken)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
}
|
||||
|
||||
func listArtifactNamesForRun(t *testing.T, runID int64, taskToken string) []string {
|
||||
t.Helper()
|
||||
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts", runID)).
|
||||
AddTokenAuth(taskToken)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var listResp listArtifactsResponse
|
||||
DecodeJSON(t, resp, &listResp)
|
||||
|
||||
names := make([]string, 0, len(listResp.Value))
|
||||
for _, item := range listResp.Value {
|
||||
names = append(names, item.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func downloadArtifactFileContentByAttempt(t *testing.T, session *TestSession, owner, repo string, runID int64, artifactName string, attempt int64, fileName string) string {
|
||||
t.Helper()
|
||||
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/artifacts/%s?attempt=%d", owner, repo, runID, url.PathEscape(artifactName), attempt))
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(resp.Body.Bytes()), int64(resp.Body.Len()))
|
||||
require.NoError(t, err)
|
||||
for _, f := range zr.File {
|
||||
if f.Name != fileName {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
require.NoError(t, err)
|
||||
content, err := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
require.NoError(t, err)
|
||||
return string(content)
|
||||
}
|
||||
|
||||
require.FailNowf(t, "artifact file not found", "artifact %q attempt %d does not contain file %q", artifactName, attempt, fileName)
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -31,6 +32,7 @@ import (
|
||||
"code.gitea.io/gitea/routers/api/actions"
|
||||
actions_service "code.gitea.io/gitea/services/actions"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
@@ -880,3 +882,127 @@ func TestActionsArtifactV4DeletePublicApiNotAllowedReadScope(t *testing.T) {
|
||||
AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
}
|
||||
|
||||
func testActionRunAttemptArtifactV4(t *testing.T, repo *repo_model.Repository, session *TestSession, runner *mockRunner) {
|
||||
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/actions/run?workflow=%s", repo.OwnerName, repo.Name, "run-attempt-artifact.yml"), map[string]string{
|
||||
"ref": "refs/heads/main",
|
||||
})
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
|
||||
// first run
|
||||
task1 := runner.fetchTask(t)
|
||||
_, job1, run := getTaskAndJobAndRunByTaskID(t, task1.Id)
|
||||
require.NotZero(t, job1.RunAttemptID)
|
||||
taskToken1 := task1.Context.GetFields()["gitea_runtime_token"].GetStringValue()
|
||||
require.NotEmpty(t, taskToken1)
|
||||
uploadTestArtifactFileV4(t, run.ID, job1.ID, taskToken1, "artifact-attempt-1", strings.Repeat("A", 32))
|
||||
uploadTestArtifactFileV4(t, run.ID, job1.ID, taskToken1, "artifact-shared", strings.Repeat("C", 32))
|
||||
attempt1Names := listArtifactNamesForRunV4(t, run.ID, job1.ID, taskToken1)
|
||||
assert.ElementsMatch(t, []string{"artifact-attempt-1", "artifact-shared"}, attempt1Names)
|
||||
|
||||
runner.execTask(t, task1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
|
||||
// rerun
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", repo.OwnerName, repo.Name, run.ID))
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
task2 := runner.fetchTask(t)
|
||||
_, job2, _ := getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
require.NotZero(t, job2.RunAttemptID)
|
||||
assert.NotEqual(t, job1.RunAttemptID, job2.RunAttemptID)
|
||||
taskToken2 := task2.Context.GetFields()["gitea_runtime_token"].GetStringValue()
|
||||
require.NotEmpty(t, taskToken2)
|
||||
uploadTestArtifactFileV4(t, run.ID, job2.ID, taskToken2, "artifact-attempt-2", strings.Repeat("B", 32))
|
||||
uploadTestArtifactFileV4(t, run.ID, job2.ID, taskToken2, "artifact-shared", strings.Repeat("D", 32))
|
||||
attempt2Names := listArtifactNamesForRunV4(t, run.ID, job2.ID, taskToken2)
|
||||
assert.ElementsMatch(t, []string{"artifact-attempt-2", "artifact-shared"}, attempt2Names)
|
||||
assert.NotContains(t, attempt2Names, "artifact-attempt-1")
|
||||
|
||||
// "artifact-attempt-1" belongs to the first attempt, so the rerun token cannot access it
|
||||
req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/GetSignedArtifactURL", toProtoJSON(&actions.GetSignedArtifactURLRequest{
|
||||
Name: "artifact-attempt-1",
|
||||
WorkflowRunBackendId: strconv.FormatInt(run.ID, 10),
|
||||
WorkflowJobRunBackendId: strconv.FormatInt(job2.ID, 10),
|
||||
})).AddTokenAuth(taskToken2)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
|
||||
// the run-scoped repo API should list finalized v4 artifacts from all attempts
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/artifacts", repo.OwnerName, repo.Name, run.ID))
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
var runArtifactsResp api.ActionArtifactsResponse
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &runArtifactsResp))
|
||||
require.Len(t, runArtifactsResp.Entries, 4)
|
||||
runArtifactNames := make([]string, 0, len(runArtifactsResp.Entries))
|
||||
for _, artifact := range runArtifactsResp.Entries {
|
||||
runArtifactNames = append(runArtifactNames, artifact.Name)
|
||||
}
|
||||
assert.ElementsMatch(t, []string{"artifact-attempt-1", "artifact-shared", "artifact-attempt-2", "artifact-shared"}, runArtifactNames)
|
||||
|
||||
// the result should contain 2 artifacts when query by name=artifact-shared
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/artifacts?name=artifact-shared", repo.OwnerName, repo.Name, run.ID))
|
||||
resp = session.MakeRequest(t, req, http.StatusOK)
|
||||
var sharedArtifactsResp api.ActionArtifactsResponse
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &sharedArtifactsResp))
|
||||
require.Len(t, sharedArtifactsResp.Entries, 2)
|
||||
assert.Equal(t, strings.Repeat("C", 32), downloadRepoArtifactV4Content(t, session, sharedArtifactsResp.Entries[0].ArchiveDownloadURL))
|
||||
assert.Equal(t, strings.Repeat("D", 32), downloadRepoArtifactV4Content(t, session, sharedArtifactsResp.Entries[1].ArchiveDownloadURL))
|
||||
}
|
||||
|
||||
func uploadTestArtifactFileV4(t *testing.T, runID, jobID int64, authToken, artifactName, content string) {
|
||||
t.Helper()
|
||||
|
||||
req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", toProtoJSON(&actions.CreateArtifactRequest{
|
||||
Version: 4,
|
||||
Name: artifactName,
|
||||
WorkflowRunBackendId: strconv.FormatInt(runID, 10),
|
||||
WorkflowJobRunBackendId: strconv.FormatInt(jobID, 10),
|
||||
MimeType: wrapperspb.String("application/zip"),
|
||||
})).AddTokenAuth(authToken)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var uploadResp actions.CreateArtifactResponse
|
||||
require.NoError(t, protojson.Unmarshal(resp.Body.Bytes(), &uploadResp))
|
||||
require.True(t, uploadResp.Ok)
|
||||
|
||||
req = NewRequestWithBody(t, "PUT", uploadResp.SignedUploadUrl+"&comp=appendBlock", strings.NewReader(content))
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact", toProtoJSON(&actions.FinalizeArtifactRequest{
|
||||
Name: artifactName,
|
||||
Size: int64(len(content)),
|
||||
Hash: wrapperspb.String("sha256:" + hex.EncodeToString(sum[:])),
|
||||
WorkflowRunBackendId: strconv.FormatInt(runID, 10),
|
||||
WorkflowJobRunBackendId: strconv.FormatInt(jobID, 10),
|
||||
})).AddTokenAuth(authToken)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
var finalizeResp actions.FinalizeArtifactResponse
|
||||
require.NoError(t, protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp))
|
||||
require.True(t, finalizeResp.Ok)
|
||||
}
|
||||
|
||||
func listArtifactNamesForRunV4(t *testing.T, runID, jobID int64, taskToken string) []string {
|
||||
t.Helper()
|
||||
|
||||
req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts", toProtoJSON(&actions.ListArtifactsRequest{
|
||||
WorkflowRunBackendId: strconv.FormatInt(runID, 10),
|
||||
WorkflowJobRunBackendId: strconv.FormatInt(jobID, 10),
|
||||
})).AddTokenAuth(taskToken)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var listResp actions.ListArtifactsResponse
|
||||
require.NoError(t, protojson.Unmarshal(resp.Body.Bytes(), &listResp))
|
||||
|
||||
names := make([]string, 0, len(listResp.Artifacts))
|
||||
for _, item := range listResp.Artifacts {
|
||||
names = append(names, item.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func downloadRepoArtifactV4Content(t *testing.T, session *TestSession, archiveDownloadURL string) string {
|
||||
t.Helper()
|
||||
|
||||
req := NewRequest(t, "GET", archiveDownloadURL)
|
||||
resp := session.MakeRequest(t, req, http.StatusFound)
|
||||
req = NewRequest(t, "GET", resp.Header().Get("Location"))
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
return resp.Body.String()
|
||||
}
|
||||
|
||||
@@ -208,15 +208,18 @@ func TestAPIActionsRerunWorkflowRun(t *testing.T) {
|
||||
assert.Equal(t, actions_model.StatusWaiting, run.Status)
|
||||
assert.Equal(t, timeutil.TimeStamp(0), run.Started)
|
||||
assert.Equal(t, timeutil.TimeStamp(0), run.Stopped)
|
||||
|
||||
job198, err := actions_model.GetRunJobByRunAndID(t.Context(), 795, 198)
|
||||
latestAttempt, hasLatestAttempt, err := run.GetLatestAttempt(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.True(t, hasLatestAttempt)
|
||||
|
||||
job198 := getLatestAttemptJobByTemplateJobID(t, 795, 198)
|
||||
assert.Equal(t, actions_model.StatusWaiting, job198.Status)
|
||||
assert.Equal(t, latestAttempt.Attempt, job198.Attempt)
|
||||
assert.Equal(t, int64(0), job198.TaskID)
|
||||
|
||||
job199, err := actions_model.GetRunJobByRunAndID(t.Context(), 795, 199)
|
||||
require.NoError(t, err)
|
||||
job199 := getLatestAttemptJobByTemplateJobID(t, 795, 199)
|
||||
assert.Equal(t, actions_model.StatusWaiting, job199.Status)
|
||||
assert.Equal(t, latestAttempt.Attempt, job199.Attempt)
|
||||
assert.Equal(t, int64(0), job199.TaskID)
|
||||
})
|
||||
|
||||
@@ -262,22 +265,28 @@ func TestAPIActionsRerunWorkflowJob(t *testing.T) {
|
||||
var rerunResp api.ActionWorkflowJob
|
||||
err := json.Unmarshal(resp.Body.Bytes(), &rerunResp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(199), rerunResp.ID)
|
||||
job199Rerun := getLatestAttemptJobByTemplateJobID(t, 795, 199)
|
||||
assert.Equal(t, job199Rerun.ID, rerunResp.ID)
|
||||
assert.Equal(t, "queued", rerunResp.Status)
|
||||
|
||||
run, err := actions_model.GetRunByRepoAndID(t.Context(), repo.ID, 795)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, actions_model.StatusWaiting, run.Status)
|
||||
|
||||
job198, err := actions_model.GetRunJobByRunAndID(t.Context(), 795, 198)
|
||||
latestAttempt, hasLatestAttempt, err := run.GetLatestAttempt(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, actions_model.StatusSuccess, job198.Status)
|
||||
assert.Equal(t, int64(53), job198.TaskID)
|
||||
require.True(t, hasLatestAttempt)
|
||||
|
||||
job199, err := actions_model.GetRunJobByRunAndID(t.Context(), 795, 199)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, actions_model.StatusWaiting, job199.Status)
|
||||
assert.Equal(t, int64(0), job199.TaskID)
|
||||
job198Rerun := getLatestAttemptJobByTemplateJobID(t, 795, 198)
|
||||
assert.Equal(t, actions_model.StatusSuccess, job198Rerun.Status)
|
||||
assert.Equal(t, latestAttempt.Attempt, job198Rerun.Attempt)
|
||||
assert.Equal(t, int64(0), job198Rerun.TaskID)
|
||||
assert.Equal(t, int64(53), job198Rerun.SourceTaskID)
|
||||
|
||||
job199Rerun = getLatestAttemptJobByTemplateJobID(t, 795, 199)
|
||||
assert.Equal(t, actions_model.StatusWaiting, job199Rerun.Status)
|
||||
assert.Equal(t, latestAttempt.Attempt, job199Rerun.Attempt)
|
||||
assert.Equal(t, int64(0), job199Rerun.TaskID)
|
||||
assert.Equal(t, int64(0), job199Rerun.SourceTaskID)
|
||||
})
|
||||
|
||||
t.Run("ForbiddenWithoutWriteScope", func(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user