feat(api): add REST API endpoints for Actions artifacts (#12140)

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. All work and communication must conform to Forgejo's [AI Agreement](https://codeberg.org/forgejo/governance/src/branch/main/AIAgreement.md). There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests for Go changes

- I added test coverage for Go changes...
  - [ ] in their respective `*_test.go` for unit tests.
  - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I ran...
  - [x] `make pr-go` before pushing

### Tests for JavaScript changes

(not applicable — Go-only change)

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [ ] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

## Summary

Add public REST API endpoints under `/api/v1/` for listing, inspecting, downloading, and deleting Actions artifacts. Previously, artifacts could only be accessed through the web UI or the internal runner API.

### New endpoints

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/repos/{owner}/{repo}/actions/artifacts` | List all artifacts for a repository |
| `GET` | `/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts` | List artifacts for a workflow run |
| `GET` | `/repos/{owner}/{repo}/actions/artifacts/{artifact_id}` | Get artifact metadata |
| `GET` | `/repos/{owner}/{repo}/actions/artifacts/{artifact_id}/zip` | Download artifact as zip |
| `DELETE` | `/repos/{owner}/{repo}/actions/artifacts/{artifact_id}` | Delete an artifact |

- List endpoints support `page`, `limit`, and `name` query parameters
- Both v1-v3 (multi-file, zip on-the-fly) and v4 (single zip) artifact backends are supported
- Expired artifacts are listed with `expired: true` but cannot be downloaded
- Delete requires write permission; all other endpoints require read permission

Co-authored-by: Mathieu Fenniak <mathieu@fenniak.net>
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12140
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
Co-authored-by: ShellWen <me@shellwen.com>
Co-committed-by: ShellWen <me@shellwen.com>
This commit is contained in:
ShellWen 2026-04-20 05:10:54 +02:00 committed by Mathieu Fenniak
commit a85c527709
10 changed files with 1210 additions and 63 deletions

View file

@ -9,6 +9,7 @@ package actions
import (
"context"
"errors"
"fmt"
"time"
"forgejo.org/models/db"
@ -88,6 +89,13 @@ func CreateArtifact(ctx context.Context, t *ActionTask, artifactName, artifactPa
return artifact, nil
}
// IsV4 reports whether the artifact was uploaded via the v4 backend.
// The v4 backend stores the whole artifact as a single zip file;
// v1-v3 stores each file as a separate row.
func (a *ActionArtifact) IsV4() bool {
return a.ArtifactName+".zip" == a.ArtifactPath && a.ContentEncoding == "application/zip"
}
func getArtifactByNameAndPath(ctx context.Context, runID int64, name, fpath string) (*ActionArtifact, error) {
var art ActionArtifact
has, err := db.GetEngine(ctx).Where("run_id = ? AND artifact_name = ? AND artifact_path = ?", runID, name, fpath).Get(&art)
@ -150,11 +158,32 @@ type ActionArtifactMeta struct {
Status ArtifactStatus
}
// AggregatedArtifact is the aggregated view of a logical artifact
// (one or more rows sharing the same run_id + artifact_name), used by the
// public API to represent a single artifact to clients.
type AggregatedArtifact struct {
ID int64 `xorm:"id"`
RunID int64 `xorm:"run_id"`
RepoID int64 `xorm:"-"`
ArtifactName string `xorm:"artifact_name"`
FileSize int64 `xorm:"file_size"`
Status ArtifactStatus `xorm:"status"`
CreatedUnix timeutil.TimeStamp `xorm:"created_unix"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated_unix"`
ExpiredUnix timeutil.TimeStamp `xorm:"expired_unix"`
}
// APIDownloadURL returns the download URL for this artifact under the given
// repository API URL prefix (e.g. "https://host/api/v1/repos/owner/name").
func (a *AggregatedArtifact) APIDownloadURL(repoAPIURL string) string {
return fmt.Sprintf("%s/actions/artifacts/%d/zip", repoAPIURL, a.ID)
}
// ListUploadedArtifactsMeta returns all uploaded artifacts meta of a run
func ListUploadedArtifactsMeta(ctx context.Context, runID int64) ([]*ActionArtifactMeta, error) {
arts := make([]*ActionArtifactMeta, 0, 10)
return arts, db.GetEngine(ctx).Table("action_artifact").
Where("run_id=? AND (status=? OR status=?)", runID, ArtifactStatusUploadConfirmed, ArtifactStatusExpired).
Where(builder.Eq{"run_id": runID}.And(builder.In("status", ArtifactStatusUploadConfirmed, ArtifactStatusExpired))).
GroupBy("artifact_name").
Select("artifact_name, sum(file_size) as file_size, max(status) as status").
Find(&arts)
@ -192,3 +221,85 @@ func SetArtifactDeleted(ctx context.Context, artifactID int64) error {
_, err := db.GetEngine(ctx).ID(artifactID).Cols("status").Update(&ActionArtifact{Status: int64(ArtifactStatusDeleted)})
return err
}
// aggregatedArtifactConds returns the common WHERE clause used by aggregated
// artifact queries: restrict to visible statuses and apply the caller's filters.
// The Status field on opts is ignored — visibility is fixed to UploadConfirmed/Expired.
func aggregatedArtifactConds(opts FindArtifactsOptions) builder.Cond {
opts.Status = 0
return opts.ToConds().And(builder.In("status", ArtifactStatusUploadConfirmed, ArtifactStatusExpired))
}
const aggregatedArtifactSelect = "min(id) as id, run_id, artifact_name, sum(file_size) as file_size, max(status) as status, min(created_unix) as created_unix, max(updated_unix) as updated_unix, max(expired_unix) as expired_unix"
// ListAggregatedArtifacts returns paginated aggregated artifacts.
// Each result represents one logical artifact: a (run_id, artifact_name) group,
// with ID = MIN(id), FileSize = SUM(file_size), Status = MAX(status), and
// timestamps aggregated accordingly. Status filter in opts is ignored; results
// are always restricted to UploadConfirmed and Expired statuses.
func ListAggregatedArtifacts(ctx context.Context, opts FindArtifactsOptions) ([]*AggregatedArtifact, int64, error) {
cond := aggregatedArtifactConds(opts)
var countKeys []struct {
ID int64 `xorm:"id"`
}
if err := db.GetEngine(ctx).Table("action_artifact").
Where(cond).
GroupBy("run_id, artifact_name").
Select("min(id) as id").
Find(&countKeys); err != nil {
return nil, 0, err
}
total := int64(len(countKeys))
sess := db.GetEngine(ctx).Table("action_artifact").
Where(cond).
GroupBy("run_id, artifact_name").
Select(aggregatedArtifactSelect).
OrderBy("id DESC")
capacity := 10
if opts.PageSize > 0 {
sess = sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize)
capacity = opts.PageSize
}
arts := make([]*AggregatedArtifact, 0, capacity)
return arts, total, sess.Find(&arts)
}
// GetAggregatedArtifactByID returns the aggregated artifact by its canonical ID
// (MIN(id) of the group), scoped to the given repository. Returns util.ErrNotExist
// when the ID does not exist, is not canonical for its group, or does not belong to repoID.
// The repoID scoping is performed in the query so callers don't need a follow-up check.
func GetAggregatedArtifactByID(ctx context.Context, repoID, artifactID int64) (*AggregatedArtifact, error) {
var art ActionArtifact
has, err := db.GetEngine(ctx).Where(builder.Eq{"id": artifactID, "repo_id": repoID}).Get(&art)
if err != nil {
return nil, err
}
if !has {
return nil, util.ErrNotExist
}
cond := aggregatedArtifactConds(FindArtifactsOptions{
RunID: art.RunID,
ArtifactName: art.ArtifactName,
})
meta := new(AggregatedArtifact)
has, err = db.GetEngine(ctx).Table("action_artifact").
Where(cond).
GroupBy("run_id, artifact_name").
Select(aggregatedArtifactSelect).
Get(meta)
if err != nil {
return nil, err
}
if !has || meta.ID != artifactID {
return nil, util.ErrNotExist
}
meta.RepoID = art.RepoID
return meta, nil
}

View file

@ -88,3 +88,26 @@ type ListActionRunResponse struct {
Entries []*ActionRun `json:"workflow_runs"`
TotalCount int64 `json:"total_count"`
}
// ActionArtifact represents an artifact of a workflow run
// swagger:model
type ActionArtifact struct {
// the artifact's ID
ID int64 `json:"id"`
// the artifact's name
Name string `json:"name"`
// the total size of the artifact in bytes
SizeInBytes int64 `json:"size_in_bytes"`
// the URL to download the artifact zip archive
ArchiveDownloadURL string `json:"archive_download_url"`
// whether the artifact has expired
Expired bool `json:"expired"`
// the ID of the workflow run that produced this artifact
RunID int64 `json:"run_id"`
// swagger:strfmt date-time
CreatedAt time.Time `json:"created_at"`
// swagger:strfmt date-time
UpdatedAt time.Time `json:"updated_at"`
// swagger:strfmt date-time
ExpiresAt time.Time `json:"expires_at"`
}

View file

@ -1240,10 +1240,17 @@ func Routes() *web.Route {
}, reqToken(), reqAdmin())
m.Group("/actions", func() {
m.Get("/tasks", repo.ListActionTasks)
m.Group("/artifacts", func() {
m.Get("", repo.ListActionArtifacts)
m.Get("/{artifact_id}", repo.GetActionArtifact)
m.Delete("/{artifact_id}", reqToken(), reqRepoWriter(unit.TypeActions), repo.DeleteActionArtifact)
m.Get("/{artifact_id}/zip", repo.DownloadActionArtifact)
})
m.Group("/runs", func() {
m.Get("", repo.ListActionRuns)
m.Get("/{run_id}", repo.GetActionRun)
m.Get("/{run_id}/jobs", repo.ListActionRunJobs)
m.Get("/{run_id}/artifacts", repo.ListActionRunArtifacts)
})
m.Group("/workflows", func() {

View file

@ -1096,3 +1096,317 @@ func ListActionRunJobs(ctx *context.APIContext) {
ctx.JSON(http.StatusOK, response)
}
// ListActionArtifacts list artifacts for a repository
func ListActionArtifacts(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/actions/artifacts repository ListActionArtifacts
// ---
// summary: List a repository's artifacts
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: name
// in: query
// description: filter by artifact name
// type: string
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results, default maximum page size is 50
// type: integer
// responses:
// "200":
// "$ref": "#/responses/ActionArtifactList"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
opts := actions_model.FindArtifactsOptions{
ListOptions: utils.GetListOptions(ctx),
RepoID: ctx.Repo.Repository.ID,
ArtifactName: ctx.FormString("name"),
}
arts, total, err := actions_model.ListAggregatedArtifacts(ctx, opts)
if err != nil {
ctx.Error(http.StatusInternalServerError, "ListAggregatedArtifacts", err)
return
}
repoAPIURL := ctx.Repo.Repository.APIURL()
entries := make([]*api.ActionArtifact, len(arts))
for i, art := range arts {
entries[i] = convert.ToActionArtifact(repoAPIURL, art)
}
ctx.SetLinkHeader(int(total), opts.PageSize)
ctx.SetTotalCountHeader(total)
ctx.JSON(http.StatusOK, entries)
}
// ListActionRunArtifacts list artifacts for a workflow run
func ListActionRunArtifacts(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts repository ListActionRunArtifacts
// ---
// summary: List artifacts of a workflow run
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: run_id
// in: path
// description: ID of the workflow run
// type: integer
// format: int64
// required: true
// - name: name
// in: query
// description: filter by artifact name
// type: string
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results, default maximum page size is 50
// type: integer
// responses:
// "200":
// "$ref": "#/responses/ActionArtifactList"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
runID := ctx.ParamsInt64(":run_id")
run, err := actions_model.GetRunByID(ctx, runID)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.Error(http.StatusNotFound, "GetRunByID", err)
} else {
ctx.Error(http.StatusInternalServerError, "GetRunByID", err)
}
return
}
if ctx.Repo.Repository.ID != run.RepoID {
ctx.Error(http.StatusNotFound, "GetRunByID", util.ErrNotExist)
return
}
opts := actions_model.FindArtifactsOptions{
ListOptions: utils.GetListOptions(ctx),
RunID: runID,
ArtifactName: ctx.FormString("name"),
}
arts, total, err := actions_model.ListAggregatedArtifacts(ctx, opts)
if err != nil {
ctx.Error(http.StatusInternalServerError, "ListAggregatedArtifacts", err)
return
}
repoAPIURL := ctx.Repo.Repository.APIURL()
entries := make([]*api.ActionArtifact, len(arts))
for i, art := range arts {
entries[i] = convert.ToActionArtifact(repoAPIURL, art)
}
ctx.SetLinkHeader(int(total), opts.PageSize)
ctx.SetTotalCountHeader(total)
ctx.JSON(http.StatusOK, entries)
}
// GetActionArtifact get an artifact by its ID
func GetActionArtifact(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id} repository GetActionArtifact
// ---
// summary: Get an artifact by ID
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: artifact_id
// in: path
// description: ID of the artifact
// type: integer
// format: int64
// required: true
// responses:
// "200":
// "$ref": "#/responses/ActionArtifact"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
meta, err := actions_model.GetAggregatedArtifactByID(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":artifact_id"))
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.Error(http.StatusNotFound, "GetAggregatedArtifactByID", err)
} else {
ctx.Error(http.StatusInternalServerError, "GetAggregatedArtifactByID", err)
}
return
}
ctx.JSON(http.StatusOK, convert.ToActionArtifact(ctx.Repo.Repository.APIURL(), meta))
}
// DownloadActionArtifact download an artifact by its ID
func DownloadActionArtifact(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/zip repository DownloadActionArtifact
// ---
// summary: Download an artifact
// produces:
// - application/zip
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: artifact_id
// in: path
// description: ID of the artifact
// type: integer
// format: int64
// required: true
// responses:
// "200":
// description: the artifact archive
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
meta, err := actions_model.GetAggregatedArtifactByID(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":artifact_id"))
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.Error(http.StatusNotFound, "GetAggregatedArtifactByID", err)
} else {
ctx.Error(http.StatusInternalServerError, "GetAggregatedArtifactByID", err)
}
return
}
// Load all artifact rows for this logical artifact
artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{
RunID: meta.RunID,
ArtifactName: meta.ArtifactName,
})
if err != nil {
ctx.Error(http.StatusInternalServerError, "FindArtifacts", err)
return
}
for _, art := range artifacts {
if art.Status != int64(actions_model.ArtifactStatusUploadConfirmed) {
ctx.Error(http.StatusNotFound, "DownloadActionArtifact", errors.New("artifact not confirmed"))
return
}
}
if err := actions_service.ServeArtifact(ctx.Base, artifacts); err != nil {
ctx.Error(http.StatusInternalServerError, "ServeArtifact", err)
return
}
}
// DeleteActionArtifact marks an artifact for deletion
func DeleteActionArtifact(ctx *context.APIContext) {
// swagger:operation DELETE /repos/{owner}/{repo}/actions/artifacts/{artifact_id} repository DeleteActionArtifact
// ---
// summary: Mark an artifact for deletion
// description: |
// Marks the artifact for deletion. Storage space will be reclaimed
// asynchronously by a background job.
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: artifact_id
// in: path
// description: ID of the artifact
// type: integer
// format: int64
// required: true
// responses:
// "204":
// description: artifact marked for deletion
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
meta, err := actions_model.GetAggregatedArtifactByID(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":artifact_id"))
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.Error(http.StatusNotFound, "GetAggregatedArtifactByID", err)
} else {
ctx.Error(http.StatusInternalServerError, "GetAggregatedArtifactByID", err)
}
return
}
if err := actions_model.SetArtifactNeedDelete(ctx, meta.RunID, meta.ArtifactName); err != nil {
ctx.Error(http.StatusInternalServerError, "SetArtifactNeedDelete", err)
return
}
ctx.Status(http.StatusNoContent)
}

View file

@ -533,3 +533,17 @@ type swaggerActionRunJobList struct {
// in:body
Body []api.ActionRunJob `json:"body"`
}
// ActionArtifactList
// swagger:response ActionArtifactList
type swaggerActionArtifactList struct {
// in:body
Body []api.ActionArtifact `json:"body"`
}
// ActionArtifact
// swagger:response ActionArtifact
type swaggerActionArtifact struct {
// in:body
Body api.ActionArtifact `json:"body"`
}

View file

@ -5,13 +5,10 @@
package actions
import (
"archive/zip"
"compress/gzip"
"context"
"errors"
"fmt"
"html/template"
"io"
"net/http"
"net/url"
"strconv"
@ -28,14 +25,11 @@ import (
"forgejo.org/modules/git"
"forgejo.org/modules/json"
"forgejo.org/modules/log"
"forgejo.org/modules/setting"
"forgejo.org/modules/storage"
"forgejo.org/modules/templates"
"forgejo.org/modules/timeutil"
"forgejo.org/modules/translation"
"forgejo.org/modules/util"
"forgejo.org/modules/web"
"forgejo.org/routers/common"
actions_service "forgejo.org/services/actions"
app_context "forgejo.org/services/context"
@ -818,7 +812,6 @@ func ArtifactsDownloadView(ctx *app_context.Context) {
return
}
// if artifacts status is not uploaded-confirmed, treat it as not found
for _, art := range artifacts {
if art.Status != int64(actions_model.ArtifactStatusUploadConfirmed) {
ctx.Error(http.StatusNotFound, "artifact not found")
@ -826,63 +819,10 @@ func ArtifactsDownloadView(ctx *app_context.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
if len(artifacts) == 1 && artifacts[0].ArtifactName+".zip" == artifacts[0].ArtifactPath && artifacts[0].ContentEncoding == "application/zip" {
art := artifacts[0]
if setting.Actions.ArtifactStorage.MinioConfig.ServeDirect {
u, err := storage.ActionsArtifacts.URL(art.StoragePath, art.ArtifactPath, nil)
if u != nil && err == nil {
ctx.Redirect(u.String())
return
}
}
f, err := storage.ActionsArtifacts.Open(art.StoragePath)
if err != nil {
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
common.ServeContentByReadSeeker(ctx.Base, artifacts[0].ArtifactName+".zip", util.ToPointer(art.UpdatedUnix.AsTime()), f)
if err := actions_service.ServeArtifact(ctx.Base, artifacts); err != nil {
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
// Artifacts using the v1-v3 backend are stored as multiple individual files per artifact on the backend
// Those need to be zipped for download
artifactName := artifacts[0].ArtifactName
ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(artifactName), artifactName))
writer := zip.NewWriter(ctx.Resp)
defer writer.Close()
for _, art := range artifacts {
f, err := storage.ActionsArtifacts.Open(art.StoragePath)
if err != nil {
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
var r io.ReadCloser
if art.ContentEncoding == "gzip" {
r, err = gzip.NewReader(f)
if err != nil {
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
} else {
r = f
}
defer r.Close()
w, err := writer.Create(art.ArtifactPath)
if err != nil {
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
if _, err := io.Copy(w, r); err != nil {
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
}
}
func DisableWorkflowFile(ctx *app_context.Context) {

View file

@ -0,0 +1,96 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package actions
import (
"archive/zip"
"compress/gzip"
"errors"
"fmt"
"io"
"net/url"
actions_model "forgejo.org/models/actions"
"forgejo.org/modules/httplib"
"forgejo.org/modules/setting"
"forgejo.org/modules/storage"
"forgejo.org/modules/util"
"forgejo.org/services/context"
)
// ServeArtifact writes an artifact archive to the response. The given rows
// must all belong to the same logical artifact (same run_id + artifact_name)
// and be in ArtifactStatusUploadConfirmed; callers are responsible for
// validating status and repository ownership beforehand.
//
// When the artifact was produced by the v4 backend (a single zip already
// sitting in storage), it is streamed or redirected to directly. Otherwise
// (v1-v3 backend) the archive is assembled on the fly from the individual
// file rows, applying gzip decompression where needed.
func ServeArtifact(base *context.Base, artifacts []*actions_model.ActionArtifact) error {
if len(artifacts) == 0 {
return errors.New("no artifacts to serve")
}
if len(artifacts) == 1 && artifacts[0].IsV4() {
return serveV4Artifact(base, artifacts[0])
}
return serveLegacyArtifact(base, artifacts)
}
func serveV4Artifact(base *context.Base, art *actions_model.ActionArtifact) error {
if setting.Actions.ArtifactStorage.MinioConfig.ServeDirect {
u, err := storage.ActionsArtifacts.URL(art.StoragePath, art.ArtifactPath, nil)
if u != nil && err == nil {
base.Redirect(u.String())
return nil
}
}
f, err := storage.ActionsArtifacts.Open(art.StoragePath)
if err != nil {
return err
}
httplib.ServeContentByReadSeeker(base.Req, base.Resp, art.ArtifactName+".zip", util.ToPointer(art.UpdatedUnix.AsTime()), f)
return nil
}
func serveLegacyArtifact(base *context.Base, artifacts []*actions_model.ActionArtifact) error {
name := artifacts[0].ArtifactName
base.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(name), name))
writer := zip.NewWriter(base.Resp)
defer writer.Close()
for _, art := range artifacts {
if err := writeArtifactFile(writer, art); err != nil {
return err
}
}
return nil
}
func writeArtifactFile(writer *zip.Writer, art *actions_model.ActionArtifact) error {
f, err := storage.ActionsArtifacts.Open(art.StoragePath)
if err != nil {
return err
}
defer f.Close()
var r io.Reader = f
if art.ContentEncoding == "gzip" {
gz, err := gzip.NewReader(f)
if err != nil {
return err
}
defer gz.Close()
r = gz
}
w, err := writer.Create(art.ArtifactPath)
if err != nil {
return err
}
_, err = io.Copy(w, r)
return err
}

View file

@ -48,6 +48,22 @@ func ToActionRun(ctx context.Context, run *actions_model.ActionRun, doer *user_m
}
}
// ToActionArtifact converts an AggregatedArtifact to an API ActionArtifact.
// repoAPIURL is the API URL prefix for the repository (e.g. from Repository.APIURL()).
func ToActionArtifact(repoAPIURL string, art *actions_model.AggregatedArtifact) *api.ActionArtifact {
return &api.ActionArtifact{
ID: art.ID,
Name: art.ArtifactName,
SizeInBytes: art.FileSize,
ArchiveDownloadURL: art.APIDownloadURL(repoAPIURL),
Expired: art.Status == actions_model.ArtifactStatusExpired,
RunID: art.RunID,
CreatedAt: art.CreatedUnix.AsTime(),
UpdatedAt: art.UpdatedUnix.AsTime(),
ExpiresAt: art.ExpiredUnix.AsTime(),
}
}
func ToActionRunJob(job *actions_model.ActionRunJob) *api.ActionRunJob {
if job == nil {
return nil

View file

@ -5460,6 +5460,209 @@
}
}
},
"/repos/{owner}/{repo}/actions/artifacts": {
"get": {
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "List a repository's artifacts",
"operationId": "ListActionArtifacts",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "string",
"description": "filter by artifact name",
"name": "name",
"in": "query"
},
{
"type": "integer",
"description": "page number of results to return (1-based)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "page size of results, default maximum page size is 50",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"$ref": "#/responses/ActionArtifactList"
},
"400": {
"$ref": "#/responses/error"
},
"403": {
"$ref": "#/responses/forbidden"
}
}
}
},
"/repos/{owner}/{repo}/actions/artifacts/{artifact_id}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Get an artifact by ID",
"operationId": "GetActionArtifact",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "integer",
"format": "int64",
"description": "ID of the artifact",
"name": "artifact_id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/ActionArtifact"
},
"400": {
"$ref": "#/responses/error"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
}
}
},
"delete": {
"description": "Marks the artifact for deletion. Storage space will be reclaimed\nasynchronously by a background job.\n",
"tags": [
"repository"
],
"summary": "Mark an artifact for deletion",
"operationId": "DeleteActionArtifact",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "integer",
"format": "int64",
"description": "ID of the artifact",
"name": "artifact_id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "artifact marked for deletion"
},
"400": {
"$ref": "#/responses/error"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/actions/artifacts/{artifact_id}/zip": {
"get": {
"produces": [
"application/zip"
],
"tags": [
"repository"
],
"summary": "Download an artifact",
"operationId": "DownloadActionArtifact",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "integer",
"format": "int64",
"description": "ID of the artifact",
"name": "artifact_id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "the artifact archive"
},
"400": {
"$ref": "#/responses/error"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/actions/runners": {
"get": {
"produces": [
@ -5888,6 +6091,74 @@
}
}
},
"/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts": {
"get": {
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "List artifacts of a workflow run",
"operationId": "ListActionRunArtifacts",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "integer",
"format": "int64",
"description": "ID of the workflow run",
"name": "run_id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "filter by artifact name",
"name": "name",
"in": "query"
},
{
"type": "integer",
"description": "page number of results to return (1-based)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "page size of results, default maximum page size is 50",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"$ref": "#/responses/ActionArtifactList"
},
"400": {
"$ref": "#/responses/error"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/actions/runs/{run_id}/jobs": {
"get": {
"produces": [
@ -22378,6 +22649,61 @@
},
"x-go-package": "forgejo.org/modules/structs"
},
"ActionArtifact": {
"description": "ActionArtifact represents an artifact of a workflow run",
"type": "object",
"properties": {
"archive_download_url": {
"description": "the URL to download the artifact zip archive",
"type": "string",
"x-go-name": "ArchiveDownloadURL"
},
"created_at": {
"type": "string",
"format": "date-time",
"x-go-name": "CreatedAt"
},
"expired": {
"description": "whether the artifact has expired",
"type": "boolean",
"x-go-name": "Expired"
},
"expires_at": {
"type": "string",
"format": "date-time",
"x-go-name": "ExpiresAt"
},
"id": {
"description": "the artifact's ID",
"type": "integer",
"format": "int64",
"x-go-name": "ID"
},
"name": {
"description": "the artifact's name",
"type": "string",
"x-go-name": "Name"
},
"run_id": {
"description": "the ID of the workflow run that produced this artifact",
"type": "integer",
"format": "int64",
"x-go-name": "RunID"
},
"size_in_bytes": {
"description": "the total size of the artifact in bytes",
"type": "integer",
"format": "int64",
"x-go-name": "SizeInBytes"
},
"updated_at": {
"type": "string",
"format": "date-time",
"x-go-name": "UpdatedAt"
}
},
"x-go-package": "forgejo.org/modules/structs"
},
"ActionRun": {
"description": "ActionRun represents an action run",
"type": "object",
@ -30431,6 +30757,21 @@
}
}
},
"ActionArtifact": {
"description": "ActionArtifact",
"schema": {
"$ref": "#/definitions/ActionArtifact"
}
},
"ActionArtifactList": {
"description": "ActionArtifactList",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/ActionArtifact"
}
}
},
"ActionRun": {
"description": "ActionRun",
"schema": {

View file

@ -0,0 +1,285 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package integration
import (
"fmt"
"net/http"
"testing"
auth_model "forgejo.org/models/auth"
repo_model "forgejo.org/models/repo"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
api "forgejo.org/modules/structs"
"forgejo.org/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAPIListActionArtifacts(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
token := getUserToken(t, user.LowerName, auth_model.AccessTokenScopeReadRepository)
t.Run("ListRepoArtifacts", func(t *testing.T) {
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
res := MakeRequest(t, req, http.StatusOK)
var entries []*api.ActionArtifact
DecodeJSON(t, res, &entries)
// Fixture has 2 logical artifacts with confirmed status:
// "multi-file-download" (run 791, ids 19+20) and "artifact-v4-download" (run 792, id 22)
assert.Equal(t, "2", res.Header().Get("X-Total-Count"))
require.Len(t, entries, 2)
names := make([]string, len(entries))
for i, a := range entries {
names[i] = a.Name
assert.False(t, a.Expired)
assert.NotZero(t, a.SizeInBytes)
assert.Contains(t, a.ArchiveDownloadURL, "/actions/artifacts/")
assert.Contains(t, a.ArchiveDownloadURL, "/zip")
}
assert.ElementsMatch(t, []string{"multi-file-download", "artifact-v4-download"}, names)
})
t.Run("ListRepoArtifactsWithNameFilter", func(t *testing.T) {
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts?name=multi-file-download", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
res := MakeRequest(t, req, http.StatusOK)
var entries []*api.ActionArtifact
DecodeJSON(t, res, &entries)
assert.Equal(t, "1", res.Header().Get("X-Total-Count"))
require.Len(t, entries, 1)
assert.Equal(t, "multi-file-download", entries[0].Name)
// multi-file-download has 2 rows of 1024 bytes each
assert.Equal(t, int64(2048), entries[0].SizeInBytes)
})
t.Run("ListRunArtifacts", func(t *testing.T) {
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/791/artifacts", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
res := MakeRequest(t, req, http.StatusOK)
var entries []*api.ActionArtifact
DecodeJSON(t, res, &entries)
// run 791 has only "multi-file-download" (id=1 is pending, not listed)
assert.Equal(t, "1", res.Header().Get("X-Total-Count"))
require.Len(t, entries, 1)
assert.Equal(t, "multi-file-download", entries[0].Name)
})
t.Run("ListRunArtifactsNotFound", func(t *testing.T) {
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/99999/artifacts", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
})
}
func TestAPIGetActionArtifact(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
token := getUserToken(t, user.LowerName, auth_model.AccessTokenScopeReadRepository)
t.Run("GetV1V3Artifact", func(t *testing.T) {
// id=19 is the MIN(id) for "multi-file-download" group
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/19", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
res := MakeRequest(t, req, http.StatusOK)
var art api.ActionArtifact
DecodeJSON(t, res, &art)
assert.Equal(t, int64(19), art.ID)
assert.Equal(t, "multi-file-download", art.Name)
assert.Equal(t, int64(2048), art.SizeInBytes)
assert.Equal(t, int64(791), art.RunID)
assert.False(t, art.Expired)
})
t.Run("GetV4Artifact", func(t *testing.T) {
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/22", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
res := MakeRequest(t, req, http.StatusOK)
var art api.ActionArtifact
DecodeJSON(t, res, &art)
assert.Equal(t, int64(22), art.ID)
assert.Equal(t, "artifact-v4-download", art.Name)
assert.Equal(t, int64(1024), art.SizeInBytes)
assert.Equal(t, int64(792), art.RunID)
assert.False(t, art.Expired)
})
t.Run("GetNonCanonicalID", func(t *testing.T) {
// id=20 is part of "multi-file-download" but is NOT the MIN(id), so it should 404
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/20", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
})
t.Run("GetPendingArtifact", func(t *testing.T) {
// id=1 has status=1 (upload pending), should not be accessible
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/1", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
})
t.Run("GetNonExistent", func(t *testing.T) {
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/99999", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
})
t.Run("GetFromWrongRepository", func(t *testing.T) {
// artifact id=22 belongs to user5/repo4; requesting it through a repo
// the caller can access but that doesn't own the artifact must 404 —
// this is the load-bearing check that caller-side RepoID was replaced
// by a query-side constraint.
otherRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
otherUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: otherRepo.OwnerID})
otherToken := getUserToken(t, otherUser.LowerName, auth_model.AccessTokenScopeReadRepository)
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/22", otherRepo.OwnerName, otherRepo.Name),
)
req.AddTokenAuth(otherToken)
MakeRequest(t, req, http.StatusNotFound)
})
}
func TestAPIActionArtifactsRequireRepoScope(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
wrongScopeToken := getUserToken(t, user.LowerName, auth_model.AccessTokenScopeReadNotification)
endpoints := []struct {
name string
path string
}{
{"list repo", fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts", repo.OwnerName, repo.Name)},
{"list run", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/791/artifacts", repo.OwnerName, repo.Name)},
{"get", fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/19", repo.OwnerName, repo.Name)},
{"download", fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/19/zip", repo.OwnerName, repo.Name)},
}
for _, ep := range endpoints {
t.Run(ep.name, func(t *testing.T) {
req := NewRequest(t, http.MethodGet, ep.path)
req.AddTokenAuth(wrongScopeToken)
MakeRequest(t, req, http.StatusForbidden)
})
}
}
func TestAPIDownloadActionArtifact(t *testing.T) {
defer tests.PrepareTestEnv(t)()
tests.PrepareArtifactsStorage(t)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
token := getUserToken(t, user.LowerName, auth_model.AccessTokenScopeReadRepository)
t.Run("DownloadV1V3Artifact", func(t *testing.T) {
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/19/zip", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
res := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, res.Header().Get("Content-Disposition"), "multi-file-download.zip")
})
t.Run("DownloadV4Artifact", func(t *testing.T) {
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/22/zip", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
res := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "bytes", res.Header().Get("Accept-Ranges"))
})
t.Run("DownloadPendingArtifact", func(t *testing.T) {
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/1/zip", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
})
}
func TestAPIDeleteActionArtifact(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
t.Run("DeleteRequiresWritePermission", func(t *testing.T) {
readToken := getUserToken(t, user.LowerName, auth_model.AccessTokenScopeReadRepository)
req := NewRequest(t, http.MethodDelete,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/22", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(readToken)
MakeRequest(t, req, http.StatusForbidden)
})
t.Run("DeleteArtifact", func(t *testing.T) {
writeToken := getUserToken(t, user.LowerName, auth_model.AccessTokenScopeWriteRepository)
req := NewRequest(t, http.MethodDelete,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/22", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(writeToken)
MakeRequest(t, req, http.StatusNoContent)
// Verify the artifact is no longer accessible
readToken := getUserToken(t, user.LowerName, auth_model.AccessTokenScopeReadRepository)
req = NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/22", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(readToken)
MakeRequest(t, req, http.StatusNotFound)
})
t.Run("DeleteNonExistent", func(t *testing.T) {
writeToken := getUserToken(t, user.LowerName, auth_model.AccessTokenScopeWriteRepository)
req := NewRequest(t, http.MethodDelete,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/99999", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(writeToken)
MakeRequest(t, req, http.StatusNotFound)
})
}