Replace index with id in actions routes (#36842)

This PR migrates the web Actions run/job routes from index-based
`runIndex` or `jobIndex` to database IDs.

**⚠️ BREAKING ⚠️**: Existing saved links/bookmarks that use the old
index-based URLs will no longer resolve after this change.

Improvements of this change:
- Previously, `jobIndex` depended on list order, making it hard to
locate a specific job. Using `jobID` provides stable addressing.
- Web routes now align with API, which already use IDs.
- Behavior is closer to GitHub, which exposes run/job IDs in URLs.
- Provides a cleaner base for future features without relying on list
order.
- #36388 this PR improves the support for reusable workflows. If a job
uses a reusable workflow, it may contain multiple child jobs, which
makes relying on job index to locate a job much more complicated

---------

Signed-off-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Zettat123
2026-03-10 15:14:48 -06:00
committed by GitHub
parent 6e8f78ae27
commit 385994295d
33 changed files with 713 additions and 228 deletions

View File

@@ -38,11 +38,11 @@ import (
"github.com/nektos/act/pkg/model"
)
func getRunIndex(ctx *context_module.Context) int64 {
// if run param is "latest", get the latest run index
func getRunID(ctx *context_module.Context) int64 {
// if run param is "latest", get the latest run id
if ctx.PathParam("run") == "latest" {
if run, _ := actions_model.GetLatestRun(ctx, ctx.Repo.Repository.ID); run != nil {
return run.Index
return run.ID
}
}
return ctx.PathParamInt64("run")
@@ -50,24 +50,25 @@ func getRunIndex(ctx *context_module.Context) int64 {
func View(ctx *context_module.Context) {
ctx.Data["PageIsActions"] = true
runIndex := getRunIndex(ctx)
jobIndex := ctx.PathParamInt("job")
ctx.Data["RunIndex"] = runIndex
ctx.Data["JobIndex"] = jobIndex
ctx.Data["ActionsURL"] = ctx.Repo.RepoLink + "/actions"
runID := getRunID(ctx)
if getRunJobs(ctx, runIndex, jobIndex); ctx.Written() {
_, _, current := getRunJobsAndCurrentJob(ctx, runID)
if ctx.Written() {
return
}
ctx.Data["RunID"] = runID
ctx.Data["JobID"] = current.ID
ctx.Data["ActionsURL"] = ctx.Repo.RepoLink + "/actions"
ctx.HTML(http.StatusOK, tplViewActions)
}
func ViewWorkflowFile(ctx *context_module.Context) {
runIndex := getRunIndex(ctx)
run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex)
runID := getRunID(ctx)
run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
if err != nil {
ctx.NotFoundOrServerError("GetRunByIndex", func(err error) bool {
ctx.NotFoundOrServerError("GetRunByRepoAndID", func(err error) bool {
return errors.Is(err, util.ErrNotExist)
}, err)
return
@@ -187,8 +188,8 @@ type ViewStepLogLine struct {
Timestamp float64 `json:"timestamp"`
}
func getActionsViewArtifacts(ctx context.Context, repoID, runIndex int64) (artifactsViewItems []*ArtifactsViewItem, err error) {
run, err := actions_model.GetRunByIndex(ctx, repoID, runIndex)
func getActionsViewArtifacts(ctx context.Context, repoID, runID int64) (artifactsViewItems []*ArtifactsViewItem, err error) {
run, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID)
if err != nil {
return nil, err
}
@@ -208,14 +209,12 @@ func getActionsViewArtifacts(ctx context.Context, repoID, runIndex int64) (artif
func ViewPost(ctx *context_module.Context) {
req := web.GetForm(ctx).(*ViewRequest)
runIndex := getRunIndex(ctx)
jobIndex := ctx.PathParamInt("job")
runID := getRunID(ctx)
current, jobs := getRunJobs(ctx, runIndex, jobIndex)
run, jobs, current := getRunJobsAndCurrentJob(ctx, runID)
if ctx.Written() {
return
}
run := current.Run
if err := run.LoadAttributes(ctx); err != nil {
ctx.ServerError("run.LoadAttributes", err)
return
@@ -223,7 +222,7 @@ func ViewPost(ctx *context_module.Context) {
var err error
resp := &ViewResponse{}
resp.Artifacts, err = getActionsViewArtifacts(ctx, ctx.Repo.Repository.ID, runIndex)
resp.Artifacts, err = getActionsViewArtifacts(ctx, ctx.Repo.Repository.ID, runID)
if err != nil {
if !errors.Is(err, util.ErrNotExist) {
ctx.ServerError("getActionsViewArtifacts", err)
@@ -400,15 +399,12 @@ func convertToViewModel(ctx context.Context, locale translation.Locale, cursors
}
// Rerun will rerun jobs in the given run
// If jobIndexStr is a blank string, it means rerun all jobs
// If jobIDStr is a blank string, it means rerun all jobs
func Rerun(ctx *context_module.Context) {
runIndex := getRunIndex(ctx)
jobIndexHas := ctx.PathParam("job") != ""
jobIndex := ctx.PathParamInt("job")
runID := getRunID(ctx)
run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex)
if err != nil {
ctx.ServerError("GetRunByIndex", err)
run, jobs, currentJob := getRunJobsAndCurrentJob(ctx, runID)
if ctx.Written() {
return
}
@@ -426,19 +422,9 @@ func Rerun(ctx *context_module.Context) {
return
}
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
if err != nil {
ctx.ServerError("GetRunJobsByRunID", err)
return
}
var targetJob *actions_model.ActionRunJob // nil means rerun all jobs
if jobIndexHas {
if jobIndex < 0 || jobIndex >= len(jobs) {
ctx.JSONError(ctx.Locale.Tr("error.not_found"))
return
}
targetJob = jobs[jobIndex] // only rerun the selected job
if ctx.PathParam("job") != "" {
targetJob = currentJob
}
if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs, targetJob); err != nil {
@@ -450,28 +436,28 @@ func Rerun(ctx *context_module.Context) {
}
func Logs(ctx *context_module.Context) {
runIndex := getRunIndex(ctx)
jobIndex := ctx.PathParamInt64("job")
runID := getRunID(ctx)
jobID := ctx.PathParamInt64("job")
run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex)
run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
if err != nil {
ctx.NotFoundOrServerError("GetRunByIndex", func(err error) bool {
ctx.NotFoundOrServerError("GetRunByRepoAndID", func(err error) bool {
return errors.Is(err, util.ErrNotExist)
}, err)
return
}
if err = common.DownloadActionsRunJobLogsWithIndex(ctx.Base, ctx.Repo.Repository, run.ID, jobIndex); err != nil {
ctx.NotFoundOrServerError("DownloadActionsRunJobLogsWithIndex", func(err error) bool {
if err = common.DownloadActionsRunJobLogsWithID(ctx.Base, ctx.Repo.Repository, run.ID, jobID); err != nil {
ctx.NotFoundOrServerError("DownloadActionsRunJobLogsWithID", func(err error) bool {
return errors.Is(err, util.ErrNotExist)
}, err)
}
}
func Cancel(ctx *context_module.Context) {
runIndex := getRunIndex(ctx)
runID := getRunID(ctx)
firstJob, jobs := getRunJobs(ctx, runIndex, -1)
run, jobs, _ := getRunJobsAndCurrentJob(ctx, runID)
if ctx.Written() {
return
}
@@ -490,7 +476,7 @@ func Cancel(ctx *context_module.Context) {
return
}
actions_service.CreateCommitStatusForRunJobs(ctx, firstJob.Run, jobs...)
actions_service.CreateCommitStatusForRunJobs(ctx, run, jobs...)
actions_service.EmitJobsIfReadyByJobs(updatedJobs)
for _, job := range updatedJobs {
@@ -505,9 +491,9 @@ func Cancel(ctx *context_module.Context) {
}
func Approve(ctx *context_module.Context) {
runIndex := getRunIndex(ctx)
runID := getRunID(ctx)
approveRuns(ctx, []int64{runIndex})
approveRuns(ctx, []int64{runID})
if ctx.Written() {
return
}
@@ -515,17 +501,17 @@ func Approve(ctx *context_module.Context) {
ctx.JSONOK()
}
func approveRuns(ctx *context_module.Context, runIndexes []int64) {
func approveRuns(ctx *context_module.Context, runIDs []int64) {
doer := ctx.Doer
repo := ctx.Repo.Repository
updatedJobs := make([]*actions_model.ActionRunJob, 0)
runMap := make(map[int64]*actions_model.ActionRun, len(runIndexes))
runJobs := make(map[int64][]*actions_model.ActionRunJob, len(runIndexes))
runMap := make(map[int64]*actions_model.ActionRun, len(runIDs))
runJobs := make(map[int64][]*actions_model.ActionRunJob, len(runIDs))
err := db.WithTx(ctx, func(ctx context.Context) (err error) {
for _, runIndex := range runIndexes {
run, err := actions_model.GetRunByIndex(ctx, repo.ID, runIndex)
for _, runID := range runIDs {
run, err := actions_model.GetRunByRepoAndID(ctx, repo.ID, runID)
if err != nil {
return err
}
@@ -560,7 +546,9 @@ func approveRuns(ctx *context_module.Context, runIndexes []int64) {
return nil
})
if err != nil {
ctx.ServerError("UpdateRunJob", err)
ctx.NotFoundOrServerError("approveRuns", func(err error) bool {
return errors.Is(err, util.ErrNotExist)
}, err)
return
}
@@ -580,16 +568,16 @@ func approveRuns(ctx *context_module.Context, runIndexes []int64) {
}
func Delete(ctx *context_module.Context) {
runIndex := getRunIndex(ctx)
runID := getRunID(ctx)
repoID := ctx.Repo.Repository.ID
run, err := actions_model.GetRunByIndex(ctx, repoID, runIndex)
run, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.JSONErrorNotFound()
return
}
ctx.ServerError("GetRunByIndex", err)
ctx.ServerError("GetRunByRepoAndID", err)
return
}
@@ -606,47 +594,54 @@ func Delete(ctx *context_module.Context) {
ctx.JSONOK()
}
// getRunJobs gets the jobs of runIndex, and returns jobs[jobIndex], jobs.
// Any error will be written to the ctx.
// It never returns a nil job of an empty jobs, if the jobIndex is out of range, it will be treated as 0.
func getRunJobs(ctx *context_module.Context, runIndex int64, jobIndex int) (*actions_model.ActionRunJob, []*actions_model.ActionRunJob) {
run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex)
// getRunJobsAndCurrentJob loads the run and its jobs for runID, and returns the selected job based on the optional "job" path param (or the first job by default).
// Any error will be written to the ctx, and nils are returned in that case.
func getRunJobsAndCurrentJob(ctx *context_module.Context, runID int64) (*actions_model.ActionRun, []*actions_model.ActionRunJob, *actions_model.ActionRunJob) {
run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.NotFound(nil)
return nil, nil
}
ctx.ServerError("GetRunByIndex", err)
return nil, nil
ctx.NotFoundOrServerError("GetRunByRepoAndID", func(err error) bool {
return errors.Is(err, util.ErrNotExist)
}, err)
return nil, nil, nil
}
run.Repo = ctx.Repo.Repository
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
if err != nil {
ctx.ServerError("GetRunJobsByRunID", err)
return nil, nil
return nil, nil, nil
}
if len(jobs) == 0 {
ctx.NotFound(nil)
return nil, nil
return nil, nil, nil
}
for _, v := range jobs {
v.Run = run
for _, job := range jobs {
job.Run = run
}
if jobIndex >= 0 && jobIndex < len(jobs) {
return jobs[jobIndex], jobs
current := jobs[0]
if ctx.PathParam("job") != "" {
jobID := ctx.PathParamInt64("job")
current, err = actions_model.GetRunJobByRunAndID(ctx, run.ID, jobID)
if err != nil {
ctx.NotFoundOrServerError("GetRunJobByRunAndID", func(err error) bool {
return errors.Is(err, util.ErrNotExist)
}, err)
return nil, nil, nil
}
current.Run = run
}
return jobs[0], jobs
return run, jobs, current
}
func ArtifactsDeleteView(ctx *context_module.Context) {
runIndex := getRunIndex(ctx)
runID := getRunID(ctx)
artifactName := ctx.PathParam("artifact_name")
run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex)
run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
if err != nil {
ctx.NotFoundOrServerError("GetRunByIndex", func(err error) bool {
ctx.NotFoundOrServerError("GetRunByRepoAndID", func(err error) bool {
return errors.Is(err, util.ErrNotExist)
}, err)
return
@@ -659,16 +654,16 @@ func ArtifactsDeleteView(ctx *context_module.Context) {
}
func ArtifactsDownloadView(ctx *context_module.Context) {
runIndex := getRunIndex(ctx)
runID := getRunID(ctx)
artifactName := ctx.PathParam("artifact_name")
run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex)
run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.HTTPError(http.StatusNotFound, err.Error())
return
}
ctx.ServerError("GetRunByIndex", err)
ctx.ServerError("GetRunByRepoAndID", err)
return
}
@@ -754,19 +749,19 @@ func ApproveAllChecks(ctx *context_module.Context) {
return
}
runIndexes := make([]int64, 0, len(runs))
runIDs := make([]int64, 0, len(runs))
for _, run := range runs {
if run.NeedApproval {
runIndexes = append(runIndexes, run.Index)
runIDs = append(runIDs, run.ID)
}
}
if len(runIndexes) == 0 {
if len(runIDs) == 0 {
ctx.JSONOK()
return
}
approveRuns(ctx, runIndexes)
approveRuns(ctx, runIDs)
if ctx.Written() {
return
}