Files
gitea/tests/integration/api_actions_artifact_test.go
T
Lunny Xiao 2087d4a1a5 refactor: use the shared workflow model from actionslib (#38768)
Pairs with https://gitea.com/gitea/runner/pulls/1143.

Gitea depends on `gitea.com/gitea/runner` for exactly two packages:
`act/model` and `act/exprparser`, the workflow model and the expression
evaluator it needs to parse workflows and to build the task payload the
runner consumes. Pulling the whole runner module in for that is heavy
and puts shared code in the repository of one of the two consumers.

Both packages now live in `gitea.dev/actionslib` (`pkg/model`,
`pkg/exprparser`), the module Gitea and the runner already share for the
runner API, so the dependency on the runner repository is dropped here.

### Changes

- `gitea.com/gitea/runner/act/model` ->
`gitea.dev/actionslib/pkg/model`, `.../act/exprparser` ->
`gitea.dev/actionslib/pkg/exprparser` (22 files, import paths only).
- `routers/api/actions/runner/interceptor.go` takes the `x-runner-uuid`
/ `x-runner-token` names from `gitea.dev/actionslib/pkg/protocol`
instead of repeating the literals the runner also has.
- `go.mod`: `gitea.com/gitea/runner` removed.

---------

Signed-off-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-08-07 20:56:40 -07:00

694 lines
28 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)
})
})
}
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 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 ""
}