forked from mirrors/forgejo
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:
parent
f9b3630911
commit
a85c527709
10 changed files with 1210 additions and 63 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
96
services/actions/download.go
Normal file
96
services/actions/download.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
341
templates/swagger/v1_json.tmpl
generated
341
templates/swagger/v1_json.tmpl
generated
|
|
@ -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": {
|
||||
|
|
|
|||
285
tests/integration/api_repo_action_artifact_test.go
Normal file
285
tests/integration/api_repo_action_artifact_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue