Skip to content

Commit 2b8cfb5

Browse files
Artifacts download api for artifact actions v4 (#33510)
* download endpoint has to use 302 redirect * fake blob download used if direct download not possible * downloading v3 artifacts not possible New repo apis based on GitHub Rest V3 - GET /runs/{run}/artifacts (Cannot use run index of url due to not being unique) - GET /artifacts - GET + DELETE /artifacts/{artifact_id} - GET /artifacts/{artifact_id}/zip - (GET /artifacts/{artifact_id}/zip/raw this is a workaround for a http 302 assertion in actions/toolkit) - api docs removed this is protected by a signed url like the internal artifacts api and no longer usable with any token or swagger - returns http 401 if the signature is invalid - or change the artifact id - or expired after 1 hour Closes #33353 Closes #32124 --------- Co-authored-by: wxiaoguang <[email protected]>
1 parent 01bf8da commit 2b8cfb5

File tree

14 files changed

+1146
-27
lines changed

14 files changed

+1146
-27
lines changed

cmd/migrate_storage.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ func migrateActionsLog(ctx context.Context, dstStorage storage.ObjectStorage) er
196196

197197
func migrateActionsArtifacts(ctx context.Context, dstStorage storage.ObjectStorage) error {
198198
return db.Iterate(ctx, nil, func(ctx context.Context, artifact *actions_model.ActionArtifact) error {
199-
if artifact.Status == int64(actions_model.ArtifactStatusExpired) {
199+
if artifact.Status == actions_model.ArtifactStatusExpired {
200200
return nil
201201
}
202202

models/actions/artifact.go

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ type ActionArtifact struct {
4848
ContentEncoding string // The content encoding of the artifact
4949
ArtifactPath string `xorm:"index unique(runid_name_path)"` // The path to the artifact when runner uploads it
5050
ArtifactName string `xorm:"index unique(runid_name_path)"` // The name of the artifact when runner uploads it
51-
Status int64 `xorm:"index"` // The status of the artifact, uploading, expired or need-delete
51+
Status ArtifactStatus `xorm:"index"` // The status of the artifact, uploading, expired or need-delete
5252
CreatedUnix timeutil.TimeStamp `xorm:"created"`
5353
UpdatedUnix timeutil.TimeStamp `xorm:"updated index"`
5454
ExpiredUnix timeutil.TimeStamp `xorm:"index"` // The time when the artifact will be expired
@@ -68,7 +68,7 @@ func CreateArtifact(ctx context.Context, t *ActionTask, artifactName, artifactPa
6868
RepoID: t.RepoID,
6969
OwnerID: t.OwnerID,
7070
CommitSHA: t.CommitSHA,
71-
Status: int64(ArtifactStatusUploadPending),
71+
Status: ArtifactStatusUploadPending,
7272
ExpiredUnix: timeutil.TimeStamp(time.Now().Unix() + timeutil.Day*expiredDays),
7373
}
7474
if _, err := db.GetEngine(ctx).Insert(artifact); err != nil {
@@ -108,10 +108,11 @@ func UpdateArtifactByID(ctx context.Context, id int64, art *ActionArtifact) erro
108108

109109
type FindArtifactsOptions struct {
110110
db.ListOptions
111-
RepoID int64
112-
RunID int64
113-
ArtifactName string
114-
Status int
111+
RepoID int64
112+
RunID int64
113+
ArtifactName string
114+
Status int
115+
FinalizedArtifactsV4 bool
115116
}
116117

117118
func (opts FindArtifactsOptions) ToOrders() string {
@@ -134,6 +135,10 @@ func (opts FindArtifactsOptions) ToConds() builder.Cond {
134135
if opts.Status > 0 {
135136
cond = cond.And(builder.Eq{"status": opts.Status})
136137
}
138+
if opts.FinalizedArtifactsV4 {
139+
cond = cond.And(builder.Eq{"status": ArtifactStatusUploadConfirmed}.Or(builder.Eq{"status": ArtifactStatusExpired}))
140+
cond = cond.And(builder.Eq{"content_encoding": "application/zip"})
141+
}
137142

138143
return cond
139144
}
@@ -172,18 +177,18 @@ func ListPendingDeleteArtifacts(ctx context.Context, limit int) ([]*ActionArtifa
172177

173178
// SetArtifactExpired sets an artifact to expired
174179
func SetArtifactExpired(ctx context.Context, artifactID int64) error {
175-
_, err := db.GetEngine(ctx).Where("id=? AND status = ?", artifactID, ArtifactStatusUploadConfirmed).Cols("status").Update(&ActionArtifact{Status: int64(ArtifactStatusExpired)})
180+
_, err := db.GetEngine(ctx).Where("id=? AND status = ?", artifactID, ArtifactStatusUploadConfirmed).Cols("status").Update(&ActionArtifact{Status: ArtifactStatusExpired})
176181
return err
177182
}
178183

179184
// SetArtifactNeedDelete sets an artifact to need-delete, cron job will delete it
180185
func SetArtifactNeedDelete(ctx context.Context, runID int64, name string) error {
181-
_, err := db.GetEngine(ctx).Where("run_id=? AND artifact_name=? AND status = ?", runID, name, ArtifactStatusUploadConfirmed).Cols("status").Update(&ActionArtifact{Status: int64(ArtifactStatusPendingDeletion)})
186+
_, err := db.GetEngine(ctx).Where("run_id=? AND artifact_name=? AND status = ?", runID, name, ArtifactStatusUploadConfirmed).Cols("status").Update(&ActionArtifact{Status: ArtifactStatusPendingDeletion})
182187
return err
183188
}
184189

185190
// SetArtifactDeleted sets an artifact to deleted
186191
func SetArtifactDeleted(ctx context.Context, artifactID int64) error {
187-
_, err := db.GetEngine(ctx).ID(artifactID).Cols("status").Update(&ActionArtifact{Status: int64(ArtifactStatusDeleted)})
192+
_, err := db.GetEngine(ctx).ID(artifactID).Cols("status").Update(&ActionArtifact{Status: ArtifactStatusDeleted})
188193
return err
189194
}

models/fixtures/action_artifact.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,21 @@
6969
created_unix: 1730330775
7070
updated_unix: 1730330775
7171
expired_unix: 1738106775
72+
73+
-
74+
id: 23
75+
run_id: 793
76+
runner_id: 1
77+
repo_id: 2
78+
owner_id: 2
79+
commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
80+
storage_path: "27/5/1730330775594233150.chunk"
81+
file_size: 1024
82+
file_compressed_size: 1024
83+
content_encoding: "application/zip"
84+
artifact_path: "artifact-v4-download.zip"
85+
artifact_name: "artifact-v4-download"
86+
status: 2
87+
created_unix: 1730330775
88+
updated_unix: 1730330775
89+
expired_unix: 1738106775

modules/actions/artifacts.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package actions
5+
6+
import (
7+
"net/http"
8+
9+
actions_model "code.gitea.io/gitea/models/actions"
10+
"code.gitea.io/gitea/modules/setting"
11+
"code.gitea.io/gitea/modules/storage"
12+
"code.gitea.io/gitea/services/context"
13+
)
14+
15+
// Artifacts using the v4 backend are stored as a single combined zip file per artifact on the backend
16+
// The v4 backend ensures ContentEncoding is set to "application/zip", which is not the case for the old backend
17+
func IsArtifactV4(art *actions_model.ActionArtifact) bool {
18+
return art.ArtifactName+".zip" == art.ArtifactPath && art.ContentEncoding == "application/zip"
19+
}
20+
21+
func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.ActionArtifact) (bool, error) {
22+
if setting.Actions.ArtifactStorage.ServeDirect() {
23+
u, err := storage.ActionsArtifacts.URL(art.StoragePath, art.ArtifactPath, nil)
24+
if u != nil && err == nil {
25+
ctx.Redirect(u.String(), http.StatusFound)
26+
return true, nil
27+
}
28+
}
29+
return false, nil
30+
}
31+
32+
func DownloadArtifactV4Fallback(ctx *context.Base, art *actions_model.ActionArtifact) error {
33+
f, err := storage.ActionsArtifacts.Open(art.StoragePath)
34+
if err != nil {
35+
return err
36+
}
37+
defer f.Close()
38+
http.ServeContent(ctx.Resp, ctx.Req, art.ArtifactName+".zip", art.CreatedUnix.AsLocalTime(), f)
39+
return nil
40+
}
41+
42+
func DownloadArtifactV4(ctx *context.Base, art *actions_model.ActionArtifact) error {
43+
ok, err := DownloadArtifactV4ServeDirectOnly(ctx, art)
44+
if ok || err != nil {
45+
return err
46+
}
47+
return DownloadArtifactV4Fallback(ctx, art)
48+
}

modules/structs/repo_actions.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,34 @@ type ActionWorkflowResponse struct {
6565
Workflows []*ActionWorkflow `json:"workflows"`
6666
TotalCount int64 `json:"total_count"`
6767
}
68+
69+
// ActionArtifact represents a ActionArtifact
70+
type ActionArtifact struct {
71+
ID int64 `json:"id"`
72+
Name string `json:"name"`
73+
SizeInBytes int64 `json:"size_in_bytes"`
74+
URL string `json:"url"`
75+
ArchiveDownloadURL string `json:"archive_download_url"`
76+
Expired bool `json:"expired"`
77+
WorkflowRun *ActionWorkflowRun `json:"workflow_run"`
78+
79+
// swagger:strfmt date-time
80+
CreatedAt time.Time `json:"created_at"`
81+
// swagger:strfmt date-time
82+
UpdatedAt time.Time `json:"updated_at"`
83+
// swagger:strfmt date-time
84+
ExpiresAt time.Time `json:"expires_at"`
85+
}
86+
87+
// ActionWorkflowRun represents a WorkflowRun
88+
type ActionWorkflowRun struct {
89+
ID int64 `json:"id"`
90+
RepositoryID int64 `json:"repository_id"`
91+
HeadSha string `json:"head_sha"`
92+
}
93+
94+
// ActionArtifactsResponse returns ActionArtifacts
95+
type ActionArtifactsResponse struct {
96+
Entries []*ActionArtifact `json:"artifacts"`
97+
TotalCount int64 `json:"total_count"`
98+
}

routers/api/actions/artifacts_chunks.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ func mergeChunksForArtifact(ctx *ArtifactContext, chunks []*chunkFileItem, st st
292292
}
293293

294294
artifact.StoragePath = storagePath
295-
artifact.Status = int64(actions.ArtifactStatusUploadConfirmed)
295+
artifact.Status = actions.ArtifactStatusUploadConfirmed
296296
if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil {
297297
return fmt.Errorf("update artifact error: %v", err)
298298
}

routers/api/actions/artifactsv4.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ package actions
2525
// 1.3. Continue Upload Zip Content to Blobstorage (unauthenticated request), repeat until everything is uploaded
2626
// PUT: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75&comp=appendBlock
2727
// 1.4. BlockList xml payload to Blobstorage (unauthenticated request)
28-
// Files of about 800MB are parallel in parallel and / or out of order, this file is needed to enshure the correct order
28+
// Files of about 800MB are parallel in parallel and / or out of order, this file is needed to ensure the correct order
2929
// PUT: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75&comp=blockList
3030
// Request
3131
// <?xml version="1.0" encoding="UTF-8" standalone="yes"?>

routers/api/v1/api.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1241,6 +1241,13 @@ func Routes() *web.Router {
12411241
}, reqToken(), reqAdmin())
12421242
m.Group("/actions", func() {
12431243
m.Get("/tasks", repo.ListActionTasks)
1244+
m.Get("/runs/{run}/artifacts", repo.GetArtifactsOfRun)
1245+
m.Get("/artifacts", repo.GetArtifacts)
1246+
m.Group("/artifacts/{artifact_id}", func() {
1247+
m.Get("", repo.GetArtifact)
1248+
m.Delete("", reqRepoWriter(unit.TypeActions), repo.DeleteArtifact)
1249+
})
1250+
m.Get("/artifacts/{artifact_id}/zip", repo.DownloadArtifact)
12441251
}, reqRepoReader(unit.TypeActions), context.ReferencesGitRepo(true))
12451252
m.Group("/keys", func() {
12461253
m.Combo("").Get(repo.ListDeployKeys).
@@ -1401,6 +1408,10 @@ func Routes() *web.Router {
14011408
}, repoAssignment(), checkTokenPublicOnly())
14021409
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
14031410

1411+
// Artifacts direct download endpoint authenticates via signed url
1412+
// it is protected by the "sig" parameter (to help to access private repo), so no need to use other middlewares
1413+
m.Get("/repos/{username}/{reponame}/actions/artifacts/{artifact_id}/zip/raw", repo.DownloadArtifactRaw)
1414+
14041415
// Notifications (requires notifications scope)
14051416
m.Group("/repos", func() {
14061417
m.Group("/{username}/{reponame}", func() {

0 commit comments

Comments
 (0)