diff --git a/models/actions/task.go b/models/actions/task.go
index e092d6fbbd948..1667733759481 100644
--- a/models/actions/task.go
+++ b/models/actions/task.go
@@ -29,7 +29,7 @@ import (
// ActionTask represents a distribution of job
type ActionTask struct {
ID int64
- JobID int64
+ JobID int64 `xorm:"index"`
Job *ActionRunJob `xorm:"-"`
Steps []*ActionTaskStep `xorm:"-"`
Attempt int64
@@ -164,6 +164,30 @@ func GetTaskByID(ctx context.Context, id int64) (*ActionTask, error) {
return &task, nil
}
+// GetTasksByJobID returns lightweight task metadata for all attempts of a job,
+// ordered by attempt ascending. Only fields needed for the attempts list are selected
+// to avoid loading LogIndexes (LONGBLOB) on every request.
+func GetTasksByJobID(ctx context.Context, jobID int64) ([]*ActionTask, error) {
+ var tasks []*ActionTask
+ return tasks, db.GetEngine(ctx).
+ Cols("id", "job_id", "attempt", "status", "started", "stopped", "log_expired").
+ Where("job_id=?", jobID).
+ OrderBy("attempt ASC").
+ Find(&tasks)
+}
+
+// GetTaskByJobAndAttempt returns the task for a specific attempt of a job.
+func GetTaskByJobAndAttempt(ctx context.Context, jobID, attempt int64) (*ActionTask, error) {
+ var task ActionTask
+ has, err := db.GetEngine(ctx).Where("job_id=? AND attempt=?", jobID, attempt).Get(&task)
+ if err != nil {
+ return nil, err
+ } else if !has {
+ return nil, util.NewNotExistErrorf("task with job_id %d attempt %d", jobID, attempt)
+ }
+ return &task, nil
+}
+
func GetRunningTaskByToken(ctx context.Context, token string) (*ActionTask, error) {
errNotExist := fmt.Errorf("task with token %q: %w", token, util.ErrNotExist)
if token == "" {
diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json
index acf45d989a810..5d2d6b8fffc9d 100644
--- a/options/locale/locale_en-US.json
+++ b/options/locale/locale_en-US.json
@@ -3779,6 +3779,8 @@
"actions.variables.update.success": "The variable has been edited.",
"actions.logs.always_auto_scroll": "Always auto scroll logs",
"actions.logs.always_expand_running": "Always expand running logs",
+ "actions.attempt": "Attempt",
+ "actions.previous_logs": "Previous logs",
"actions.general": "General",
"actions.general.enable_actions": "Enable Actions",
"actions.general.collaborative_owners_management": "Collaborative Owners Management",
diff --git a/routers/api/v1/repo/actions_run.go b/routers/api/v1/repo/actions_run.go
index 64ac1a3ad5f02..b9cdf71d09f22 100644
--- a/routers/api/v1/repo/actions_run.go
+++ b/routers/api/v1/repo/actions_run.go
@@ -5,6 +5,7 @@ package repo
import (
"errors"
+ "net/http"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/modules/util"
@@ -34,6 +35,11 @@ func DownloadActionsRunJobLogs(ctx *context.APIContext) {
// description: id of the job
// type: integer
// required: true
+ // - name: attempt
+ // in: query
+ // description: the attempt number of the job (0 or omit for latest)
+ // type: integer
+ // required: false
// responses:
// "200":
// description: output blob content
@@ -43,6 +49,11 @@ func DownloadActionsRunJobLogs(ctx *context.APIContext) {
// "$ref": "#/responses/notFound"
jobID := ctx.PathParamInt64("job_id")
+ attempt := ctx.FormInt64("attempt")
+ if attempt < 0 {
+ ctx.APIError(http.StatusBadRequest, util.NewInvalidArgumentErrorf("attempt must be >= 0"))
+ return
+ }
curJob, err := actions_model.GetRunJobByRepoAndID(ctx, ctx.Repo.Repository.ID, jobID)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
@@ -57,7 +68,7 @@ func DownloadActionsRunJobLogs(ctx *context.APIContext) {
return
}
- err = common.DownloadActionsRunJobLogs(ctx.Base, ctx.Repo.Repository, curJob)
+ err = common.DownloadActionsRunJobLogs(ctx.Base, ctx.Repo.Repository, curJob, attempt)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.APIErrorNotFound(err)
diff --git a/routers/common/actions.go b/routers/common/actions.go
index 4eb7078db6754..ab02e434e899e 100644
--- a/routers/common/actions.go
+++ b/routers/common/actions.go
@@ -15,7 +15,7 @@ import (
"code.gitea.io/gitea/services/context"
)
-func DownloadActionsRunJobLogsWithID(ctx *context.Base, ctxRepo *repo_model.Repository, runID, jobID int64) error {
+func DownloadActionsRunJobLogsWithID(ctx *context.Base, ctxRepo *repo_model.Repository, runID, jobID, attempt int64) error {
job, err := actions_model.GetRunJobByRunAndID(ctx, runID, jobID)
if err != nil {
return err
@@ -23,25 +23,33 @@ func DownloadActionsRunJobLogsWithID(ctx *context.Base, ctxRepo *repo_model.Repo
if err := job.LoadRepo(ctx); err != nil {
return fmt.Errorf("LoadRepo: %w", err)
}
- return DownloadActionsRunJobLogs(ctx, ctxRepo, job)
+ return DownloadActionsRunJobLogs(ctx, ctxRepo, job, attempt)
}
-func DownloadActionsRunJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository, curJob *actions_model.ActionRunJob) error {
+func DownloadActionsRunJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository, curJob *actions_model.ActionRunJob, attempt int64) error {
if curJob.Repo.ID != ctxRepo.ID {
return util.NewNotExistErrorf("job not found")
}
- if curJob.TaskID == 0 {
- return util.NewNotExistErrorf("job not started")
- }
-
if err := curJob.LoadRun(ctx); err != nil {
return fmt.Errorf("LoadRun: %w", err)
}
- task, err := actions_model.GetTaskByID(ctx, curJob.TaskID)
- if err != nil {
- return fmt.Errorf("GetTaskByID: %w", err)
+ var task *actions_model.ActionTask
+ var err error
+ if attempt > 0 {
+ task, err = actions_model.GetTaskByJobAndAttempt(ctx, curJob.ID, attempt)
+ if err != nil {
+ return fmt.Errorf("GetTaskByJobAndAttempt: %w", err)
+ }
+ } else {
+ if curJob.TaskID == 0 {
+ return util.NewNotExistErrorf("job not started")
+ }
+ task, err = actions_model.GetTaskByID(ctx, curJob.TaskID)
+ if err != nil {
+ return fmt.Errorf("GetTaskByID: %w", err)
+ }
}
if task.LogExpired {
diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go
index 0fb2a358243c0..f50927722069b 100644
--- a/routers/web/devtest/mock_actions.go
+++ b/routers/web/devtest/mock_actions.go
@@ -4,6 +4,7 @@
package devtest
import (
+ "fmt"
mathRand "math/rand/v2"
"net/http"
"slices"
@@ -30,6 +31,7 @@ func generateMockStepsLog(logCur actions.LogCursor, opts generateMockStepsLogOpt
mockedLogs = append(mockedLogs, "::group::test group for: step={step}, cursor={cursor}")
mockedLogs = append(mockedLogs, slices.Repeat([]string{"in group msg for: step={step}, cursor={cursor}"}, opts.groupRepeat)...)
mockedLogs = append(mockedLogs, "::endgroup::")
+ mockedLogs = append(mockedLogs, "::error::error message for: step={step}, cursor={cursor}")
mockedLogs = append(mockedLogs,
"message for: step={step}, cursor={cursor}",
"message for: step={step}, cursor={cursor}",
@@ -64,6 +66,17 @@ func MockActionsView(ctx *context.Context) {
ctx.HTML(http.StatusOK, "devtest/repo-action-view")
}
+func MockActionsJobLogs(ctx *context.Context) {
+ runID := ctx.PathParamInt64("run")
+ jobID := ctx.PathParamInt64("job")
+ attempt := ctx.FormInt64("attempt")
+ if attempt <= 0 {
+ attempt = 3
+ }
+
+ ctx.PlainText(http.StatusOK, fmt.Sprintf("mock run=%d job=%d attempt=%d log line 1\nmock run=%d job=%d attempt=%d log line 2\n", runID, jobID, attempt, runID, jobID, attempt))
+}
+
func MockActionsRunsJobs(ctx *context.Context) {
runID := ctx.PathParamInt64("run")
@@ -122,6 +135,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
Name: "job 100",
Status: actions_model.StatusRunning.String(),
CanRerun: true,
+ Attempt: 3,
Duration: "1h",
})
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
@@ -130,6 +144,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
Name: "job 101",
Status: actions_model.StatusWaiting.String(),
CanRerun: false,
+ Attempt: 1,
Duration: "2h",
Needs: []string{"job-100"},
})
@@ -139,6 +154,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
Name: "ULTRA LOOOOOOOOOOOONG job name 102 that exceeds the limit",
Status: actions_model.StatusFailure.String(),
CanRerun: false,
+ Attempt: 2,
Duration: "3h",
Needs: []string{"job-100", "job-101"},
})
@@ -148,6 +164,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
Name: "job 103",
Status: actions_model.StatusCancelled.String(),
CanRerun: false,
+ Attempt: 1,
Duration: "2m",
Needs: []string{"job-100"},
})
@@ -161,6 +178,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
Name: "job dup test " + strconv.Itoa(i),
Status: actions_model.StatusSuccess.String(),
CanRerun: false,
+ Attempt: 1,
Duration: "2m",
Needs: []string{"job-103", "job-101", "job-100"},
})
@@ -178,6 +196,16 @@ func fillViewRunResponseCurrentJob(ctx *context.Context, resp *actions.ViewRespo
}
req := web.GetForm(ctx).(*actions.ViewRequest)
+
+ if ctx.PathParamInt64("run") == 10 && jobID == 100 {
+ resp.State.CurrentJob.Attempt = 4
+ resp.State.CurrentJob.AvailableAttempts = []*actions.ViewAttempt{
+ {Attempt: 1, Status: actions_model.StatusFailure.String(), Started: time.Now().Add(-2 * time.Hour).Unix(), Stopped: time.Now().Add(-110 * time.Minute).Unix()},
+ {Attempt: 2, Status: actions_model.StatusCancelled.String(), Started: time.Now().Add(-90 * time.Minute).Unix(), Stopped: time.Now().Add(-80 * time.Minute).Unix()},
+ {Attempt: 3, Status: actions_model.StatusSuccess.String(), Started: time.Now().Add(-10 * time.Minute).Unix(), LogExpired: true},
+ }
+ }
+
var mockLogOptions []generateMockStepsLogOptions
resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{
Summary: "step 0 (mock slow)",
diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go
index 6b3e95f3dafb9..760cc0eefbfd8 100644
--- a/routers/web/repo/actions/view.go
+++ b/routers/web/repo/actions/view.go
@@ -151,9 +151,11 @@ type ViewResponse struct {
TriggerEvent string `json:"triggerEvent"` // e.g. pull_request, push, schedule
} `json:"run"`
CurrentJob struct {
- Title string `json:"title"`
- Detail string `json:"detail"`
- Steps []*ViewJobStep `json:"steps"`
+ Title string `json:"title"`
+ Detail string `json:"detail"`
+ Attempt int64 `json:"attempt"`
+ Steps []*ViewJobStep `json:"steps"`
+ AvailableAttempts []*ViewAttempt `json:"availableAttempts"`
} `json:"currentJob"`
} `json:"state"`
Logs struct {
@@ -167,6 +169,7 @@ type ViewJob struct {
Name string `json:"name"`
Status string `json:"status"`
CanRerun bool `json:"canRerun"`
+ Attempt int64 `json:"attempt"`
Duration string `json:"duration"`
Needs []string `json:"needs,omitempty"`
}
@@ -195,6 +198,14 @@ type ViewJobStep struct {
Status string `json:"status"`
}
+type ViewAttempt struct {
+ Attempt int64 `json:"attempt"`
+ Status string `json:"status"`
+ Started int64 `json:"started"` // unix seconds
+ Stopped int64 `json:"stopped"` // unix seconds
+ LogExpired bool `json:"logExpired"`
+}
+
type ViewStepLog struct {
Step int `json:"step"`
Cursor int64 `json:"cursor"`
@@ -283,6 +294,7 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
Name: v.Name,
Status: v.Status.String(),
CanRerun: resp.State.Run.CanRerun,
+ Attempt: v.Attempt,
Duration: v.Duration().String(),
Needs: v.Needs,
})
@@ -327,6 +339,26 @@ func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewRespon
return
}
+ if current.Attempt > 1 {
+ allTasks, err := actions_model.GetTasksByJobID(ctx, current.ID)
+ if err != nil {
+ ctx.ServerError("actions_model.GetTasksByJobID", err)
+ return
+ }
+ for _, t := range allTasks {
+ if t.Attempt == current.Attempt {
+ continue
+ }
+ resp.State.CurrentJob.AvailableAttempts = append(resp.State.CurrentJob.AvailableAttempts, &ViewAttempt{
+ Attempt: t.Attempt,
+ Status: t.Status.String(),
+ Started: t.Started.AsTime().Unix(),
+ Stopped: t.Stopped.AsTime().Unix(),
+ LogExpired: t.LogExpired,
+ })
+ }
+ }
+
var task *actions_model.ActionTask
if current.TaskID > 0 {
var err error
@@ -344,6 +376,7 @@ func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewRespon
resp.State.CurrentJob.Title = current.Name
resp.State.CurrentJob.Detail = current.Status.LocaleString(ctx.Locale)
+ resp.State.CurrentJob.Attempt = current.Attempt
if run.NeedApproval {
resp.State.CurrentJob.Detail = ctx.Locale.TrString("actions.need_approval_desc")
}
@@ -375,7 +408,7 @@ func convertToViewModel(ctx context.Context, locale translation.Locale, cursors
}
for _, cursor := range cursors {
- if !cursor.Expanded {
+ if !cursor.Expanded || cursor.Step < 0 || cursor.Step >= len(steps) {
continue
}
@@ -514,8 +547,13 @@ func Logs(ctx *context_module.Context) {
return
}
jobID := ctx.PathParamInt64("job")
+ attempt := ctx.FormInt64("attempt")
+ if attempt < 0 {
+ ctx.HTTPError(http.StatusBadRequest, "attempt")
+ return
+ }
- if err := common.DownloadActionsRunJobLogsWithID(ctx.Base, ctx.Repo.Repository, run.ID, jobID); err != nil {
+ if err := common.DownloadActionsRunJobLogsWithID(ctx.Base, ctx.Repo.Repository, run.ID, jobID, attempt); err != nil {
ctx.NotFoundOrServerError("DownloadActionsRunJobLogsWithID", func(err error) bool {
return errors.Is(err, util.ErrNotExist)
}, err)
diff --git a/routers/web/web.go b/routers/web/web.go
index e3dcf27cc4afe..bbe1f867a605e 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1749,6 +1749,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Any("/{sub}", devtest.TmplCommon)
m.Get("/repo-action-view/runs/{run}", devtest.MockActionsView)
m.Get("/repo-action-view/runs/{run}/jobs/{job}", devtest.MockActionsView)
+ m.Get("/repo-action-view/runs/{run}/jobs/{job}/logs", devtest.MockActionsJobLogs)
m.Post("/repo-action-view/runs/{run}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)
m.Post("/repo-action-view/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)
})
diff --git a/templates/repo/actions/view_component.tmpl b/templates/repo/actions/view_component.tmpl
index 59b5c9cbf9dec..1d1c4f8522df3 100644
--- a/templates/repo/actions/view_component.tmpl
+++ b/templates/repo/actions/view_component.tmpl
@@ -33,5 +33,7 @@
data-locale-download-logs="{{ctx.Locale.Tr "download_logs"}}"
data-locale-logs-always-auto-scroll="{{ctx.Locale.Tr "actions.logs.always_auto_scroll"}}"
data-locale-logs-always-expand-running="{{ctx.Locale.Tr "actions.logs.always_expand_running"}}"
+ data-locale-attempt="{{ctx.Locale.Tr "actions.attempt"}}"
+ data-locale-previous-logs="{{ctx.Locale.Tr "actions.previous_logs"}}"
>
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index b4247e261243d..04c9085267f41 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -4938,6 +4938,12 @@
"name": "job_id",
"in": "path",
"required": true
+ },
+ {
+ "type": "integer",
+ "description": "the attempt number of the job (0 or omit for latest)",
+ "name": "attempt",
+ "in": "query"
}
],
"responses": {
diff --git a/tests/integration/actions_route_test.go b/tests/integration/actions_route_test.go
index 91d56507ede9f..c3e68a0e67833 100644
--- a/tests/integration/actions_route_test.go
+++ b/tests/integration/actions_route_test.go
@@ -5,6 +5,7 @@ package integration
import (
"fmt"
+ "io"
"net/http"
"net/url"
"testing"
@@ -16,6 +17,7 @@ import (
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestActionsRoute(t *testing.T) {
@@ -98,3 +100,72 @@ jobs:
user2Session.MakeRequest(t, req, http.StatusNotFound)
})
}
+
+func TestActionsRouteJobAttemptLogs(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)
+
+ repo := createActionsTestRepo(t, token, "actions-attempt-logs", false)
+ runner := newMockRunner()
+ runner.registerAsRepoRunner(t, user2.Name, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
+
+ workflowTreePath := ".gitea/workflows/test.yml"
+ workflowContent := `name: test
+on:
+ push:
+ paths:
+ - '.gitea/workflows/test.yml'
+jobs:
+ job1:
+ runs-on: ubuntu-latest
+ steps:
+ - run: echo job1
+`
+
+ opts := getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "create "+workflowTreePath, workflowContent)
+ createWorkflowFile(t, token, user2.Name, repo.Name, workflowTreePath, opts)
+
+ task1 := runner.fetchTask(t)
+ _, job, run := getTaskAndJobAndRunByTaskID(t, task1.Id)
+ runner.execTask(t, task1, &mockTaskOutcome{
+ result: runnerv1.Result_RESULT_SUCCESS,
+ logRows: []*runnerv1.LogRow{{Content: "attempt-1"}},
+ })
+
+ req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, run.ID, job.ID))
+ session.MakeRequest(t, req, http.StatusOK)
+
+ task2 := runner.fetchTask(t)
+ runner.execTask(t, task2, &mockTaskOutcome{
+ result: runnerv1.Result_RESULT_SUCCESS,
+ logRows: []*runnerv1.LogRow{{Content: "attempt-2"}},
+ })
+ runner.fetchNoTask(t)
+
+ req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo.Name, run.ID, job.ID))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ var viewResp actions_web.ViewResponse
+ DecodeJSON(t, resp, &viewResp)
+ assert.Equal(t, int64(2), viewResp.State.CurrentJob.Attempt)
+ require.Len(t, viewResp.State.CurrentJob.AvailableAttempts, 1)
+ assert.Equal(t, int64(1), viewResp.State.CurrentJob.AvailableAttempts[0].Attempt)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs?attempt=1", user2.Name, repo.Name, run.ID, job.ID))
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ body, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ assert.Contains(t, string(body), "attempt-1")
+ assert.NotContains(t, string(body), "attempt-2")
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job.ID))
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ body, err = io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ assert.Contains(t, string(body), "attempt-2")
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs?attempt=99", user2.Name, repo.Name, run.ID, job.ID))
+ session.MakeRequest(t, req, http.StatusNotFound)
+ })
+}
diff --git a/web_src/js/components/ActionRunJobView.vue b/web_src/js/components/ActionRunJobView.vue
index fba78917c9c69..279be328e8f1c 100644
--- a/web_src/js/components/ActionRunJobView.vue
+++ b/web_src/js/components/ActionRunJobView.vue
@@ -11,6 +11,7 @@ import {localUserSettings} from '../modules/user-settings.ts';
import type {ActionsArtifact, ActionsRun, ActionsRunStatus} from '../modules/gitea-actions.ts';
import {
type ActionRunViewStore,
+ type ActionsAttempt,
createLogLineMessage,
type LogLine,
type LogLineCommand,
@@ -52,7 +53,9 @@ type LocaleStorageOptions = {
type CurrentJob = {
title: string;
detail: string;
+ attempt: number;
steps: Array