fix(actions): let a rerun of selected jobs read the previous attempt's artifacts (#38857) (#38901)

Backport #38857

Fixes #38773

## Background

Artifacts became attempt-scoped in #37119, and the runner-facing
artifact APIs filter strictly by the attempt of the running job. "Re-run
failed jobs" creates a new attempt whose passed-through jobs never
upload their artifacts again, so a re-run job that downloads one of them
fails with "artifact not found".

## Fix

The read paths (v3 and v4 list and download) now resolve artifacts
across the running job's attempt plus the attempts it inherits from, and
an inherited artifact is shadowed by a same-named one from a newer
attempt.

## Note

GitHub's documentation does not document these behaviors. The
conclusions below are based on manual testing, so consistency with
GitHub cannot be guaranteed.

- In a "partial re-run", a job can download artifacts uploaded by an
earlier attempt, every attempt keeps its own copy of a name, and a
lookup by name resolves to the newest one.
- A full "Re-run all jobs" never downloads artifacts from earlier
attempts.
This commit is contained in:
bircni
2026-08-13 15:31:42 +02:00
committed by GitHub
parent 0acbcc58a7
commit ba4db8a2d9
9 changed files with 368 additions and 63 deletions
+25 -4
View File
@@ -9,10 +9,10 @@ package actions
import (
"context"
"errors"
"slices"
"time"
"gitea.dev/models/db"
"gitea.dev/modules/optional"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
@@ -147,7 +147,7 @@ type FindArtifactsOptions struct {
db.ListOptions
RepoID int64
RunID int64
RunAttemptID optional.Option[int64] // use optional to allow filtering by zero (legacy artifacts have run_attempt_id=0)
RunAttemptIDs []int64 // empty means every attempt; pass 0 to target legacy artifacts, which have run_attempt_id=0
ArtifactName string
Status int
FinalizedArtifactsV4 bool
@@ -167,8 +167,8 @@ func (opts FindArtifactsOptions) ToConds() builder.Cond {
if opts.RunID > 0 {
cond = cond.And(builder.Eq{"run_id": opts.RunID})
}
if opts.RunAttemptID.Has() {
cond = cond.And(builder.Eq{"run_attempt_id": opts.RunAttemptID.Value()})
if len(opts.RunAttemptIDs) > 0 {
cond = cond.And(builder.In("run_attempt_id", opts.RunAttemptIDs))
}
if opts.ArtifactName != "" {
cond = cond.And(builder.Eq{"artifact_name": opts.ArtifactName})
@@ -185,6 +185,27 @@ func (opts FindArtifactsOptions) ToConds() builder.Cond {
return cond
}
// FindReadableArtifacts returns the artifacts of opts.RunAttemptIDs, only keeps the ones from a newer attempt.
func FindReadableArtifacts(ctx context.Context, opts FindArtifactsOptions) ([]*ActionArtifact, error) {
arts, err := db.Find[ActionArtifact](ctx, opts)
if err != nil || len(opts.RunAttemptIDs) <= 1 {
return arts, err
}
return keepLatestAttemptArtifacts(arts), nil
}
// keepLatestAttemptArtifacts keeps, per name, only the artifacts of the newest attempt that has it.
// A v3 artifact is one row per uploaded file, so the whole group of the winning attempt is kept.
func keepLatestAttemptArtifacts(arts []*ActionArtifact) []*ActionArtifact {
latest := make(map[string]int64)
for _, art := range arts {
latest[art.ArtifactName] = max(latest[art.ArtifactName], art.RunAttemptID)
}
return slices.DeleteFunc(arts, func(art *ActionArtifact) bool {
return art.RunAttemptID != latest[art.ArtifactName]
})
}
// ActionArtifactMeta is the meta-data of an artifact
type ActionArtifactMeta struct {
ArtifactName string