Skip to content
Merged
Show file tree
Hide file tree
Changes from 76 commits
Commits
Show all changes
85 commits
Select commit Hold shift + click to select a range
8901741
Feature non zipped actions artifacts
ChristopherHX Feb 28, 2026
accc370
Merge branch 'main' into non-zip-artifacts
ChristopherHX Feb 28, 2026
36b0c0e
fix typo
ChristopherHX Feb 28, 2026
1fd4fbe
.
ChristopherHX Feb 28, 2026
4b1fa71
.
ChristopherHX Feb 28, 2026
ff5d1bb
revert b64chunkName encoding, seams like I caused the initial issue i…
ChristopherHX Feb 28, 2026
c6540ef
add Content-Security-Policy
ChristopherHX Feb 28, 2026
035aa22
Revert "add Content-Security-Policy"
ChristopherHX Feb 28, 2026
33a2b68
Update routers/web/repo/actions/view.go
ChristopherHX Feb 28, 2026
2980ea9
update comment
ChristopherHX Feb 28, 2026
fef0ca4
implement TODO
ChristopherHX Mar 1, 2026
076ee4f
add more upload tests without content-length header
ChristopherHX Mar 1, 2026
b70c185
assert mime type in db
ChristopherHX Mar 1, 2026
e2c19cf
lint
ChristopherHX Mar 1, 2026
f032a5a
fix comment
ChristopherHX Mar 1, 2026
cf03c2a
add Content-Security-Policy + inline back
ChristopherHX Mar 1, 2026
7a33ad9
fix minio
ChristopherHX Mar 1, 2026
c773028
Set content headers for internal download
ChristopherHX Mar 1, 2026
fe9a14d
test blob storage content type
ChristopherHX Mar 1, 2026
ceb933e
fixes
ChristopherHX Mar 1, 2026
b8b3d67
Merge reqParams before returning
ChristopherHX Mar 1, 2026
3c3d45d
.
ChristopherHX Mar 1, 2026
07fbdbc
...
ChristopherHX Mar 1, 2026
cfb3e7e
fix test
ChristopherHX Mar 1, 2026
754c601
fixes
ChristopherHX Mar 1, 2026
2bae491
Merge branch 'main' into non-zip-artifacts
ChristopherHX Mar 5, 2026
57e7dce
Merge branch 'main' of https://github.com/go-gitea/gitea into non-zip…
ChristopherHX Mar 7, 2026
c302794
cleanup
ChristopherHX Mar 7, 2026
fbf28e8
fix
ChristopherHX Mar 7, 2026
10a7cae
cleanup
ChristopherHX Mar 7, 2026
1ea4611
Remove hacks
ChristopherHX Mar 7, 2026
893a0be
revert
ChristopherHX Mar 7, 2026
ff31359
add missing code for webui content-type serve direct
ChristopherHX Mar 7, 2026
68f5ee9
...
ChristopherHX Mar 7, 2026
702fa9a
Merge branch 'main' of https://github.com/go-gitea/gitea into non-zip…
ChristopherHX Mar 9, 2026
8c3590b
refactor
ChristopherHX Mar 9, 2026
744bb71
Reenable ServeDirect for minio
ChristopherHX Mar 9, 2026
2994294
update comments
ChristopherHX Mar 9, 2026
deae805
fix double import
ChristopherHX Mar 9, 2026
6666dff
close body
ChristopherHX Mar 9, 2026
dcbe0d6
cleanup
ChristopherHX Mar 9, 2026
8ba37a2
GetArtifactV4ServeDirectURL add method param
ChristopherHX Mar 10, 2026
9c2528e
application/pdf skip Content-Security-Policy
ChristopherHX Mar 10, 2026
89af55d
Only use inline for web route
ChristopherHX Mar 10, 2026
b6a1ba6
add download tests other file types internal api
ChristopherHX Mar 10, 2026
d1ac1db
Fix old test
ChristopherHX Mar 11, 2026
83c6dc5
Merge branch 'main' of https://github.com/go-gitea/gitea into non-zip…
ChristopherHX Mar 22, 2026
8fd3a9a
refac
ChristopherHX Mar 22, 2026
51280b0
fixes
ChristopherHX Mar 22, 2026
60b39df
refactor content disposition formatting
ChristopherHX Mar 22, 2026
6772530
add copyright
ChristopherHX Mar 22, 2026
1282340
apply feedback
ChristopherHX Mar 22, 2026
95cfb1e
fix
ChristopherHX Mar 22, 2026
fc05027
feedback
ChristopherHX Mar 22, 2026
e1fb863
add more tests for ContentDisposition
ChristopherHX Mar 22, 2026
5dd8e5e
revert content-type
ChristopherHX Mar 24, 2026
6dc705a
move from public to httplib
ChristopherHX Mar 24, 2026
e9bc1ca
Unify artifact storage path
ChristopherHX Mar 24, 2026
70cc32a
Split finalizeArtifact finalizer
ChristopherHX Mar 24, 2026
8399396
Merge branch 'main' of https://github.com/go-gitea/gitea into non-zip…
ChristopherHX Mar 24, 2026
5331f99
fix lint
ChristopherHX Mar 24, 2026
a4aad65
clarify ContentEncoding
wxiaoguang Mar 25, 2026
809822c
clarify ContentEncoding
wxiaoguang Mar 25, 2026
3606c73
fix defer
wxiaoguang Mar 25, 2026
d7006b8
clean serve file opts
wxiaoguang Mar 25, 2026
071ae49
* artifact rest api also use base64.RawURLEncoding to avoid possible …
ChristopherHX Mar 25, 2026
068a324
add comment
wxiaoguang Mar 25, 2026
c544734
fix comments and tests
wxiaoguang Mar 25, 2026
57a7679
fix db column name
wxiaoguang Mar 25, 2026
efd98a2
clean up
wxiaoguang Mar 25, 2026
7364517
clean up
wxiaoguang Mar 25, 2026
88a7abb
fix tests
wxiaoguang Mar 25, 2026
6c40a56
fix fmt
wxiaoguang Mar 25, 2026
4fc90b0
fix test
wxiaoguang Mar 25, 2026
50737ec
fix test
wxiaoguang Mar 25, 2026
dc0f605
simplify code
wxiaoguang Mar 25, 2026
51371c3
fix content type detection
wxiaoguang Mar 25, 2026
7b9922a
fix content serving
wxiaoguang Mar 25, 2026
6eef0e4
fix DownloadArtifactV4ReadStorage
wxiaoguang Mar 25, 2026
8081b79
fix incorrect httplib.ServeXxx
wxiaoguang Mar 25, 2026
3c3c78a
revert tests
wxiaoguang Mar 25, 2026
b00f5ec
rename internal function
wxiaoguang Mar 25, 2026
d5a62f2
fix default ContentDisposition
wxiaoguang Mar 25, 2026
83231b5
clarify content-type charset detection
wxiaoguang Mar 25, 2026
7d80929
Merge branch 'main' into non-zip-artifacts
wxiaoguang Mar 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 27 additions & 11 deletions models/actions/artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ func init() {
db.RegisterModel(new(ActionArtifact))
}

