Files
Gitea/tests/integration/api_actions_artifact_test.go
T
Zettat123 c186cc4b8d fix(actions): let a rerun of selected jobs read the previous attempt's artifacts (#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.

---------

Co-authored-by: bircni <bircni@icloud.com>
2026-08-12 19:55:26 +00:00

799 lines
34 KiB
Go

// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"archive/zip"
"bytes"
"crypto/md5"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"testing"
runnerv1 "gitea.dev/actionslib/runner/v1"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/util"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type uploadArtifactResponse struct {
FileContainerResourceURL string `json:"fileContainerResourceUrl"`
}
type getUploadArtifactRequest struct {
Type string
Name string
RetentionDays int64
}
func prepareTestEnvActionsArtifacts(t *testing.T) func() {
t.Helper()
f := tests.PrepareTestEnv(t, 1)
tests.PrepareArtifactsStorage(t)
return f
}
func getArtifactFixtureTask(t *testing.T) *actions_model.ActionTask {
t.Helper()
task, err := actions_model.GetRunningTaskByToken(t.Context(), "8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
require.NoError(t, err)
require.NoError(t, task.LoadJob(t.Context()))
ensureArtifactFixtureTaskSteps(t, task)
return task
}
func ensureArtifactFixtureTaskSteps(t *testing.T, task *actions_model.ActionTask) {
t.Helper()
steps, err := actions_model.GetTaskStepsByTaskID(t.Context(), task.ID)
require.NoError(t, err)
existingIndexes := make(map[int64]bool, len(steps))
for _, step := range steps {
existingIndexes[step.Index] = true
}
var stepsToInsert []*actions_model.ActionTaskStep
for _, idx := range []int64{0, 1} {
if existingIndexes[idx] {
continue
}
stepsToInsert = append(stepsToInsert, &actions_model.ActionTaskStep{
TaskID: task.ID,
Index: idx,
RepoID: task.RepoID,
Status: actions_model.StatusWaiting,
})
}
if len(stepsToInsert) == 0 {
return
}
_, err = db.GetEngine(t.Context()).Insert(stepsToInsert)
require.NoError(t, err)
}
func TestActionsJobSummaryUpload(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
const runnerToken = "8061e833a55f6fc0157c98b883e91fcfeeb1a71a"
task := getArtifactFixtureTask(t)
summaryURL := func(stepIndex int64) string {
return fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/jobs/%d/steps/%d/summary", task.Job.RunID, task.Job.ID, stepIndex)
}
putSummary := func(stepIndex int64, body, contentType string) *RequestWrapper {
return NewRequestWithBody(t, "PUT", summaryURL(stepIndex), strings.NewReader(body)).
AddTokenAuth(runnerToken).
SetHeader("Content-Type", contentType)
}
t.Run("success", func(t *testing.T) {
body := "### Uploaded summary\n\n- line one\n"
MakeRequest(t, putSummary(0, body, "text/markdown; charset=utf-8"), http.StatusOK)
summary, err := actions_model.GetActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 0)
require.NoError(t, err)
assert.Equal(t, actions_model.JobSummaryContentTypeMarkdown, summary.ContentType)
assert.Equal(t, body, summary.Content)
staleUpdated := summary.Updated - 60
_, err = db.GetEngine(t.Context()).ID(summary.ID).Cols("updated").Update(&actions_model.ActionRunJobSummary{Updated: staleUpdated})
require.NoError(t, err)
updatedBody := "### Updated summary\n\n- refreshed\n"
MakeRequest(t, putSummary(0, updatedBody, actions_model.JobSummaryContentTypeMarkdown), http.StatusOK)
summary, err = actions_model.GetActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 0)
require.NoError(t, err)
assert.Equal(t, updatedBody, summary.Content)
assert.Greater(t, summary.Updated, staleUpdated)
stepTwoBody := "### Second step summary\n\n- another step\n"
MakeRequest(t, putSummary(1, stepTwoBody, actions_model.JobSummaryContentTypeMarkdown), http.StatusOK)
summary, err = actions_model.GetActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 1)
require.NoError(t, err)
assert.Equal(t, stepTwoBody, summary.Content)
summaries, err := actions_model.ListActionRunJobSummaries(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, 0)
require.NoError(t, err)
require.Len(t, summaries, 2)
assert.Equal(t, int64(0), summaries[0].StepIndex)
assert.Equal(t, int64(1), summaries[1].StepIndex)
})
t.Run("invalid-content-type", func(t *testing.T) {
resp := MakeRequest(t, putSummary(0, "summary", "text/html"), http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "invalid summary content type")
})
t.Run("size-limit", func(t *testing.T) {
resp := MakeRequest(t, putSummary(0, strings.Repeat("a", actions_model.MaxJobSummarySize+1), actions_model.JobSummaryContentTypeMarkdown), http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "invalid summary")
})
t.Run("aggregate-size-limit", func(t *testing.T) {
require.NoError(t, actions_model.UpsertActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 0,
actions_model.JobSummaryContentTypeMarkdown, []byte(strings.Repeat("a", actions_model.MaxJobSummaryAggregateSize-1024))))
resp := MakeRequest(t, putSummary(1, strings.Repeat("b", 4096), actions_model.JobSummaryContentTypeMarkdown), http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "aggregate size exceeded")
})
t.Run("job-mismatch", func(t *testing.T) {
req := NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/jobs/%d/steps/0/summary", task.Job.RunID, task.Job.ID+1), strings.NewReader("summary")).
AddTokenAuth(runnerToken).
SetHeader("Content-Type", actions_model.JobSummaryContentTypeMarkdown)
resp := MakeRequest(t, req, http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "job_id mismatch")
})
t.Run("run-mismatch", func(t *testing.T) {
req := NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/jobs/%d/steps/0/summary", task.Job.RunID+1, task.Job.ID), strings.NewReader("summary")).
AddTokenAuth(runnerToken).
SetHeader("Content-Type", actions_model.JobSummaryContentTypeMarkdown)
resp := MakeRequest(t, req, http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "run-id does not match")
})
t.Run("invalid-step-index", func(t *testing.T) {
resp := MakeRequest(t, putSummary(-1, "summary", actions_model.JobSummaryContentTypeMarkdown), http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "invalid step_index")
})
t.Run("step-index-mismatch", func(t *testing.T) {
resp := MakeRequest(t, putSummary(999, "summary", actions_model.JobSummaryContentTypeMarkdown), http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "step_index mismatch")
})
t.Run("empty-body-clears", func(t *testing.T) {
MakeRequest(t, putSummary(0, "### keep me", actions_model.JobSummaryContentTypeMarkdown), http.StatusOK)
MakeRequest(t, putSummary(0, "", actions_model.JobSummaryContentTypeMarkdown), http.StatusOK)
_, err := actions_model.GetActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 0)
require.ErrorIs(t, err, util.ErrNotExist)
})
}
func TestActionsArtifactUploadSingleFile(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
// acquire artifact upload url
req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts", getUploadArtifactRequest{
Type: "actions_storage",
Name: "artifact",
}).AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp := MakeRequest(t, req, http.StatusOK)
uploadResp := DecodeJSON(t, resp, &uploadArtifactResponse{})
assert.Contains(t, uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
// get upload url
idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
url := uploadResp.FileContainerResourceURL[idx:] + "?itemPath=artifact/abc-2.txt"
// upload artifact chunk
body := strings.Repeat("C", 1024)
req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body)).
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a").
SetHeader("Content-Range", "bytes 0-1023/1024").
SetHeader("x-tfs-filelength", "1024").
SetHeader("x-actions-results-md5", "XVlf820rMInUi64wmMi6EA==") // base64(md5(body))
MakeRequest(t, req, http.StatusOK)
t.Logf("Create artifact confirm")
// confirm artifact upload
req = NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts?artifactName=artifact-single").
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
MakeRequest(t, req, http.StatusOK)
}
func TestActionsArtifactUploadInvalidHash(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
// artifact id 54321 not exist
url := "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts/8e5b948a454515dbabfc7eb718ddddddd/upload?itemPath=artifact/abc.txt"
body := strings.Repeat("A", 1024)
req := NewRequestWithBody(t, "PUT", url, strings.NewReader(body)).
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a").
SetHeader("Content-Range", "bytes 0-1023/1024").
SetHeader("x-tfs-filelength", "1024").
SetHeader("x-actions-results-md5", "1HsSe8LeLWh93ILaw1TEFQ==") // base64(md5(body))
resp := MakeRequest(t, req, http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "Invalid artifact hash")
}
func TestActionsArtifactConfirmUploadWithoutName(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
req := NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts").
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp := MakeRequest(t, req, http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "artifact name is empty")
}
func TestActionsArtifactUploadWithoutToken(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/1/artifacts", nil)
MakeRequest(t, req, http.StatusUnauthorized)
}
type (
listArtifactsResponseItem struct {
Name string `json:"name"`
FileContainerResourceURL string `json:"fileContainerResourceUrl"`
}
listArtifactsResponse struct {
Count int64 `json:"count"`
Value []listArtifactsResponseItem `json:"value"`
}
downloadArtifactResponseItem struct {
Path string `json:"path"`
ItemType string `json:"itemType"`
ContentLocation string `json:"contentLocation"`
}
downloadArtifactResponse struct {
Value []downloadArtifactResponseItem `json:"value"`
}
)
func TestActionsArtifactDownload(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
req := NewRequest(t, "GET", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts").
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp := MakeRequest(t, req, http.StatusOK)
listResp := DecodeJSON(t, resp, &listArtifactsResponse{})
assert.Equal(t, int64(2), listResp.Count)
// Return list might be in any order. Get one file.
var artifactIdx int
for i, artifact := range listResp.Value {
if artifact.Name == "artifact-download" {
artifactIdx = i
break
}
}
assert.NotNil(t, artifactIdx)
assert.Equal(t, "artifact-download", listResp.Value[artifactIdx].Name)
assert.Contains(t, listResp.Value[artifactIdx].FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
idx := strings.Index(listResp.Value[artifactIdx].FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
url := listResp.Value[artifactIdx].FileContainerResourceURL[idx:] + "?itemPath=artifact-download"
req = NewRequest(t, "GET", url).
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp = MakeRequest(t, req, http.StatusOK)
downloadResp := DecodeJSON(t, resp, &downloadArtifactResponse{})
assert.Len(t, downloadResp.Value, 1)
assert.Equal(t, "artifact-download/abc.txt", downloadResp.Value[0].Path)
assert.Equal(t, "file", downloadResp.Value[0].ItemType)
assert.Contains(t, downloadResp.Value[0].ContentLocation, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
idx = strings.Index(downloadResp.Value[0].ContentLocation, "/api/actions_pipeline/_apis/pipelines/")
url = downloadResp.Value[0].ContentLocation[idx:]
req = NewRequest(t, "GET", url).
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp = MakeRequest(t, req, http.StatusOK)
body := strings.Repeat("A", 1024)
assert.Equal(t, body, resp.Body.String())
}
func TestActionsArtifactUploadMultipleFile(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
const testArtifactName = "multi-files"
// acquire artifact upload url
req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts", getUploadArtifactRequest{
Type: "actions_storage",
Name: testArtifactName,
}).AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp := MakeRequest(t, req, http.StatusOK)
uploadResp := DecodeJSON(t, resp, &uploadArtifactResponse{})
assert.Contains(t, uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
type uploadingFile struct {
Path string
Content string
MD5 string
}
files := []uploadingFile{
{
Path: "abc-3.txt",
Content: strings.Repeat("D", 1024),
MD5: "9nqj7E8HZmfQtPifCJ5Zww==",
},
{
Path: "xyz/def-2.txt",
Content: strings.Repeat("E", 1024),
MD5: "/s1kKvxeHlUX85vaTaVxuA==",
},
}
for _, f := range files {
// get upload url
idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
url := uploadResp.FileContainerResourceURL[idx:] + "?itemPath=" + testArtifactName + "/" + f.Path
// upload artifact chunk
req = NewRequestWithBody(t, "PUT", url, strings.NewReader(f.Content)).
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a").
SetHeader("Content-Range", "bytes 0-1023/1024").
SetHeader("x-tfs-filelength", "1024").
SetHeader("x-actions-results-md5", f.MD5) // base64(md5(body))
MakeRequest(t, req, http.StatusOK)
}
t.Logf("Create artifact confirm")
// confirm artifact upload
req = NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts?artifactName="+testArtifactName).
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
MakeRequest(t, req, http.StatusOK)
}
func TestActionsArtifactDownloadMultiFiles(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
const testArtifactName = "multi-file-download"
req := NewRequest(t, "GET", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts").
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp := MakeRequest(t, req, http.StatusOK)
listResp := DecodeJSON(t, resp, &listArtifactsResponse{})
assert.Equal(t, int64(2), listResp.Count)
var fileContainerResourceURL string
for _, v := range listResp.Value {
if v.Name == testArtifactName {
fileContainerResourceURL = v.FileContainerResourceURL
break
}
}
assert.Contains(t, fileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
idx := strings.Index(fileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
url := fileContainerResourceURL[idx:] + "?itemPath=" + testArtifactName
req = NewRequest(t, "GET", url).
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp = MakeRequest(t, req, http.StatusOK)
downloadResp := DecodeJSON(t, resp, &downloadArtifactResponse{})
assert.Len(t, downloadResp.Value, 2)
downloads := [][]string{{"multi-file-download/abc.txt", "B"}, {"multi-file-download/xyz/def.txt", "C"}}
for _, v := range downloadResp.Value {
var bodyChar string
var path string
for _, d := range downloads {
if v.Path == d[0] {
path = d[0]
bodyChar = d[1]
break
}
}
value := v
assert.Equal(t, path, value.Path)
assert.Equal(t, "file", value.ItemType)
assert.Contains(t, value.ContentLocation, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
idx = strings.Index(value.ContentLocation, "/api/actions_pipeline/_apis/pipelines/")
url = value.ContentLocation[idx:]
req = NewRequest(t, "GET", url).
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp = MakeRequest(t, req, http.StatusOK)
assert.Equal(t, strings.Repeat(bodyChar, 1024), resp.Body.String())
}
}
func TestActionsArtifactUploadWithRetentionDays(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
// acquire artifact upload url
req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts", getUploadArtifactRequest{
Type: "actions_storage",
Name: "artifact-retention-days",
RetentionDays: 9,
}).AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp := MakeRequest(t, req, http.StatusOK)
uploadResp := DecodeJSON(t, resp, &uploadArtifactResponse{})
assert.Contains(t, uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
assert.Contains(t, uploadResp.FileContainerResourceURL, "?retentionDays=9")
// get upload url
idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
url := uploadResp.FileContainerResourceURL[idx:] + "&itemPath=artifact-retention-days/abc.txt"
// upload artifact chunk
body := strings.Repeat("A", 1024)
req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body)).
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a").
SetHeader("Content-Range", "bytes 0-1023/1024").
SetHeader("x-tfs-filelength", "1024").
SetHeader("x-actions-results-md5", "1HsSe8LeLWh93ILaw1TEFQ==") // base64(md5(body))
MakeRequest(t, req, http.StatusOK)
t.Logf("Create artifact confirm")
// confirm artifact upload
req = NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts?artifactName=artifact-retention-days").
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
MakeRequest(t, req, http.StatusOK)
}
func TestActionsArtifactOverwrite(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
{
// download old artifact uploaded by tests above, it should 1024 A
req := NewRequest(t, "GET", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts").
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp := MakeRequest(t, req, http.StatusOK)
listResp := DecodeJSON(t, resp, &listArtifactsResponse{})
idx := strings.Index(listResp.Value[0].FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
url := listResp.Value[0].FileContainerResourceURL[idx:] + "?itemPath=artifact-download"
req = NewRequest(t, "GET", url).
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp = MakeRequest(t, req, http.StatusOK)
downloadResp := DecodeJSON(t, resp, &downloadArtifactResponse{})
idx = strings.Index(downloadResp.Value[0].ContentLocation, "/api/actions_pipeline/_apis/pipelines/")
url = downloadResp.Value[0].ContentLocation[idx:]
req = NewRequest(t, "GET", url).
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp = MakeRequest(t, req, http.StatusOK)
body := strings.Repeat("A", 1024)
assert.Equal(t, resp.Body.String(), body)
}
{
// upload same artifact, it uses 4096 B
req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts", getUploadArtifactRequest{
Type: "actions_storage",
Name: "artifact-download",
}).AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp := MakeRequest(t, req, http.StatusOK)
uploadResp := DecodeJSON(t, resp, &uploadArtifactResponse{})
idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
url := uploadResp.FileContainerResourceURL[idx:] + "?itemPath=artifact-download/abc.txt"
body := strings.Repeat("B", 4096)
req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body)).
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a").
SetHeader("Content-Range", "bytes 0-4095/4096").
SetHeader("x-tfs-filelength", "4096").
SetHeader("x-actions-results-md5", "wUypcJFeZCK5T6r4lfqzqg==") // base64(md5(body))
MakeRequest(t, req, http.StatusOK)
// confirm artifact upload
req = NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts?artifactName=artifact-download").
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
MakeRequest(t, req, http.StatusOK)
}
{
// download artifact again, it should 4096 B
req := NewRequest(t, "GET", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts").
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp := MakeRequest(t, req, http.StatusOK)
listResp := DecodeJSON(t, resp, &listArtifactsResponse{})
var uploadedItem listArtifactsResponseItem
for _, item := range listResp.Value {
if item.Name == "artifact-download" {
uploadedItem = item
break
}
}
assert.Equal(t, "artifact-download", uploadedItem.Name)
idx := strings.Index(uploadedItem.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
url := uploadedItem.FileContainerResourceURL[idx:] + "?itemPath=artifact-download"
req = NewRequest(t, "GET", url).
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp = MakeRequest(t, req, http.StatusOK)
downloadResp := DecodeJSON(t, resp, &downloadArtifactResponse{})
idx = strings.Index(downloadResp.Value[0].ContentLocation, "/api/actions_pipeline/_apis/pipelines/")
url = downloadResp.Value[0].ContentLocation[idx:]
req = NewRequest(t, "GET", url).
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp = MakeRequest(t, req, http.StatusOK)
body := strings.Repeat("B", 4096)
assert.Equal(t, resp.Body.String(), body)
}
}
func TestActionRunAttemptArtifact(t *testing.T) {
defer prepareTestEnvActionsArtifacts(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)
apiRepo := createActionsTestRepo(t, token, "actions-run-attempt-artifact", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(httpContext)(t)
runner := newMockRunner()
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
wfTreePath := ".gitea/workflows/run-attempt-artifact.yml"
wfFileContent := `name: run-attempt-artifact
on:
workflow_dispatch:
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo 'job1'
`
opts := getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "create "+wfTreePath, wfFileContent)
createWorkflowFile(t, token, user2.Name, repo.Name, wfTreePath, opts)
urlStr := fmt.Sprintf("/%s/%s/actions/run?workflow=%s", user2.Name, repo.Name, "run-attempt-artifact.yml")
req := NewRequestWithValues(t, "POST", urlStr, map[string]string{
"ref": "refs/heads/main",
})
session.MakeRequest(t, req, http.StatusSeeOther)
t.Run("testActionRunAttemptArtifactV3", func(t *testing.T) {
testActionRunAttemptArtifactV3(t, repo, session, runner)
})
t.Run("testActionRunAttemptArtifactV4", func(t *testing.T) {
testActionRunAttemptArtifactV4(t, repo, session, runner)
})
t.Run("testPartialRerunArtifactInheritance", func(t *testing.T) {
testPartialRerunArtifactInheritance(t, user2, token, repo, session, runner)
})
})
}
func testActionRunAttemptArtifactV3(t *testing.T, repo *repo_model.Repository, session *TestSession, runner *mockRunner) {
// first run
task1 := runner.fetchTask(t)
_, job1, run := getTaskAndJobAndRunByTaskID(t, task1.Id)
require.NotZero(t, job1.RunAttemptID)
taskToken1 := task1.Context.GetFields()["gitea_runtime_token"].GetStringValue()
require.NotEmpty(t, taskToken1)
uploadTestArtifactFile(t, run.ID, taskToken1, "artifact-attempt-1", "attempt-1.txt", strings.Repeat("A", 32))
uploadTestArtifactFile(t, run.ID, taskToken1, "artifact-shared", "shared.txt", strings.Repeat("C", 32))
attempt1Names := listArtifactNamesForRun(t, run.ID, taskToken1)
assert.ElementsMatch(t, []string{"artifact-attempt-1", "artifact-shared"}, attempt1Names)
runner.execTask(t, task1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS}) // complete first run
// rerun
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", repo.OwnerName, repo.Name, run.ID))
session.MakeRequest(t, req, http.StatusOK)
task2 := runner.fetchTask(t)
_, job2, _ := getTaskAndJobAndRunByTaskID(t, task2.Id)
require.NotZero(t, job2.RunAttemptID)
assert.NotEqual(t, job1.RunAttemptID, job2.RunAttemptID)
taskToken2 := task2.Context.GetFields()["gitea_runtime_token"].GetStringValue()
require.NotEmpty(t, taskToken2)
uploadTestArtifactFile(t, run.ID, taskToken2, "artifact-attempt-2", "attempt-2.txt", strings.Repeat("B", 32))
uploadTestArtifactFile(t, run.ID, taskToken2, "artifact-shared", "shared.txt", strings.Repeat("D", 32))
attempt2Names := listArtifactNamesForRun(t, run.ID, taskToken2)
assert.ElementsMatch(t, []string{"artifact-attempt-2", "artifact-shared"}, attempt2Names)
assert.NotContains(t, attempt2Names, "artifact-attempt-1")
// "artifact-attempt-1" belongs to the first attempt, so the rerun token cannot access it
req = NewRequest(t, "GET", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts/%x/download_url?itemPath=artifact-attempt-1", run.ID, md5.Sum([]byte("artifact-attempt-1")))).
AddTokenAuth(taskToken2)
MakeRequest(t, req, http.StatusNotFound)
// "artifact-shared" for each attempt has different content
sharedContent1 := downloadArtifactFileContentByAttempt(t, session, repo.OwnerName, repo.Name, run.ID, "artifact-shared", 1, "shared.txt")
assert.Equal(t, strings.Repeat("C", 32), sharedContent1)
sharedContent2 := downloadArtifactFileContentByAttempt(t, session, repo.OwnerName, repo.Name, run.ID, "artifact-shared", 2, "shared.txt")
assert.Equal(t, strings.Repeat("D", 32), sharedContent2)
}
func testPartialRerunArtifactInheritance(t *testing.T, user *user_model.User, token string, repo *repo_model.Repository, session *TestSession, runner *mockRunner) {
wfTreePath := ".gitea/workflows/partial-rerun-artifact.yml"
wfFileContent := `name: partial-rerun-artifact
on:
workflow_dispatch:
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo 'job1'
job2:
runs-on: ubuntu-latest
needs: job1
steps:
- run: echo 'job2'
`
opts := getWorkflowCreateFileOptions(user, repo.DefaultBranch, "create "+wfTreePath, wfFileContent)
createWorkflowFile(t, token, user.Name, repo.Name, wfTreePath, opts)
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/actions/run?workflow=%s", repo.OwnerName, repo.Name, "partial-rerun-artifact.yml"), map[string]string{
"ref": "refs/heads/main",
})
session.MakeRequest(t, req, http.StatusSeeOther)
// job1 uploads the artifacts and succeeds
task1 := runner.fetchTask(t)
_, job1, run := getTaskAndJobAndRunByTaskID(t, task1.Id)
taskToken1 := task1.Context.GetFields()["gitea_runtime_token"].GetStringValue()
uploadTestArtifactFileV4(t, run.ID, job1.ID, taskToken1, "job1-only", strings.Repeat("A", 32))
uploadTestArtifactFileV4(t, run.ID, job1.ID, taskToken1, "job1-shared", strings.Repeat("B", 32))
// a v3 artifact is one row per file, so this one spans two rows
uploadTestArtifactFile(t, run.ID, taskToken1, "job1-v3", "a.txt", strings.Repeat("D", 32))
uploadTestArtifactFile(t, run.ID, taskToken1, "job1-v3", "b.txt", strings.Repeat("E", 32))
runner.execTask(t, task1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
// job2 fails
task2 := runner.fetchTask(t)
_, job2, _ := getTaskAndJobAndRunByTaskID(t, task2.Id)
runner.execTask(t, task2, &mockTaskOutcome{result: runnerv1.Result_RESULT_FAILURE})
// re-run only the failed job, so job1 is passed through and never uploads its artifacts again
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun-failed", repo.OwnerName, repo.Name, run.ID))
session.MakeRequest(t, req, http.StatusOK)
task3 := runner.fetchTask(t)
_, job3, _ := getTaskAndJobAndRunByTaskID(t, task3.Id)
require.Equal(t, job2.JobID, job3.JobID)
require.NotEqual(t, job2.RunAttemptID, job3.RunAttemptID)
taskToken3 := task3.Context.GetFields()["gitea_runtime_token"].GetStringValue()
// the new attempt inherits what the previous attempt uploaded
assert.ElementsMatch(t, []string{"job1-only", "job1-shared"}, listArtifactNamesForRunV4(t, run.ID, job3.ID, taskToken3))
assert.Equal(t, strings.Repeat("A", 32), downloadArtifactContentV4ByTask(t, run.ID, job3.ID, taskToken3, "job1-only"))
assert.Contains(t, listArtifactNamesForRun(t, run.ID, taskToken3), "job1-v3")
// a pending upload of this attempt must not shadow the confirmed copy it inherited
createTestArtifactV4(t, run.ID, job3.ID, taskToken3, "job1-only")
assert.Equal(t, strings.Repeat("A", 32), downloadArtifactContentV4ByTask(t, run.ID, job3.ID, taskToken3, "job1-only"))
// both rows of the inherited v3 artifact are readable
inheritedV3 := getArtifactDownloadItemsForRun(t, run.ID, taskToken3, "job1-v3")
require.Len(t, inheritedV3, 2)
assert.Equal(t, strings.Repeat("D", 32), downloadArtifactItemContent(t, taskToken3, inheritedV3[0]))
// uploading an inherited name in this attempt shadows the inherited artifact
inheritedSharedID := listArtifactIDForRunV4(t, run.ID, job3.ID, taskToken3, "job1-shared")
require.Len(t, listArtifactsByIDV4(t, run.ID, job3.ID, inheritedSharedID, taskToken3), 1)
uploadTestArtifactFileV4(t, run.ID, job3.ID, taskToken3, "job1-shared", strings.Repeat("C", 32))
assert.ElementsMatch(t, []string{"job1-only", "job1-shared"}, listArtifactNamesForRunV4(t, run.ID, job3.ID, taskToken3))
assert.Equal(t, strings.Repeat("C", 32), downloadArtifactContentV4ByTask(t, run.ID, job3.ID, taskToken3, "job1-shared"))
// a shadowed artifact is not listed by id either: a download resolves by name
assert.Empty(t, listArtifactsByIDV4(t, run.ID, job3.ID, inheritedSharedID, taskToken3))
// the shadowed v3 artifact is dropped as a whole, its b.txt row must not survive next to the new a.txt
uploadTestArtifactFile(t, run.ID, taskToken3, "job1-v3", "a.txt", strings.Repeat("F", 32))
shadowedV3 := getArtifactDownloadItemsForRun(t, run.ID, taskToken3, "job1-v3")
require.Len(t, shadowedV3, 1)
assert.Equal(t, strings.Repeat("F", 32), downloadArtifactItemContent(t, taskToken3, shadowedV3[0]))
runner.execTask(t, task3, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
}
func getArtifactDownloadItemsForRun(t *testing.T, runID int64, taskToken, artifactName string) []downloadArtifactResponseItem {
t.Helper()
req := NewRequest(t, "GET", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts/%x/download_url?itemPath=%s", runID, md5.Sum([]byte(artifactName)), artifactName)).
AddTokenAuth(taskToken)
resp := MakeRequest(t, req, http.StatusOK)
return DecodeJSON(t, resp, &downloadArtifactResponse{}).Value
}
func downloadArtifactItemContent(t *testing.T, taskToken string, item downloadArtifactResponseItem) string {
t.Helper()
idx := strings.Index(item.ContentLocation, "/api/actions_pipeline/_apis/pipelines/")
require.NotEqual(t, -1, idx)
req := NewRequest(t, "GET", item.ContentLocation[idx:]).AddTokenAuth(taskToken)
return MakeRequest(t, req, http.StatusOK).Body.String()
}
func uploadTestArtifactFile(t *testing.T, runID int64, authToken, artifactName, fileName, content string) {
t.Helper()
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts", runID), getUploadArtifactRequest{
Type: "actions_storage",
Name: artifactName,
}).AddTokenAuth(authToken)
resp := MakeRequest(t, req, http.StatusOK)
uploadResp := DecodeJSON(t, resp, &uploadArtifactResponse{})
idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
uploadURL := uploadResp.FileContainerResourceURL[idx:] + "?itemPath=" + artifactName + "/" + fileName
contentLen := strconv.Itoa(len(content))
contentMD5 := md5.Sum([]byte(content))
req = NewRequestWithBody(t, "PUT", uploadURL, strings.NewReader(content)).
AddTokenAuth(authToken).
SetHeader("Content-Range", fmt.Sprintf("bytes 0-%d/%d", len(content)-1, len(content))).
SetHeader("x-tfs-filelength", contentLen).
SetHeader("x-actions-results-md5", base64.StdEncoding.EncodeToString(contentMD5[:]))
MakeRequest(t, req, http.StatusOK)
req = NewRequest(t, "PATCH", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts?artifactName=%s", runID, artifactName)).
AddTokenAuth(authToken)
MakeRequest(t, req, http.StatusOK)
}
func listArtifactNamesForRun(t *testing.T, runID int64, taskToken string) []string {
t.Helper()
req := NewRequest(t, "GET", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts", runID)).
AddTokenAuth(taskToken)
resp := MakeRequest(t, req, http.StatusOK)
listResp := DecodeJSON(t, resp, &listArtifactsResponse{})
names := make([]string, 0, len(listResp.Value))
for _, item := range listResp.Value {
names = append(names, item.Name)
}
return names
}
func downloadArtifactFileContentByAttempt(t *testing.T, session *TestSession, owner, repo string, runID int64, artifactName string, attempt int64, fileName string) string {
t.Helper()
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/artifacts/%s?attempt=%d", owner, repo, runID, url.PathEscape(artifactName), attempt))
resp := session.MakeRequest(t, req, http.StatusOK)
zr, err := zip.NewReader(bytes.NewReader(resp.Body.Bytes()), int64(resp.Body.Len()))
require.NoError(t, err)
for _, f := range zr.File {
if f.Name != fileName {
continue
}
rc, err := f.Open()
require.NoError(t, err)
content, err := io.ReadAll(rc)
rc.Close()
require.NoError(t, err)
return string(content)
}
require.FailNowf(t, "artifact file not found", "artifact %q attempt %d does not contain file %q", artifactName, attempt, fileName)
return ""
}