feat(actions): Add artifact preview in Actions run view (#36754)

Closes https://github.com/go-gitea/gitea/issues/33579.

Adds browser previews for Actions artifacts. Selecting an artifact opens
its file browser; selecting a file renders it in the same tab. The ZIP
download remains available separately.

Previews require sign-in and read access to the run. Text, image and PDF
files are supported; rendered HTML and JavaScript run in a sandboxed
frame and are labeled as automatically generated. The frame loads files
from a signed link that expires after an hour, because its requests
carry no session cookie. `[actions] ARTIFACT_PREVIEW_MAX_SIZE` limits
total previewable artifact size (`0` disables previews; `-1` removes the
limit); individual files also follow `[ui] MAX_DISPLAY_FILE_SIZE`.

<img width="1803" height="913" alt="image"
src="https://github.com/user-attachments/assets/a38fd704-2244-44fa-9181-c695ecbe0276"
/>

Docs: https://gitea.com/gitea/docs/pulls/533

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
This commit is contained in:
bircni
2026-09-26 09:42:36 +02:00
committed by GitHub
parent 857d3d3df3
commit 5a56e118e4
22 changed files with 971 additions and 68 deletions
+71 -2
View File
@@ -5,8 +5,10 @@ package devtest
import (
"fmt"
"maps"
mathRand "math/rand/v2"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
@@ -23,6 +25,49 @@ import (
"gitea.dev/services/context"
)
const (
mockActionsArtifactNameB = "artifact-b"
mockActionsArtifactNameHTMLReport = "artifact-html-report"
mockActionsArtifactNameReallyLong = "artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong"
)
var mockActionsArtifactFiles = map[string]map[string]string{
mockActionsArtifactNameB: {"report.txt": "artifact-b report"},
mockActionsArtifactNameHTMLReport: {
"report/index.html": `<html><head><link rel="stylesheet" href="style.css"></head><body>
<a href="./style.css">link to style.css</a><br>
<p>This line is red, next line is from JS:</p>
<script>document.write('window origin: ' + window.origin)</script>
</body></html>`,
"report/style.css": "body {padding: 10px;} p {color: red;}",
"demo.svg": `<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" x="10" y="10" rx="20" ry="20" fill="blue" />
</svg>`,
"demo.pdf": `%PDF-1.0
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/MediaBox[0 0 3 3]>>endobj
xref
0 4
0000000000 65535 f
0000000009 00000 n
0000000052 00000 n
0000000101 00000 n
trailer<</Size 4/Root 1 0 R>>
startxref
149
%EOF`,
},
mockActionsArtifactNameReallyLong: {
"index.html": "<html><body>mock preview</body></html>",
"logs/output.txt": "mock logs",
},
}
func mockArtifactPreviewLink(artifactName string) string {
return setting.AppSubURL + "/devtest/repo-action-view/artifacts/" + url.PathEscape(artifactName)
}
type generateMockStepsLogOptions struct {
mockCountFirst int
mockCountGeneral int
@@ -222,10 +267,11 @@ func MockActionsRunsJobs(ctx *context.Context) {
ExpiresUnix: alignTime(time.Now().Add(-24*time.Hour).Unix(), 3600),
})
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
Name: "artifact-b",
Name: mockActionsArtifactNameB,
Size: 1024 * 1024,
Status: "completed",
ExpiresUnix: alignTime(time.Now().Add(24*time.Hour).Unix(), 3600),
PreviewLink: mockArtifactPreviewLink(mockActionsArtifactNameB) + "/preview",
})
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
Name: "artifact-very-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong",
@@ -234,10 +280,18 @@ func MockActionsRunsJobs(ctx *context.Context) {
ExpiresUnix: alignTime(time.Now().Add(-24*time.Hour).Unix(), 3600),
})
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
Name: "artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong",
Name: mockActionsArtifactNameHTMLReport,
Size: 256 * 1024,
Status: "completed",
ExpiresUnix: alignTime(time.Now().Add(24*time.Hour).Unix(), 3600),
PreviewLink: mockArtifactPreviewLink(mockActionsArtifactNameHTMLReport) + "/preview",
})
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
Name: mockActionsArtifactNameReallyLong,
Size: 1024 * 1024,
Status: "completed",
ExpiresUnix: 0,
PreviewLink: mockArtifactPreviewLink(mockActionsArtifactNameReallyLong) + "/preview",
})
jobLink := func(jobID int64) string {
@@ -576,3 +630,18 @@ func fillViewRunResponseCurrentJob(ctx *context.Context, resp *actions.ViewRespo
time.Sleep(time.Duration(100) * time.Millisecond) // actually, frontend reload every 1 second, any smaller delay is fine
}
}
func MockActionsArtifactPreview(ctx *context.Context) {
artifactName := ctx.PathParam("artifact_name")
files := mockActionsArtifactFiles[artifactName]
runURL := setting.AppSubURL + "/devtest/repo-action-view/runs/10"
data := &actions.ArtifactPreviewTemplateData{RunURL: runURL, RunIndex: 10, ArtifactName: artifactName, DownloadURL: runURL + "/artifacts/" + url.PathEscape(artifactName)}
link := mockArtifactPreviewLink(artifactName)
actions.RenderArtifactPreview(ctx, data, slices.Sorted(maps.Keys(files)), ctx.PathParam("*"), link+"/preview/", link+"/raw/")
}
func MockActionsArtifactPreviewRaw(ctx *context.Context) {
filePath := ctx.PathParam("*")
content := mockActionsArtifactFiles[ctx.PathParam("artifact_name")][filePath]
actions.ServeArtifactPreviewContent(ctx.Base, filePath, strings.NewReader(content), int64(len(content)))
}