const (
ContentEncodingV3Gzip = "gzip"
ContentTypeZip = "application/zip"
)

// ActionArtifact is a file that is stored in the artifact storage.
type ActionArtifact struct {
ID int64 `xorm:"pk autoincr"`
Expand All @@ -61,16 +66,26 @@ type ActionArtifact struct {
RepoID int64 `xorm:"index"`
OwnerID int64
CommitSHA string
StoragePath string // The path to the artifact in the storage
FileSize int64 // The size of the artifact in bytes
FileCompressedSize int64 // The size of the artifact in bytes after gzip compression
ContentEncoding string // The content encoding of the artifact
ArtifactPath string `xorm:"index unique(runid_name_path)"` // The path to the artifact when runner uploads it
ArtifactName string `xorm:"index unique(runid_name_path)"` // The name of the artifact when runner uploads it
Status ArtifactStatus `xorm:"index"` // The status of the artifact, uploading, expired or need-delete
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated index"`
ExpiredUnix timeutil.TimeStamp `xorm:"index"` // The time when the artifact will be expired
StoragePath string // The path to the artifact in the storage
FileSize int64 // The size of the artifact in bytes
FileCompressedSize int64 // The size of the artifact in bytes after gzip compression

// The content encoding or content type of the artifact
// * empty or null: legacy (v3) uncompressed content
// * magic string "gzip" (ContentEncodingV3Gzip): v3 gzip compressed content
// * requires gzip decoding before storing in a zip for download
// * requires gzip content-encoding header when downloaded single files within a workflow
// * mime type for "Content-Type":
// * "application/zip" (ContentTypeZip), seems to be an abuse, fortunately there is no conflict, and it won't cause problems?
// * "application/pdf", "text/html", etc.: real content type of the artifact
ContentEncodingOrType string `xorm:"content_encoding"`

ArtifactPath string `xorm:"index unique(runid_name_path)"` // The path to the artifact when runner uploads it
ArtifactName string `xorm:"index unique(runid_name_path)"` // The name of the artifact when runner uploads it
Status ArtifactStatus `xorm:"index"` // The status of the artifact, uploading, expired or need-delete
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated index"`
ExpiredUnix timeutil.TimeStamp `xorm:"index"` // The time when the artifact will be expired
}

func CreateArtifact(ctx context.Context, t *ActionTask, artifactName, artifactPath string, expiredDays int64) (*ActionArtifact, error) {
Expand Down Expand Up @@ -156,7 +171,8 @@ func (opts FindArtifactsOptions) ToConds() builder.Cond {
}
if opts.FinalizedArtifactsV4 {
cond = cond.And(builder.Eq{"status": ArtifactStatusUploadConfirmed}.Or(builder.Eq{"status": ArtifactStatusExpired}))
cond = cond.And(builder.Eq{"content_encoding": "application/zip"})
// see the comment of ActionArtifact.ContentEncodingOrType: "*/*" means the field is a content type
cond = cond.And(builder.Like{"content_encoding", "%/%"})
}

return cond
Expand Down
36 changes: 36 additions & 0 deletions models/fixtures/action_artifact.yml
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,39 @@
created_unix: 1730330775
updated_unix: 1730330775
expired_unix: 1738106775

-
id: 26
run_id: 792
runner_id: 1
repo_id: 4
owner_id: 1
commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
storage_path: "27/5/1730330775594233150.chunk"
file_size: 1024
file_compressed_size: 1024
content_encoding: "application/pdf"
artifact_path: "report.pdf"
artifact_name: "report.pdf"
status: 2
created_unix: 1730330775
updated_unix: 1730330775
expired_unix: 1738106775

-
id: 27
run_id: 792
runner_id: 1
repo_id: 4
owner_id: 1
commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
storage_path: "27/5/1730330775594233150.chunk"
file_size: 1024
file_compressed_size: 1024
content_encoding: "application/html"
artifact_path: "report.html"
artifact_name: "report.html"
status: 2
created_unix: 1730330775
updated_unix: 1730330775
expired_unix: 1738106775
56 changes: 39 additions & 17 deletions modules/actions/artifacts.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,44 +5,66 @@ package actions

import (
"net/http"
"path"
"strings"

actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/services/context"
)

// Artifacts using the v4 backend are stored as a single combined zip file per artifact on the backend
// The v4 backend ensures ContentEncoding is set to "application/zip", which is not the case for the old backend
// IsArtifactV4 detects whether the artifact is likely from v4.
// V4 backend stores the files as a single combined zip file per artifact, and ensures ContentEncoding contains a slash
// (otherwise this uses application/zip instead of the custom mime type), which is not the case for the old backend.
func IsArtifactV4(art *actions_model.ActionArtifact) bool {
return art.ArtifactName+".zip" == art.ArtifactPath && art.ContentEncoding == "application/zip"
return strings.Contains(art.ContentEncodingOrType, "/")
}

func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.ActionArtifact) (bool, error) {
if setting.Actions.ArtifactStorage.ServeDirect() {
u, err := storage.ActionsArtifacts.ServeDirectURL(art.StoragePath, art.ArtifactPath, ctx.Req.Method, nil)
if u != nil && err == nil {
ctx.Redirect(u.String(), http.StatusFound)
return true, nil
}
func GetArtifactV4ServeDirectURL(art *actions_model.ActionArtifact, method string) (string, error) {
contentType := art.ContentEncodingOrType
u, err := storage.ActionsArtifacts.ServeDirectURL(art.StoragePath, art.ArtifactPath, method, &storage.ServeDirectOptions{ContentType: contentType})
if err != nil {
return "", err
}
return u.String(), nil
}

func DownloadArtifactV4ServeDirect(ctx *context.Base, art *actions_model.ActionArtifact) bool {
if !setting.Actions.ArtifactStorage.ServeDirect() {
return false
}
return false, nil
u, err := GetArtifactV4ServeDirectURL(art, ctx.Req.Method)
if err != nil {
log.Error("GetArtifactV4ServeDirectURL: %v", err)
return false
}
ctx.Redirect(u, http.StatusFound)
return true
}

func DownloadArtifactV4Fallback(ctx *context.Base, art *actions_model.ActionArtifact) error {
func DownloadArtifactV4ReadStorage(ctx *context.Base, art *actions_model.ActionArtifact) error {
f, err := storage.ActionsArtifacts.Open(art.StoragePath)
if err != nil {
return err
}
defer f.Close()
http.ServeContent(ctx.Resp, ctx.Req, art.ArtifactName+".zip", art.CreatedUnix.AsLocalTime(), f)

contentType := art.ContentEncodingOrType
contentLength := int64(-1) // TODO: do we know the content length (by artifact)?
httplib.ServeContentByReader(ctx.Req, ctx.Resp, contentLength, f, httplib.ServeHeaderOptions{
Filename: path.Base(art.ArtifactPath),
ContentType: contentType,
ContentDisposition: httplib.ContentDispositionInline,
})
return nil
}

func DownloadArtifactV4(ctx *context.Base, art *actions_model.ActionArtifact) error {
ok, err := DownloadArtifactV4ServeDirectOnly(ctx, art)
if ok || err != nil {
return err
if DownloadArtifactV4ServeDirect(ctx, art) {
return nil
}
return DownloadArtifactV4Fallback(ctx, art)
return DownloadArtifactV4ReadStorage(ctx, art)
}
65 changes: 65 additions & 0 deletions modules/httplib/content_disposition.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package httplib

import (
"mime"
"strings"

"code.gitea.io/gitea/modules/setting"
)

type ContentDispositionType string

const (
ContentDispositionInline ContentDispositionType = "inline"
ContentDispositionAttachment ContentDispositionType = "attachment"
)

func needsEncodingRune(b rune) bool {
return (b < ' ' || b > '~') && b != '\t'
}

// getSafeName replaces all invalid chars in the filename field by underscore
func getSafeName(s string) (_ string, needsEncoding bool) {
var out strings.Builder
for _, b := range s {
if needsEncodingRune(b) {
needsEncoding = true
out.WriteRune('_')
} else {
out.WriteRune(b)
}
}
return out.String(), needsEncoding
}

func EncodeContentDispositionAttachment(filename string) string {
return encodeContentDisposition(ContentDispositionAttachment, filename)
}

func EncodeContentDispositionInline(filename string) string {
return encodeContentDisposition(ContentDispositionInline, filename)
}

// encodeContentDisposition encodes a correct Content-Disposition Header
func encodeContentDisposition(t ContentDispositionType, filename string) string {
safeFilename, needsEncoding := getSafeName(filename)
result := mime.FormatMediaType(string(t), map[string]string{"filename": safeFilename})
// No need for the utf8 encoding
if !needsEncoding {
return result
}
utf8Result := mime.FormatMediaType(string(t), map[string]string{"filename": filename})

// The mime package might have unexpected results in other go versions
// Make tests instance fail, otherwise use the default behavior of the go mime package
if !strings.HasPrefix(result, string(t)+"; filename=") || !strings.HasPrefix(utf8Result, string(t)+"; filename*=") {
setting.PanicInDevOrTesting("Unexpected mime package result %s", result)
return utf8Result
}

encodedFileName := strings.TrimPrefix(utf8Result, string(t))
return result + encodedFileName
}
64 changes: 64 additions & 0 deletions modules/httplib/content_disposition_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package httplib

import (
"mime"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestContentDisposition(t *testing.T) {
type testEntry struct {
disposition ContentDispositionType
filename string
header string
}
table := []testEntry{
{disposition: ContentDispositionInline, filename: "test.txt", header: "inline; filename=test.txt"},
{disposition: ContentDispositionInline, filename: "test❌.txt", header: "inline; filename=test_.txt; filename*=utf-8''test%E2%9D%8C.txt"},
{disposition: ContentDispositionInline, filename: "test ❌.txt", header: "inline; filename=\"test _.txt\"; filename*=utf-8''test%20%E2%9D%8C.txt"},
{disposition: ContentDispositionInline, filename: "\"test.txt", header: "inline; filename=\"\\\"test.txt\""},
{disposition: ContentDispositionInline, filename: "hello\tworld.txt", header: "inline; filename=\"hello\tworld.txt\""},
{disposition: ContentDispositionAttachment, filename: "hello\tworld.txt", header: "attachment; filename=\"hello\tworld.txt\""},
{disposition: ContentDispositionAttachment, filename: "hello\nworld.txt", header: "attachment; filename=hello_world.txt; filename*=utf-8''hello%0Aworld.txt"},
{disposition: ContentDispositionAttachment, filename: "hello\rworld.txt", header: "attachment; filename=hello_world.txt; filename*=utf-8''hello%0Dworld.txt"},
}

// Check the needsEncodingRune replacer ranges except tab that is checked above
// Any change in behavior should fail here
for c := ' '; !needsEncodingRune(c); c++ {
var header string
switch {
case strings.ContainsAny(string(c), ` (),/:;<=>?@[]`):
header = "inline; filename=\"hello" + string(c) + "world.txt\""
case strings.ContainsAny(string(c), `"\`):
// This document advises against for backslash in quoted form:
// https://datatracker.ietf.org/doc/html/rfc6266#appendix-D
// However the mime package is not generating the filename* in this scenario
header = "inline; filename=\"hello\\" + string(c) + "world.txt\""
default:
header = "inline; filename=hello" + string(c) + "world.txt"
}
table = append(table, testEntry{
disposition: ContentDispositionInline,
filename: "hello" + string(c) + "world.txt",
header: header,
})
}

for _, entry := range table {
t.Run(string(entry.disposition)+"_"+entry.filename, func(t *testing.T) {
encoded := encodeContentDisposition(entry.disposition, entry.filename)
assert.Equal(t, entry.header, encoded)
disposition, params, err := mime.ParseMediaType(encoded)
require.NoError(t, err)
assert.Equal(t, string(entry.disposition), disposition)
assert.Equal(t, entry.filename, params["filename"])
})
}
}
Loading
Loading