diff --git a/models/actions/artifact.go b/models/actions/artifact.go index 492e3f6cea..25bf58705a 100644 --- a/models/actions/artifact.go +++ b/models/actions/artifact.go @@ -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 +} diff --git a/modules/structs/action.go b/modules/structs/action.go index cb6d76f3e3..1f015a08fb 100644 --- a/modules/structs/action.go +++ b/modules/structs/action.go @@ -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"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index efd10b567f..715aa9003b 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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() { diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index ba430d2e64..63d9e830b7 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -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) +} diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go index 5ad16b21ae..f93f523d8e 100644 --- a/routers/api/v1/swagger/repo.go +++ b/routers/api/v1/swagger/repo.go @@ -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"` +} diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index b1b7b5f2df..31b757d2b3 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -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) { diff --git a/services/actions/download.go b/services/actions/download.go new file mode 100644 index 0000000000..04aa1ca289 --- /dev/null +++ b/services/actions/download.go @@ -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 +} diff --git a/services/convert/action.go b/services/convert/action.go index 204139e3aa..dd21b9fb87 100644 --- a/services/convert/action.go +++ b/services/convert/action.go @@ -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 diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 3401d42641..e74f0039a6 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -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": { diff --git a/tests/integration/api_repo_action_artifact_test.go b/tests/integration/api_repo_action_artifact_test.go new file mode 100644 index 0000000000..f6486557cf --- /dev/null +++ b/tests/integration/api_repo_action_artifact_test.go @@ -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) + }) +}