gitforge/services/actions/download.go
ShellWen a85c527709 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>
2026-04-20 05:10:54 +02:00

96 lines
2.7 KiB
Go

// 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
}