Files
gitea/routers/api/actions/artifacts_utils.go
bircni ba4db8a2d9 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.
2026-08-13 13:31:42 +00:00

106 lines
3.5 KiB
Go

// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"crypto/md5"
"fmt"
"net/http"
"strconv"
"strings"
"gitea.dev/models/actions"
"gitea.dev/modules/log"
"gitea.dev/modules/util"
)
const (
artifactXTfsFileLengthHeader = "x-tfs-filelength"
artifactXActionsResultsMD5Header = "x-actions-results-md5"
)
// The rules are from https://github.com/actions/toolkit/blob/main/packages/artifact/src/internal/upload/path-and-artifact-name-validation.ts
const invalidArtifactNameChars = "\\/\":<>|*?\r\n"
func validateArtifactName(ctx *ArtifactContext, artifactName string) bool {
if strings.ContainsAny(artifactName, invalidArtifactNameChars) {
log.Error("Error checking artifact name contains invalid character")
ctx.HTTPError(http.StatusBadRequest, "Error checking artifact name contains invalid character")
return false
}
return true
}
func validateRunID(ctx *ArtifactContext) (*actions.ActionTask, int64, bool) {
task := ctx.ActionTask
runID := ctx.PathParamInt64("run_id")
if task.Job.RunID != runID {
log.Error("Error runID not match")
ctx.HTTPError(http.StatusBadRequest, "run-id does not match")
return nil, 0, false
}
return task, runID, true
}
func validateRunIDV4(ctx *ArtifactContext, rawRunID string) (*actions.ActionTask, int64, bool) {
task := ctx.ActionTask
runID, err := strconv.ParseInt(rawRunID, 10, 64)
if err != nil || task.Job.RunID != runID {
log.Error("Error runID not match")
ctx.HTTPError(http.StatusBadRequest, "run-id does not match")
return nil, 0, false
}
return task, runID, true
}
// readableArtifactAttemptIDs resolves the attempts a task may read artifacts from:
// its own attempt, plus the attempts it inherits from when only a subset of the run's jobs was re-run.
func readableArtifactAttemptIDs(ctx *ArtifactContext, task *actions.ActionTask) ([]int64, bool) {
attemptIDs, err := actions.GetArtifactAttemptIDs(ctx, task.Job)
if err != nil {
log.Error("Error getting readable artifact attempts: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error getting readable artifact attempts")
return nil, false
}
return attemptIDs, true
}
func validateArtifactHash(ctx *ArtifactContext, artifactName string) bool {
paramHash := ctx.PathParam("artifact_hash")
// use artifact name to create upload url
artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(artifactName)))
if paramHash == artifactHash {
return true
}
log.Error("Invalid artifact hash: %s", paramHash)
ctx.HTTPError(http.StatusBadRequest, "Invalid artifact hash")
return false
}
func parseArtifactItemPath(ctx *ArtifactContext) (string, string, bool) {
// itemPath is generated from upload-artifact action
// it's formatted as {artifact_name}/{artfict_path_in_runner}
// act_runner in host mode on Windows, itemPath is joined by Windows slash '\'
itemPath := util.PathJoinRelX(ctx.Req.URL.Query().Get("itemPath"))
artifactName := strings.Split(itemPath, "/")[0]
artifactPath := strings.TrimPrefix(itemPath, artifactName+"/")
if !validateArtifactHash(ctx, artifactName) {
return "", "", false
}
if !validateArtifactName(ctx, artifactName) {
return "", "", false
}
return artifactName, artifactPath, true
}
// getUploadFileSize returns the size of the file to be uploaded.
// The raw size is the size of the file as reported by the header X-TFS-FileLength.
func getUploadFileSize(ctx *ArtifactContext) int64 {
xTfsLength, _ := strconv.ParseInt(ctx.Req.Header.Get(artifactXTfsFileLengthHeader), 10, 64)
if xTfsLength > 0 {
return xTfsLength
}
return ctx.Req.ContentLength
}