Support legacy run/job index-based URLs and refactor migration 326 (#37008)

Follow up #36842

Migration `326` can be prohibitively slow on large instances because it
scans and rewrites all commit status target URLs generated by Gitea
Actions in the database. This PR refactors migration `326` to perform a
partial update instead of rewriting every legacy target URL. The reason
for this partial rewrite is that **smaller legacy run/job indexes are
the most likely to be ambiguous with run/job ID-based URLs** during
runtime resolution, so this change prioritizes that subset while
avoiding the cost of rewriting all legacy records.

To preserve access to old links, this PR introduces
`resolveCurrentRunForView` to handle both ID-based URLs and index-based
URLs:

- For job pages (`/actions/runs/{run}/jobs/{job}`), it first tries to
confirm that the URL is ID-based. It does so by checking whether `{job}`
can be treated as an existing job ID in the repository and whether that
job belongs to `{run}`. If that match cannot be confirmed, it falls back
to treating the URL as legacy `run index + job index`, resolves the
corresponding run and job, and redirects to the correct ID-based URL.
- When both ID-based and index-based interpretations are valid at the
same time, the resolver **prefers the ID-based interpretation by
default**. For example, if a repository contains one run-job pair
(`run_id=3, run_index=2, job_id=4`), and also another run-job pair
(`run_id=1100, run_index=3, job_id=1200, job_index=4`), then
`/actions/runs/3/jobs/4` is ambiguous. In that case, the resolver treats
it as the ID-based URL by default and shows the page for `run_id=3,
job_id=4`. Users can still explicitly force the legacy index-based
interpretation with `?by_index=1`, which would resolve the same URL to
`/actions/runs/1100/jobs/1200`.
- For run summary pages (`/actions/runs/{run}`), it uses a best-effort
strategy: by default it first treats `{run}` as a run ID, and if no such
run exists in the repository, it falls back to treating `{run}` as a
legacy run index and redirects to the ID-based URL. Users can also
explicitly force the legacy interpretation with `?by_index=1`.
- This summary-page compatibility is best-effort, not a strict ambiguity
check. For example, if a repository contains two runs: runA (`id=7,
index=3`) and runB (`id=99, index=7`), then `/actions/runs/7` will
resolve to runA by default, even though the old index-based URL
originally referred to runB.

The table below shows how valid legacy index-based target URLs are
handled before and after migration `326`. Lower-range legacy URLs are
rewritten to ID-based URLs, while higher-range legacy URLs remain
unchanged in the database but are still handled correctly by
`resolveCurrentRunForView` at runtime.

| run_id | run_index | job_id | job_index | old target URL | updated by
migration 326 | current target URL | can be resolved correctly |
|---|---|---|---|---|---|---|---|
| 3 | 2 | 4 | 1 | `/user2/repo2/actions/runs/2/jobs/1` | true |
`/user2/repo2/actions/runs/3/jobs/4` | true |
| 4 | 3 | 8 | 4 | `/user2/repo2/actions/runs/3/jobs/4` | true |
`/user2/repo2/actions/runs/4/jobs/8` | true (without migration 326, this
URL will resolve to run(`id=3`)) |
| 80 | 20 | 170 | 0 | `/user2/repo2/actions/runs/20/jobs/0` | true |
`/user2/repo2/actions/runs/80/jobs/170` | true |
| 1500 | 900 | 1600 | 0 | `/user2/repo2/actions/runs/900/jobs/0` | false
| `/user2/repo2/actions/runs/900/jobs/0` | true |
| 2400 | 1500 | 2600 | 0 | `/user2/repo2/actions/runs/1500/jobs/0` |
false | `/user2/repo2/actions/runs/1500/jobs/0` | true |
| 2400 | 1500 | 2601 | 1 | `/user2/repo2/actions/runs/1500/jobs/1` |
false | `/user2/repo2/actions/runs/1500/jobs/1` | true |

For users who already ran the old migration `326`, this change has no
functional impact. Their historical URLs are already stored in the
ID-based form, and ID-based URLs continue to resolve correctly.

For users who have not run the old migration `326`, only a subset of
legacy target URLs will now be rewritten during upgrade. This avoids the
extreme runtime cost of the previous full migration, while all remaining
legacy target URLs continue to work through the web-layer compatibility
logic.

Many thanks to @wxiaoguang for the suggestions.
This commit is contained in:
Zettat123
2026-04-02 18:23:29 -06:00
committed by GitHub
parent 686d10b7f0
commit 23c662ebb1
12 changed files with 695 additions and 213 deletions

View File

@@ -68,9 +68,138 @@ func getCurrentRunByPathParam(ctx *context_module.Context) (run *actions_model.A
return run
}
// resolveCurrentRunForView resolves GET Actions page URLs and supports both ID-based and legacy index-based forms.
//
// By default, run summary pages (/actions/runs/{run}) use a best-effort ID-first fallback,
// and job pages (/actions/runs/{run}/jobs/{job}) try to confirm an ID-based URL first and prefer the ID-based interpretation when both are valid.
//
// `by_id=1` param explicitly forces the ID-based path, and `by_index=1` explicitly forces the legacy index-based path.
// If both are present, `by_id` takes precedence.
func resolveCurrentRunForView(ctx *context_module.Context) *actions_model.ActionRun {
// `by_id` explicitly requests ID-based resolution, so the request skips the legacy index-based disambiguation logic and resolves the run by ID directly.
// It takes precedence over `by_index` when both query parameters are present.
if ctx.PathParam("run") == "latest" || ctx.FormBool("by_id") {
return getCurrentRunByPathParam(ctx)
}
runNum := ctx.PathParamInt64("run")
if runNum <= 0 {
ctx.NotFound(nil)
return nil
}
byIndex := ctx.FormBool("by_index")
if ctx.PathParam("job") == "" {
// The URL does not contain a {job} path parameter, so it cannot use the
// job-specific rules to disambiguate ID-based URLs from legacy index-based URLs.
// Because of that, this path is handled with a best-effort ID-first fallback by default.
//
// When the same repository contains:
// - a run whose ID matches runNum, and
// - a different run whose repo-scope index also matches runNum
// this path prefers the ID match and may show a different run than the old legacy URL originally intended,
// unless `by_index=1` explicitly forces the legacy index-based interpretation.
if !byIndex {
runByID, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runNum)
if err == nil {
return runByID
}
if !errors.Is(err, util.ErrNotExist) {
ctx.ServerError("GetRun:"+ctx.PathParam("run"), err)
return nil
}
}
runByIndex, err := actions_model.GetRunByRepoAndIndex(ctx, ctx.Repo.Repository.ID, runNum)
if err == nil {
ctx.Redirect(fmt.Sprintf("%s/actions/runs/%d", ctx.Repo.RepoLink, runByIndex.ID), http.StatusFound)
return nil
}
if !errors.Is(err, util.ErrNotExist) {
ctx.ServerError("GetRunByRepoAndIndex", err)
return nil
}
ctx.NotFound(nil)
return nil
}
jobNum := ctx.PathParamInt64("job")
if jobNum < 0 {
ctx.NotFound(nil)
return nil
}
// A job index should not be larger than MaxJobNumPerRun, so larger values can skip the legacy index-based path and be treated as job IDs directly.
if !byIndex && jobNum >= actions_model.MaxJobNumPerRun {
return getCurrentRunByPathParam(ctx)
}
var runByID, runByIndex *actions_model.ActionRun
var targetJobByIndex *actions_model.ActionRunJob
// Each run must have at least one job, so a valid job ID in the same run cannot be smaller than the run ID.
if !byIndex && jobNum >= runNum {
// Probe the repo-scoped job ID first and only accept it when the job exists and belongs to the same runNum.
job, err := actions_model.GetRunJobByRepoAndID(ctx, ctx.Repo.Repository.ID, jobNum)
if err != nil && !errors.Is(err, util.ErrNotExist) {
ctx.ServerError("GetRunJobByRepoAndID", err)
return nil
}
if job != nil {
if err := job.LoadRun(ctx); err != nil {
ctx.ServerError("LoadRun", err)
return nil
}
if job.Run.ID == runNum {
runByID = job.Run
}
}
}
// Try to resolve the request as a legacy run-index/job-index URL.
{
run, err := actions_model.GetRunByRepoAndIndex(ctx, ctx.Repo.Repository.ID, runNum)
if err != nil && !errors.Is(err, util.ErrNotExist) {
ctx.ServerError("GetRunByRepoAndIndex", err)
return nil
}
if run != nil {
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
if err != nil {
ctx.ServerError("GetRunJobsByRunID", err)
return nil
}
if jobNum < int64(len(jobs)) {
runByIndex = run
targetJobByIndex = jobs[jobNum]
}
}
}
if runByID == nil && runByIndex == nil {
ctx.NotFound(nil)
return nil
}
if runByID != nil && runByIndex == nil {
return runByID
}
if runByID == nil && runByIndex != nil {
ctx.Redirect(fmt.Sprintf("%s/actions/runs/%d/jobs/%d", ctx.Repo.RepoLink, runByIndex.ID, targetJobByIndex.ID), http.StatusFound)
return nil
}
// Reaching this point means both ID-based and legacy index-based interpretations are valid. Prefer the ID-based interpretation by default.
// Use `by_index=1` query parameter to access the legacy index-based interpretation when necessary.
return runByID
}
func View(ctx *context_module.Context) {
ctx.Data["PageIsActions"] = true
run := getCurrentRunByPathParam(ctx)
run := resolveCurrentRunForView(ctx)
if ctx.Written() {
return
}