detached 5dbd27f

#24 feat(releases): GitHub-like releases and S3 blob storage

state
merged · 5dbd27f · fast-forward
branch
feat/releases → main
opened
bnema ·
updated

GitHub-like releases for ermite, plus a pluggable blob store (ADR 0034).

What it adds

  • Releases bound to a Git tag: title, Markdown notes, assets, draft/prerelease, latest (make_latest pin). Deleting the tag turns the release back into a draft.
  • Interfaces: GitHub-compatible REST under /-/github/api (also /api/v3 for go-github/GoReleaser), ConnectRPC ReleaseService, ermite release CLI, read-only web pages.
  • Stable links: //releases/latest/download/ and //releases/download//. Downloads are always relayed by ermite (Range/If-Range supported).
  • Scope release:write (in the cli preset), or a job token with contents: write. Branch-limited tokens are refused.
  • Blob store: disk ($ERMITE_DATA_DIR/blobs) or any S3-compatible provider via ERMITE_S3_* (common S3 subset, ermite never creates buckets). CI artifacts move onto it; legacy disk artifacts stay readable.
  • Storage schema 6: unrestricted tokens holding every schema-5 scope gain release:write.
  • Requires ermite-api v0.16.0 (SCOPE_RELEASE_WRITE = 13, release events 27-30).

Checks

  • go build, vet, hexcheck, go test -race ./... green.
  • e2e on the local dev stack: disk and MinIO backends, Firecracker runner with action-gh-release and upload-artifact (20 MiB), Playwright on the web pages. S3 contract suite against MinIO and a real Scaleway bucket.
  • Wrap-up review: 13 findings, all fixed with regression tests.

Deployment (after merge): Gordon secrets and S3 config for staging then prod, re-enable release.yml workflows.

CommitSubjectAuthorDateSignatureStatus
5dbd27ffix(lint): check ignored close and remove errors, simplify release conditionsbriceGPG Verified
b869944chore(deps): require ermite-api v0.16.0 and drop the local replacebriceGPG Verified
1428b20fix(releases): hide draft events and cursors, honour update targets, share range handlingbriceGPG Verified
97e2b33fix(blobs): reclaim crashed temp files, skip broken multipart entries, sync new directoriesbriceGPG Verified
8361f60fix(ci): bound concurrent artifact block uploads and reuse their buffersbriceGPG Verified
6abe390test(blobs): run the blob contract on MinIO, which lists unfinished uploads by keybriceGPG Verified
27d9aa4fix(githubapi): accept the api/v3 base that GoReleaser appends to the API URLbriceGPG Verified
d50b7cdfeat(app): wire releases and the blob store, S3 configuration, MinIO compose profile and docsbriceGPG Verified
0933a48feat(cli): ermite release commands and release:write in the cli presetbriceGPG Verified
f67dc6afeat(githubapi): GitHub-compatible REST API for releases and assetsbriceGPG Verified
eab40eefeat(web): releases tab, release pages and stable asset download linksbriceGPG Verified
bfabb0cfeat(rpc): ConnectRPC ReleaseService with streamed asset upload and downloadbriceGPG Verified
b8fe0a4fix(rpc): map the release:write scope to the contractbriceGPG Verified
f9e6941feat(ci): store CI artifacts on the blob store, keep legacy disk artifacts readablebriceGPG Verified
eb72c2ffeat(releases): release, asset and sweep use casesbriceGPG Verified
0c4e1e3feat(releases): git tag writer and release hooks on pushes and repository deletionbriceGPG Verified
8fd2b19feat(blobs): blob store port with disk and provider-agnostic S3 adaptersbriceGPG Verified
c57f9ecfeat(releases): release domain, kv storage, ports and schema 6 with release:writebriceGPG Verified

Checks 8

149 files · +17035 -809
8/8
M .hexcheck.yaml +12 -2
M README.md +1 -0
M compose.yaml +39 -0
M docs/ci-artifacts.md +2 -1
M docs/cli.md +45 -0
M docs/configuration.md +29 -1
A docs/releases.md +99 -0
A docs/storage.md +92 -0
M docs/tokens.md +9 -0
M gen/ermite/storage/ci_artifact.pb.go +15 -5
A gen/ermite/storage/release.pb.go +463 -0
M go.mod +16 -1
M go.sum +40 -2
A internal/adapters/blobdisk/blobdisk.go +572 -0
A internal/adapters/blobdisk/blobdisk_internal_test.go +87 -0
A internal/adapters/blobdisk/blobdisk_test.go +29 -0
A internal/adapters/blobs3/blobs3.go +461 -0
A internal/adapters/blobs3/blobs3_test.go +85 -0
A internal/adapters/blobs3/config.go +13 -0
A internal/adapters/blobs3/integration_test.go +44 -0
A internal/adapters/blobtest/suite.go +256 -0
M internal/adapters/ciartifacts/ciartifacts.go +5 -171
M internal/adapters/ciartifacts/ciartifacts_test.go +8 -105
M internal/adapters/client/client.go +5 -0
M internal/adapters/client/client_test.go +3 -3
M internal/adapters/client/features.go +3 -1
@@ -12,6 +12,8 @@ const (
1212 FeatureMarkReady = "merge_requests.ready"
1313 // FeatureIgnoreChecks permits merging with checks bypassed.
1414 FeatureIgnoreChecks = "merge_requests.ignore_checks"
15+ // FeatureReleases is ReleaseService (`ermite release`).
16+ FeatureReleases = "releases"
1517 // FeatureRepoUpdate is RepoService.UpdateRepo (`ermite repo edit`).
1618 FeatureRepoUpdate = "repo.update"
1719 // FeatureEventsResume is EventService.Watch with resume_after, which
@@ -69,7 +71,7 @@ const (
6971
7072 // UsedFeatures lists the features the CLI uses, in the order they are reported.
7173 func UsedFeatures() []string {
72- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures, FeatureAutoMerge}
74+ return []string{FeatureReleases, FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures, FeatureAutoMerge}
7375 }
7476
7577 // MissingFeatures returns the features the CLI uses that have is lacking.
@@ -12,6 +12,8 @@ const (
12 FeatureMarkReady = "merge_requests.ready"12 FeatureMarkReady = "merge_requests.ready"
13 // FeatureIgnoreChecks permits merging with checks bypassed.13 // FeatureIgnoreChecks permits merging with checks bypassed.
14 FeatureIgnoreChecks = "merge_requests.ignore_checks"14 FeatureIgnoreChecks = "merge_requests.ignore_checks"
15+ // FeatureReleases is ReleaseService (`ermite release`).
16+ FeatureReleases = "releases"
15 // FeatureRepoUpdate is RepoService.UpdateRepo (`ermite repo edit`).17 // FeatureRepoUpdate is RepoService.UpdateRepo (`ermite repo edit`).
16 FeatureRepoUpdate = "repo.update"18 FeatureRepoUpdate = "repo.update"
17 // FeatureEventsResume is EventService.Watch with resume_after, which19 // FeatureEventsResume is EventService.Watch with resume_after, which
@@ -69,7 +71,7 @@ const (
69 71
70 // UsedFeatures lists the features the CLI uses, in the order they are reported.72 // UsedFeatures lists the features the CLI uses, in the order they are reported.
71 func UsedFeatures() []string {73 func UsedFeatures() []string {
72- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures, FeatureAutoMerge}74+ return []string{FeatureReleases, FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures, FeatureAutoMerge}
73 }75 }
74 76
75 // MissingFeatures returns the features the CLI uses that have is lacking.77 // MissingFeatures returns the features the CLI uses that have is lacking.
A internal/adapters/client/release_test.go +43 -0
@@ -0,0 +1,43 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package client_test
4+
5+import (
6+ "context"
7+ "net/http"
8+ "net/http/httptest"
9+ "testing"
10+
11+ "connectrpc.com/connect"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
17+ "bnema.dev/ermite/internal/adapters/client"
18+)
19+
20+type releaseAPI struct {
21+ ermiteconnect.UnimplementedReleaseServiceHandler
22+}
23+
24+func (releaseAPI) GetRelease(_ context.Context, r *connect.Request[ermite.GetReleaseRequest]) (*connect.Response[ermite.GetReleaseResponse], error) {
25+ if r.Header().Get("Authorization") != "Bearer secret" || r.Header().Get("User-Agent") != "ermite/test" {
26+ return nil, connect.NewError(connect.CodeUnauthenticated, nil)
27+ }
28+ return connect.NewResponse(&ermite.GetReleaseResponse{Release: &ermite.Release{Id: r.Msg.GetId(), Repo: r.Msg.GetRepo()}}), nil
29+}
30+func TestReleasesAccessor(t *testing.T) {
31+ mux := http.NewServeMux()
32+ p, h := ermiteconnect.NewReleaseServiceHandler(releaseAPI{})
33+ mux.Handle("/-/api"+p, http.StripPrefix("/-/api", h))
34+ srv := httptest.NewServer(mux)
35+ t.Cleanup(srv.Close)
36+ c, err := client.New(client.Config{URL: srv.URL, Token: "secret", UserAgent: "ermite/test"})
37+ require.NoError(t, err)
38+ res, err := c.Releases().GetRelease(t.Context(), connect.NewRequest(&ermite.GetReleaseRequest{Repo: "proj", Selector: &ermite.GetReleaseRequest_Id{Id: 7}}))
39+ require.NoError(t, err)
40+ assert.Equal(t, int64(7), res.Msg.GetRelease().GetId())
41+ assert.Equal(t, "proj", res.Msg.GetRelease().GetRepo())
42+ assert.Contains(t, client.UsedFeatures(), client.FeatureReleases)
43+}
@@ -0,0 +1,43 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package client_test
4+
5+import (
6+ "context"
7+ "net/http"
8+ "net/http/httptest"
9+ "testing"
10+
11+ "connectrpc.com/connect"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
17+ "bnema.dev/ermite/internal/adapters/client"
18+)
19+
20+type releaseAPI struct {
21+ ermiteconnect.UnimplementedReleaseServiceHandler
22+}
23+
24+func (releaseAPI) GetRelease(_ context.Context, r *connect.Request[ermite.GetReleaseRequest]) (*connect.Response[ermite.GetReleaseResponse], error) {
25+ if r.Header().Get("Authorization") != "Bearer secret" || r.Header().Get("User-Agent") != "ermite/test" {
26+ return nil, connect.NewError(connect.CodeUnauthenticated, nil)
27+ }
28+ return connect.NewResponse(&ermite.GetReleaseResponse{Release: &ermite.Release{Id: r.Msg.GetId(), Repo: r.Msg.GetRepo()}}), nil
29+}
30+func TestReleasesAccessor(t *testing.T) {
31+ mux := http.NewServeMux()
32+ p, h := ermiteconnect.NewReleaseServiceHandler(releaseAPI{})
33+ mux.Handle("/-/api"+p, http.StripPrefix("/-/api", h))
34+ srv := httptest.NewServer(mux)
35+ t.Cleanup(srv.Close)
36+ c, err := client.New(client.Config{URL: srv.URL, Token: "secret", UserAgent: "ermite/test"})
37+ require.NoError(t, err)
38+ res, err := c.Releases().GetRelease(t.Context(), connect.NewRequest(&ermite.GetReleaseRequest{Repo: "proj", Selector: &ermite.GetReleaseRequest_Id{Id: 7}}))
39+ require.NoError(t, err)
40+ assert.Equal(t, int64(7), res.Msg.GetRelease().GetId())
41+ assert.Equal(t, "proj", res.Msg.GetRelease().GetRepo())
42+ assert.Contains(t, client.UsedFeatures(), client.FeatureReleases)
43+}
M internal/adapters/client/scopes_test.go +1 -0
@@ -28,6 +28,7 @@ func TestScopeWireNames(t *testing.T) {
2828 ermite.Scope_SCOPE_RUNNER_REGISTER: "runner:register",
2929 ermite.Scope_SCOPE_TOKEN_ADMIN: "token:admin",
3030 ermite.Scope_SCOPE_ISSUE_WRITE: "issue:write",
31+ ermite.Scope_SCOPE_RELEASE_WRITE: "release:write",
3132 }
3233 for s, name := range want {
3334 assert.Equal(t, name, client.ScopeName(s))
@@ -28,6 +28,7 @@ func TestScopeWireNames(t *testing.T) {
28 ermite.Scope_SCOPE_RUNNER_REGISTER: "runner:register",28 ermite.Scope_SCOPE_RUNNER_REGISTER: "runner:register",
29 ermite.Scope_SCOPE_TOKEN_ADMIN: "token:admin",29 ermite.Scope_SCOPE_TOKEN_ADMIN: "token:admin",
30 ermite.Scope_SCOPE_ISSUE_WRITE: "issue:write",30 ermite.Scope_SCOPE_ISSUE_WRITE: "issue:write",
31+ ermite.Scope_SCOPE_RELEASE_WRITE: "release:write",
31 }32 }
32 for s, name := range want {33 for s, name := range want {
33 assert.Equal(t, name, client.ScopeName(s))34 assert.Equal(t, name, client.ScopeName(s))
A internal/adapters/gitcli/tags.go +42 -0
@@ -0,0 +1,42 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ "github.com/bnema/zerowrap"
13+)
14+
15+var _ ports.ReleaseTagWriter = (*Git)(nil)
16+
17+// CreateTag creates a lightweight tag without ever moving an existing ref.
18+func (g *Git) CreateTag(ctx context.Context, repo domain.RepoName, tag string, target domain.ObjectID) error {
19+ if _, err := domain.ParseReleaseTag(tag); err != nil {
20+ return err
21+ }
22+ dir, err := g.repoDir(repo)
23+ if err != nil {
24+ return err
25+ }
26+ ref := "refs/tags/" + tag
27+ _, err = g.run(ctx, "--git-dir="+dir, "update-ref", ref, target.String(), domain.ZeroOID)
28+ if err != nil {
29+ // Distinguish a concurrent creator from an unrelated Git failure. ResolveRevision
30+ // peels tags, so use show-ref to also recognize tags pointing at non-commits.
31+ if _, existsErr := g.run(ctx, "--git-dir="+dir, "show-ref", "--verify", "--", ref); existsErr == nil {
32+ return fmt.Errorf("tag %q in %q: %w", tag, repo, domain.ErrAlreadyExists)
33+ }
34+ if errors.Is(err, context.Canceled) {
35+ return err
36+ }
37+ return fmt.Errorf("create tag %q in %q: %w", tag, repo, err)
38+ }
39+ log := zerowrap.FromCtx(ctx)
40+ log.Info().Str("repo", repo.String()).Str("tag", tag).Msg("release tag created")
41+ return nil
42+}
@@ -0,0 +1,42 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ "github.com/bnema/zerowrap"
13+)
14+
15+var _ ports.ReleaseTagWriter = (*Git)(nil)
16+
17+// CreateTag creates a lightweight tag without ever moving an existing ref.
18+func (g *Git) CreateTag(ctx context.Context, repo domain.RepoName, tag string, target domain.ObjectID) error {
19+ if _, err := domain.ParseReleaseTag(tag); err != nil {
20+ return err
21+ }
22+ dir, err := g.repoDir(repo)
23+ if err != nil {
24+ return err
25+ }
26+ ref := "refs/tags/" + tag
27+ _, err = g.run(ctx, "--git-dir="+dir, "update-ref", ref, target.String(), domain.ZeroOID)
28+ if err != nil {
29+ // Distinguish a concurrent creator from an unrelated Git failure. ResolveRevision
30+ // peels tags, so use show-ref to also recognize tags pointing at non-commits.
31+ if _, existsErr := g.run(ctx, "--git-dir="+dir, "show-ref", "--verify", "--", ref); existsErr == nil {
32+ return fmt.Errorf("tag %q in %q: %w", tag, repo, domain.ErrAlreadyExists)
33+ }
34+ if errors.Is(err, context.Canceled) {
35+ return err
36+ }
37+ return fmt.Errorf("create tag %q in %q: %w", tag, repo, err)
38+ }
39+ log := zerowrap.FromCtx(ctx)
40+ log.Info().Str("repo", repo.String()).Str("tag", tag).Msg("release tag created")
41+ return nil
42+}
A internal/adapters/gitcli/tags_test.go +31 -0
@@ -0,0 +1,31 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "bnema.dev/ermite/internal/domain"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestCreateReleaseTag(t *testing.T) {
14+ g, data := newGit(t)
15+ ctx := context.Background()
16+ m := newMergeFixture(t, data)
17+ t.Cleanup(func() { _ = g.Close(ctx) })
18+ require.NoError(t, g.CreateTag(ctx, m.f.Name, "v1.0", m.a))
19+ rev, err := domain.ParseRevision("refs/tags/v1.0")
20+ require.NoError(t, err)
21+ got, err := g.ResolveRevision(ctx, m.f.Name, rev)
22+ require.NoError(t, err)
23+ require.Equal(t, m.a, got.CommitID)
24+ require.ErrorIs(t, g.CreateTag(ctx, m.f.Name, "v1.0", m.d), domain.ErrAlreadyExists)
25+ got, err = g.ResolveRevision(ctx, m.f.Name, rev)
26+ require.NoError(t, err)
27+ require.Equal(t, m.a, got.CommitID)
28+ for _, tag := range []string{"", "../bad", "bad^{commit}", "bad\\name"} {
29+ require.ErrorIs(t, g.CreateTag(ctx, m.f.Name, tag, m.d), domain.ErrInvalid, tag)
30+ }
31+}
@@ -0,0 +1,31 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "bnema.dev/ermite/internal/domain"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestCreateReleaseTag(t *testing.T) {
14+ g, data := newGit(t)
15+ ctx := context.Background()
16+ m := newMergeFixture(t, data)
17+ t.Cleanup(func() { _ = g.Close(ctx) })
18+ require.NoError(t, g.CreateTag(ctx, m.f.Name, "v1.0", m.a))
19+ rev, err := domain.ParseRevision("refs/tags/v1.0")
20+ require.NoError(t, err)
21+ got, err := g.ResolveRevision(ctx, m.f.Name, rev)
22+ require.NoError(t, err)
23+ require.Equal(t, m.a, got.CommitID)
24+ require.ErrorIs(t, g.CreateTag(ctx, m.f.Name, "v1.0", m.d), domain.ErrAlreadyExists)
25+ got, err = g.ResolveRevision(ctx, m.f.Name, rev)
26+ require.NoError(t, err)
27+ require.Equal(t, m.a, got.CommitID)
28+ for _, tag := range []string{"", "../bad", "bad^{commit}", "bad\\name"} {
29+ require.ErrorIs(t, g.CreateTag(ctx, m.f.Name, tag, m.d), domain.ErrInvalid, tag)
30+ }
31+}
M internal/adapters/githubapi/handler.go +23 -2
@@ -68,6 +68,10 @@ const (
6868 type Config struct {
6969 Auth ports.AuthService
7070 API ports.GitHubAPIService
71+ // Releases and ReleaseDownload enable the release REST routes. Nil services
72+ // leave their routes unavailable with GitHub's 404 JSON.
73+ Releases ports.GitHubReleaseAPI
74+ ReleaseDownload ports.ReleaseService
7175 // TrustedProxies are the networks allowed to set X-Forwarded-For.
7276 TrustedProxies []netip.Prefix
7377 // Limiter counts failed authentications; nil builds a default one.
@@ -124,8 +128,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
124128 notFound(w)
125129 return
126130 }
127- if r.Method != http.MethodGet && r.Method != http.MethodHead {
128- w.Header().Set("Allow", "GET, HEAD")
131+ if strings.HasPrefix(kind, "release") && h.cfg.Releases == nil {
132+ notFound(w)
133+ return
134+ }
135+ allowed := releaseMethods(kind)
136+ if !methodAllowed(r.Method, allowed) {
137+ w.Header().Set("Allow", allowed)
129138 jsonError(w, http.StatusMethodNotAllowed, "Method Not Allowed")
130139 return
131140 }
@@ -148,6 +157,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
148157 return
149158 }
150159
160+ if strings.HasPrefix(kind, "release") {
161+ h.releaseRequest(w, r, p, kind, owner, repo, ref, clientlimit.Key(addr))
162+ return
163+ }
151164 switch kind {
152165 case "repo":
153166 body, err := h.cfg.API.GetRepo(ctx, p, owner, repo)
@@ -172,6 +185,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
172185
173186 // route recognises the endpoints. ref is "" for /tarball.
174187 func route(rest string) (kind, owner, repo, ref string, ok bool) {
188+ // go-github's enterprise client (used by GoReleaser) appends api/v3
189+ // to a custom API base. Keep the canonical URLs, accepting that alias too.
190+ if strings.HasPrefix(rest, "/api/v3/") {
191+ rest = strings.TrimPrefix(rest, "/api/v3")
192+ }
193+ if kind, owner, repo, ref, ok = releaseRoute(rest); ok {
194+ return
195+ }
175196 parts := strings.Split(strings.TrimPrefix(rest, "/"), "/")
176197 if len(parts) < 3 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" {
177198 return "", "", "", "", false
@@ -68,6 +68,10 @@ const (
68 type Config struct {68 type Config struct {
69 Auth ports.AuthService69 Auth ports.AuthService
70 API ports.GitHubAPIService70 API ports.GitHubAPIService
71+ // Releases and ReleaseDownload enable the release REST routes. Nil services
72+ // leave their routes unavailable with GitHub's 404 JSON.
73+ Releases ports.GitHubReleaseAPI
74+ ReleaseDownload ports.ReleaseService
71 // TrustedProxies are the networks allowed to set X-Forwarded-For.75 // TrustedProxies are the networks allowed to set X-Forwarded-For.
72 TrustedProxies []netip.Prefix76 TrustedProxies []netip.Prefix
73 // Limiter counts failed authentications; nil builds a default one.77 // Limiter counts failed authentications; nil builds a default one.
@@ -124,8 +128,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
124 notFound(w)128 notFound(w)
125 return129 return
126 }130 }
127- if r.Method != http.MethodGet && r.Method != http.MethodHead {131+ if strings.HasPrefix(kind, "release") && h.cfg.Releases == nil {
128- w.Header().Set("Allow", "GET, HEAD")132+ notFound(w)
133+ return
134+ }
135+ allowed := releaseMethods(kind)
136+ if !methodAllowed(r.Method, allowed) {
137+ w.Header().Set("Allow", allowed)
129 jsonError(w, http.StatusMethodNotAllowed, "Method Not Allowed")138 jsonError(w, http.StatusMethodNotAllowed, "Method Not Allowed")
130 return139 return
131 }140 }
@@ -148,6 +157,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
148 return157 return
149 }158 }
150 159
160+ if strings.HasPrefix(kind, "release") {
161+ h.releaseRequest(w, r, p, kind, owner, repo, ref, clientlimit.Key(addr))
162+ return
163+ }
151 switch kind {164 switch kind {
152 case "repo":165 case "repo":
153 body, err := h.cfg.API.GetRepo(ctx, p, owner, repo)166 body, err := h.cfg.API.GetRepo(ctx, p, owner, repo)
@@ -172,6 +185,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
172 185
173 // route recognises the endpoints. ref is "" for /tarball.186 // route recognises the endpoints. ref is "" for /tarball.
174 func route(rest string) (kind, owner, repo, ref string, ok bool) {187 func route(rest string) (kind, owner, repo, ref string, ok bool) {
188+ // go-github's enterprise client (used by GoReleaser) appends api/v3
189+ // to a custom API base. Keep the canonical URLs, accepting that alias too.
190+ if strings.HasPrefix(rest, "/api/v3/") {
191+ rest = strings.TrimPrefix(rest, "/api/v3")
192+ }
193+ if kind, owner, repo, ref, ok = releaseRoute(rest); ok {
194+ return
195+ }
175 parts := strings.Split(strings.TrimPrefix(rest, "/"), "/")196 parts := strings.Split(strings.TrimPrefix(rest, "/"), "/")
176 if len(parts) < 3 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" {197 if len(parts) < 3 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" {
177 return "", "", "", "", false198 return "", "", "", "", false
A internal/adapters/githubapi/releases.go +413 -0
@@ -0,0 +1,413 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githubapi
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "io"
10+ "mime"
11+ "net/http"
12+ "strconv"
13+ "strings"
14+ "unicode"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/httprange"
18+ "bnema.dev/ermite/internal/ports"
19+ "github.com/bnema/zerowrap"
20+)
21+
22+func releaseRoute(rest string) (kind, owner, repo, ref string, ok bool) {
23+ parts := strings.Split(strings.TrimPrefix(rest, "/"), "/")
24+ upload := len(parts) > 0 && parts[0] == "uploads"
25+ if upload {
26+ parts = parts[1:]
27+ }
28+ if len(parts) < 4 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" || parts[3] != "releases" {
29+ return
30+ }
31+ owner, repo = parts[1], parts[2]
32+ if upload {
33+ if len(parts) == 6 && isNumber(parts[4]) && parts[5] == "assets" {
34+ return "release-upload", owner, repo, parts[4], true
35+ }
36+ return
37+ }
38+ switch {
39+ case len(parts) == 4:
40+ return "release-list", owner, repo, "", true
41+ case len(parts) == 5 && parts[4] == "latest":
42+ return "release-latest", owner, repo, "", true
43+ case len(parts) >= 6 && parts[4] == "tags" && strings.Join(parts[5:], "/") != "":
44+ return "release-tag", owner, repo, strings.Join(parts[5:], "/"), true
45+ case len(parts) == 5 && isNumber(parts[4]):
46+ return "release-id", owner, repo, parts[4], true
47+ case len(parts) == 6 && isNumber(parts[4]) && parts[5] == "assets":
48+ return "release-assets", owner, repo, parts[4], true
49+ case len(parts) == 6 && parts[4] == "assets" && isNumber(parts[5]):
50+ return "release-asset", owner, repo, parts[5], true
51+ }
52+ return
53+}
54+func releaseMethods(kind string) string {
55+ switch kind {
56+ case "release-list":
57+ return "GET, HEAD, POST"
58+ case "release-id", "release-asset":
59+ return "GET, HEAD, PATCH, DELETE"
60+ case "release-upload":
61+ return "POST"
62+ default:
63+ return "GET, HEAD"
64+ }
65+}
66+func methodAllowed(method, allow string) bool {
67+ for _, m := range strings.Split(allow, ", ") {
68+ if method == m {
69+ return true
70+ }
71+ }
72+ return false
73+}
74+func releaseJSON(w http.ResponseWriter, r *http.Request, status int, body []byte) {
75+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
76+ w.Header().Set("Content-Length", strconv.Itoa(len(body)))
77+ w.WriteHeader(status)
78+ if r.Method != http.MethodHead {
79+ _, _ = w.Write(body)
80+ }
81+}
82+func releaseMessage(w http.ResponseWriter, status int, message string) {
83+ b, _ := json.Marshal(struct {
84+ Message string `json:"message"`
85+ }{message})
86+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
87+ w.WriteHeader(status)
88+ _, _ = w.Write(b)
89+}
90+func releaseBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
91+ b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
92+ if err != nil {
93+ var tooLarge *http.MaxBytesError
94+ if errors.As(err, &tooLarge) {
95+ releaseMessage(w, 422, "Body too large")
96+ } else {
97+ releaseMessage(w, 422, "Validation Failed")
98+ }
99+ return nil, false
100+ }
101+ return b, true
102+}
103+func (h *Handler) releaseRequest(w http.ResponseWriter, r *http.Request, p domain.Principal, kind, owner, repo, ref, client string) {
104+ ctx := r.Context()
105+ api := h.cfg.Releases
106+ id, _ := strconv.ParseInt(ref, 10, 64)
107+ q := r.URL.Query()
108+ page, _ := strconv.Atoi(q.Get("page"))
109+ perPage, _ := strconv.Atoi(q.Get("per_page"))
110+ var body []byte
111+ var err error
112+ var link string
113+ status := http.StatusOK
114+ if kind == "release-asset" && (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.Contains(strings.ToLower(r.Header.Get("Accept")), "application/octet-stream") {
115+ if h.cfg.ReleaseDownload == nil {
116+ notFound(w)
117+ return
118+ }
119+ h.downloadRelease(w, r, p, owner, repo, id, client)
120+ return
121+ }
122+ switch kind {
123+ case "release-list":
124+ if r.Method == http.MethodPost {
125+ b, ok := releaseBody(w, r)
126+ if !ok {
127+ return
128+ }
129+ body, err = api.CreateRelease(ctx, p, owner, repo, b)
130+ status = 201
131+ } else {
132+ body, link, err = api.ListReleases(ctx, p, owner, repo, page, perPage)
133+ }
134+ case "release-latest":
135+ body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{Latest: true})
136+ case "release-tag":
137+ body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{Tag: ref})
138+ case "release-id":
139+ switch r.Method {
140+ case http.MethodPatch:
141+ b, ok := releaseBody(w, r)
142+ if !ok {
143+ return
144+ }
145+ body, err = api.UpdateRelease(ctx, p, owner, repo, id, b)
146+ case http.MethodDelete:
147+ err = api.DeleteRelease(ctx, p, owner, repo, id)
148+ status = 204
149+ default:
150+ body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{ID: id})
151+ }
152+ case "release-assets":
153+ body, link, err = api.ListAssets(ctx, p, owner, repo, id, page, perPage)
154+ case "release-upload":
155+ if r.ContentLength < 0 || (r.ContentLength == 0 && r.Header.Get("Content-Length") == "") {
156+ releaseMessage(w, 411, "Length Required")
157+ return
158+ }
159+ if q.Get("name") == "" {
160+ h.failRelease(ctx, w, p, domain.ErrInvalid, "release-upload", "name")
161+ return
162+ }
163+ ct := r.Header.Get("Content-Type")
164+ if ct == "" {
165+ ct = "application/octet-stream"
166+ }
167+ body, err = api.UploadAsset(ctx, p, owner, repo, id, q.Get("name"), q.Get("label"), ct, r.ContentLength, io.LimitReader(r.Body, r.ContentLength))
168+ status = 201
169+ case "release-asset":
170+ switch r.Method {
171+ case http.MethodPatch:
172+ b, ok := releaseBody(w, r)
173+ if !ok {
174+ return
175+ }
176+ body, err = api.UpdateAsset(ctx, p, owner, repo, id, b)
177+ case http.MethodDelete:
178+ err = api.DeleteAsset(ctx, p, owner, repo, id)
179+ status = 204
180+ default:
181+ body, err = api.GetAsset(ctx, p, owner, repo, id)
182+ }
183+ }
184+ if err != nil {
185+ h.failRelease(ctx, w, p, err, kind, "")
186+ return
187+ }
188+ if link != "" {
189+ w.Header().Set("Link", link)
190+ }
191+ if status == 204 {
192+ w.WriteHeader(status)
193+ return
194+ }
195+ releaseJSON(w, r, status, body)
196+}
197+
198+// failRelease translates use-case failures to GitHub's status and error JSON;
199+// authorization and limits have already been decided by ReleaseService.
200+//
201+//hexcheck:ignore suspicious-business-logic-in-adapter maps domain failures and the principal kind to the GitHub HTTP error contract; no access or quota decision is made here
202+func (h *Handler) failRelease(ctx context.Context, w http.ResponseWriter, p domain.Principal, err error, kind, field string) {
203+ switch {
204+ case errors.Is(err, domain.ErrNotFound):
205+ notFound(w)
206+ case errors.Is(err, domain.ErrUnauthenticated):
207+ releaseMessage(w, 401, "Bad credentials")
208+ case errors.Is(err, domain.ErrForbidden):
209+ msg := "Forbidden"
210+ if p.ViaJobToken() {
211+ msg = "Resource not accessible by integration"
212+ }
213+ releaseMessage(w, 403, msg)
214+ case errors.Is(err, domain.ErrResourceExhausted):
215+ if strings.Contains(err.Error(), "too many uploads in progress") {
216+ w.Header().Set("Retry-After", retryAfter)
217+ releaseMessage(w, 429, err.Error())
218+ } else {
219+ releaseMessage(w, 422, err.Error())
220+ }
221+ case errors.Is(err, domain.ErrAlreadyExists), domain.IsInvalid(err), errors.Is(err, domain.ErrPreconditionFailed):
222+ writeReleaseValidation(w, err, kind, field)
223+ case errors.Is(err, context.Canceled):
224+ default:
225+ log := zerowrap.FromCtx(ctx)
226+ log.Error().Err(err).Msg("github release request failed")
227+ releaseMessage(w, 500, "Server Error")
228+ }
229+}
230+
231+type releaseValidationError struct {
232+ Resource string `json:"resource"`
233+ Code string `json:"code"`
234+ Field string `json:"field,omitempty"`
235+}
236+
237+func writeReleaseValidation(w http.ResponseWriter, err error, kind, field string) {
238+ resource, code, doc := "Release", "invalid", ""
239+ asset := kind == "release-upload" || kind == "release-asset"
240+ if asset {
241+ resource = "ReleaseAsset"
242+ }
243+ if errors.Is(err, domain.ErrAlreadyExists) {
244+ code = "already_exists"
245+ field = "tag_name"
246+ if asset {
247+ field = "name"
248+ }
249+ if kind == "release-upload" {
250+ doc = "https://docs.github.com/rest/releases/assets#upload-a-release-asset"
251+ }
252+ } else if errors.Is(err, domain.ErrPreconditionFailed) {
253+ field = "target_commitish"
254+ } else if field == "" {
255+ field = releaseErrorField(err.Error())
256+ }
257+ b, _ := json.Marshal(struct {
258+ Message string `json:"message"`
259+ Errors []releaseValidationError `json:"errors"`
260+ DocumentationURL string `json:"documentation_url,omitempty"`
261+ }{"Validation Failed", []releaseValidationError{{resource, code, field}}, doc})
262+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
263+ w.WriteHeader(422)
264+ _, _ = w.Write(b)
265+}
266+
267+// releaseErrorField recognises the wire fields and domain validation vocabulary.
268+// Errors without a named field omit it rather than guessing.
269+func releaseErrorField(text string) string {
270+ for _, f := range []string{"target_commitish", "make_latest", "tag_name", "prerelease", "draft", "content_type", "label", "body"} {
271+ if strings.Contains(text, f) {
272+ return f
273+ }
274+ }
275+ if strings.Contains(text, "tag name") {
276+ return "tag_name"
277+ }
278+ if strings.Contains(text, "media type") {
279+ return "content_type"
280+ }
281+ if strings.Contains(text, "name") {
282+ return "name"
283+ }
284+ return ""
285+}
286+
287+func releaseContentDisposition(name string) string {
288+ name = strings.Map(func(r rune) rune {
289+ if unicode.IsControl(r) || r == '"' || r == '\\' || r == '/' || r == unicode.ReplacementChar {
290+ return -1
291+ }
292+ return r
293+ }, name)
294+ if name == "" || name == "." || name == ".." {
295+ name = "asset"
296+ }
297+ ascii := true
298+ fallback := strings.Map(func(r rune) rune {
299+ if r > unicode.MaxASCII {
300+ ascii = false
301+ return '_'
302+ }
303+ return r
304+ }, name)
305+ // Quote even token-safe names, keeping a consistent RFC 6266 representation.
306+ cd := "attachment; filename=" + strconv.Quote(fallback)
307+ if !ascii {
308+ const digits = "0123456789ABCDEF"
309+ var b strings.Builder
310+ for i := 0; i < len(name); i++ {
311+ c := name[i]
312+ if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.IndexByte("!#$&+-.^_`|~", c) >= 0 {
313+ b.WriteByte(c)
314+ } else {
315+ b.WriteByte('%')
316+ b.WriteByte(digits[c>>4])
317+ b.WriteByte(digits[c&15])
318+ }
319+ }
320+ cd += "; filename*=UTF-8''" + b.String()
321+ }
322+ return cd
323+}
324+func setReleaseDownloadHeaders(w http.ResponseWriter, a domain.ReleaseAsset) string {
325+ ct := a.ContentType
326+ media, _, err := mime.ParseMediaType(ct)
327+ media = strings.ToLower(media)
328+ if err != nil || strings.HasPrefix(media, "text/html") || media == "application/javascript" || media == "image/svg+xml" {
329+ ct = "application/octet-stream"
330+ }
331+ w.Header().Set("Content-Type", ct)
332+ w.Header().Set("Content-Disposition", releaseContentDisposition(a.Name))
333+ w.Header().Set("X-Content-Type-Options", "nosniff")
334+ w.Header().Set("Content-Security-Policy", "sandbox")
335+ w.Header().Set("Accept-Ranges", "bytes")
336+ etag := `"sha256-` + strings.TrimPrefix(a.Digest, "sha256:") + `"`
337+ w.Header().Set("ETag", etag)
338+ w.Header().Set("Last-Modified", a.UpdatedAt.UTC().Format(http.TimeFormat))
339+ return etag
340+}
341+func (h *Handler) downloadRelease(w http.ResponseWriter, r *http.Request, p domain.Principal, owner, repo string, id int64, client string) {
342+ ctx := r.Context()
343+ a, err := h.cfg.Releases.AssetByID(ctx, p, owner, repo, id)
344+ if err != nil {
345+ h.failRelease(ctx, w, p, err, "release-asset", "")
346+ return
347+ }
348+ if r.Method != http.MethodHead {
349+ release, ok := h.acquire(w, client)
350+ if !ok {
351+ return
352+ }
353+ defer release()
354+ }
355+ etag := setReleaseDownloadHeaders(w, a)
356+ if r.Header.Get("If-None-Match") == etag {
357+ w.WriteHeader(304)
358+ return
359+ }
360+ offset, length, partial, err := httprange.Select(r, a.Size, etag)
361+ if err != nil {
362+ w.Header().Set("Content-Range", httprange.Unsatisfied(a.Size))
363+ w.WriteHeader(416)
364+ return
365+ }
366+ status := 200
367+ if partial {
368+ status = 206
369+ w.Header().Set("Content-Range", httprange.ContentRange(offset, length, a.Size))
370+ }
371+ w.Header().Set("Content-Length", strconv.FormatInt(length, 10))
372+ if r.Method == http.MethodHead {
373+ w.WriteHeader(status)
374+ return
375+ }
376+ // Slot failures and pre-stream failures are JSON, not a binary response.
377+ clearHeaders := func() {
378+ for _, k := range []string{"Content-Length", "Content-Range", "Content-Disposition", "ETag", "Last-Modified", "Accept-Ranges", "Content-Security-Policy"} {
379+ w.Header().Del(k)
380+ }
381+ }
382+ writer := &idleWriter{w: w, rc: http.NewResponseController(w), idle: h.idle}
383+ started := false
384+ err = h.cfg.ReleaseDownload.DownloadAsset(ctx, p, a.Repo, ports.AssetRef{ID: id}, offset, length, func(chunk ports.AssetChunk) error {
385+ if len(chunk.Data) == 0 {
386+ return nil
387+ }
388+ if !started {
389+ w.WriteHeader(status)
390+ started = true
391+ }
392+ _, e := writer.Write(chunk.Data)
393+ return e
394+ })
395+ if err == nil {
396+ if !started {
397+ w.WriteHeader(status)
398+ }
399+ return
400+ }
401+ if started {
402+ log := zerowrap.FromCtx(ctx)
403+ log.Warn().Err(err).Msg("github release download cut short")
404+ panic(http.ErrAbortHandler)
405+ }
406+ clearHeaders()
407+ if errors.Is(err, domain.ErrOutOfRange) {
408+ w.Header().Set("Content-Range", "bytes */"+strconv.FormatInt(a.Size, 10))
409+ w.WriteHeader(416)
410+ return
411+ }
412+ h.failRelease(ctx, w, p, err, "release-asset", "")
413+}
@@ -0,0 +1,413 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githubapi
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "io"
10+ "mime"
11+ "net/http"
12+ "strconv"
13+ "strings"
14+ "unicode"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/httprange"
18+ "bnema.dev/ermite/internal/ports"
19+ "github.com/bnema/zerowrap"
20+)
21+
22+func releaseRoute(rest string) (kind, owner, repo, ref string, ok bool) {
23+ parts := strings.Split(strings.TrimPrefix(rest, "/"), "/")
24+ upload := len(parts) > 0 && parts[0] == "uploads"
25+ if upload {
26+ parts = parts[1:]
27+ }
28+ if len(parts) < 4 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" || parts[3] != "releases" {
29+ return
30+ }
31+ owner, repo = parts[1], parts[2]
32+ if upload {
33+ if len(parts) == 6 && isNumber(parts[4]) && parts[5] == "assets" {
34+ return "release-upload", owner, repo, parts[4], true
35+ }
36+ return
37+ }
38+ switch {
39+ case len(parts) == 4:
40+ return "release-list", owner, repo, "", true
41+ case len(parts) == 5 && parts[4] == "latest":
42+ return "release-latest", owner, repo, "", true
43+ case len(parts) >= 6 && parts[4] == "tags" && strings.Join(parts[5:], "/") != "":
44+ return "release-tag", owner, repo, strings.Join(parts[5:], "/"), true
45+ case len(parts) == 5 && isNumber(parts[4]):
46+ return "release-id", owner, repo, parts[4], true
47+ case len(parts) == 6 && isNumber(parts[4]) && parts[5] == "assets":
48+ return "release-assets", owner, repo, parts[4], true
49+ case len(parts) == 6 && parts[4] == "assets" && isNumber(parts[5]):
50+ return "release-asset", owner, repo, parts[5], true
51+ }
52+ return
53+}
54+func releaseMethods(kind string) string {
55+ switch kind {
56+ case "release-list":
57+ return "GET, HEAD, POST"
58+ case "release-id", "release-asset":
59+ return "GET, HEAD, PATCH, DELETE"
60+ case "release-upload":
61+ return "POST"
62+ default:
63+ return "GET, HEAD"
64+ }
65+}
66+func methodAllowed(method, allow string) bool {
67+ for _, m := range strings.Split(allow, ", ") {
68+ if method == m {
69+ return true
70+ }
71+ }
72+ return false
73+}
74+func releaseJSON(w http.ResponseWriter, r *http.Request, status int, body []byte) {
75+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
76+ w.Header().Set("Content-Length", strconv.Itoa(len(body)))
77+ w.WriteHeader(status)
78+ if r.Method != http.MethodHead {
79+ _, _ = w.Write(body)
80+ }
81+}
82+func releaseMessage(w http.ResponseWriter, status int, message string) {
83+ b, _ := json.Marshal(struct {
84+ Message string `json:"message"`
85+ }{message})
86+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
87+ w.WriteHeader(status)
88+ _, _ = w.Write(b)
89+}
90+func releaseBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
91+ b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
92+ if err != nil {
93+ var tooLarge *http.MaxBytesError
94+ if errors.As(err, &tooLarge) {
95+ releaseMessage(w, 422, "Body too large")
96+ } else {
97+ releaseMessage(w, 422, "Validation Failed")
98+ }
99+ return nil, false
100+ }
101+ return b, true
102+}
103+func (h *Handler) releaseRequest(w http.ResponseWriter, r *http.Request, p domain.Principal, kind, owner, repo, ref, client string) {
104+ ctx := r.Context()
105+ api := h.cfg.Releases
106+ id, _ := strconv.ParseInt(ref, 10, 64)
107+ q := r.URL.Query()
108+ page, _ := strconv.Atoi(q.Get("page"))
109+ perPage, _ := strconv.Atoi(q.Get("per_page"))
110+ var body []byte
111+ var err error
112+ var link string
113+ status := http.StatusOK
114+ if kind == "release-asset" && (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.Contains(strings.ToLower(r.Header.Get("Accept")), "application/octet-stream") {
115+ if h.cfg.ReleaseDownload == nil {
116+ notFound(w)
117+ return
118+ }
119+ h.downloadRelease(w, r, p, owner, repo, id, client)
120+ return
121+ }
122+ switch kind {
123+ case "release-list":
124+ if r.Method == http.MethodPost {
125+ b, ok := releaseBody(w, r)
126+ if !ok {
127+ return
128+ }
129+ body, err = api.CreateRelease(ctx, p, owner, repo, b)
130+ status = 201
131+ } else {
132+ body, link, err = api.ListReleases(ctx, p, owner, repo, page, perPage)
133+ }
134+ case "release-latest":
135+ body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{Latest: true})
136+ case "release-tag":
137+ body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{Tag: ref})
138+ case "release-id":
139+ switch r.Method {
140+ case http.MethodPatch:
141+ b, ok := releaseBody(w, r)
142+ if !ok {
143+ return
144+ }
145+ body, err = api.UpdateRelease(ctx, p, owner, repo, id, b)
146+ case http.MethodDelete:
147+ err = api.DeleteRelease(ctx, p, owner, repo, id)
148+ status = 204
149+ default:
150+ body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{ID: id})
151+ }
152+ case "release-assets":
153+ body, link, err = api.ListAssets(ctx, p, owner, repo, id, page, perPage)
154+ case "release-upload":
155+ if r.ContentLength < 0 || (r.ContentLength == 0 && r.Header.Get("Content-Length") == "") {
156+ releaseMessage(w, 411, "Length Required")
157+ return
158+ }
159+ if q.Get("name") == "" {
160+ h.failRelease(ctx, w, p, domain.ErrInvalid, "release-upload", "name")
161+ return
162+ }
163+ ct := r.Header.Get("Content-Type")
164+ if ct == "" {
165+ ct = "application/octet-stream"
166+ }
167+ body, err = api.UploadAsset(ctx, p, owner, repo, id, q.Get("name"), q.Get("label"), ct, r.ContentLength, io.LimitReader(r.Body, r.ContentLength))
168+ status = 201
169+ case "release-asset":
170+ switch r.Method {
171+ case http.MethodPatch:
172+ b, ok := releaseBody(w, r)
173+ if !ok {
174+ return
175+ }
176+ body, err = api.UpdateAsset(ctx, p, owner, repo, id, b)
177+ case http.MethodDelete:
178+ err = api.DeleteAsset(ctx, p, owner, repo, id)
179+ status = 204
180+ default:
181+ body, err = api.GetAsset(ctx, p, owner, repo, id)
182+ }
183+ }
184+ if err != nil {
185+ h.failRelease(ctx, w, p, err, kind, "")
186+ return
187+ }
188+ if link != "" {
189+ w.Header().Set("Link", link)
190+ }
191+ if status == 204 {
192+ w.WriteHeader(status)
193+ return
194+ }
195+ releaseJSON(w, r, status, body)
196+}
197+
198+// failRelease translates use-case failures to GitHub's status and error JSON;
199+// authorization and limits have already been decided by ReleaseService.
200+//
201+//hexcheck:ignore suspicious-business-logic-in-adapter maps domain failures and the principal kind to the GitHub HTTP error contract; no access or quota decision is made here
202+func (h *Handler) failRelease(ctx context.Context, w http.ResponseWriter, p domain.Principal, err error, kind, field string) {
203+ switch {
204+ case errors.Is(err, domain.ErrNotFound):
205+ notFound(w)
206+ case errors.Is(err, domain.ErrUnauthenticated):
207+ releaseMessage(w, 401, "Bad credentials")
208+ case errors.Is(err, domain.ErrForbidden):
209+ msg := "Forbidden"
210+ if p.ViaJobToken() {
211+ msg = "Resource not accessible by integration"
212+ }
213+ releaseMessage(w, 403, msg)
214+ case errors.Is(err, domain.ErrResourceExhausted):
215+ if strings.Contains(err.Error(), "too many uploads in progress") {
216+ w.Header().Set("Retry-After", retryAfter)
217+ releaseMessage(w, 429, err.Error())
218+ } else {
219+ releaseMessage(w, 422, err.Error())
220+ }
221+ case errors.Is(err, domain.ErrAlreadyExists), domain.IsInvalid(err), errors.Is(err, domain.ErrPreconditionFailed):
222+ writeReleaseValidation(w, err, kind, field)
223+ case errors.Is(err, context.Canceled):
224+ default:
225+ log := zerowrap.FromCtx(ctx)
226+ log.Error().Err(err).Msg("github release request failed")
227+ releaseMessage(w, 500, "Server Error")
228+ }
229+}
230+
231+type releaseValidationError struct {
232+ Resource string `json:"resource"`
233+ Code string `json:"code"`
234+ Field string `json:"field,omitempty"`
235+}
236+
237+func writeReleaseValidation(w http.ResponseWriter, err error, kind, field string) {
238+ resource, code, doc := "Release", "invalid", ""
239+ asset := kind == "release-upload" || kind == "release-asset"
240+ if asset {
241+ resource = "ReleaseAsset"
242+ }
243+ if errors.Is(err, domain.ErrAlreadyExists) {
244+ code = "already_exists"
245+ field = "tag_name"
246+ if asset {
247+ field = "name"
248+ }
249+ if kind == "release-upload" {
250+ doc = "https://docs.github.com/rest/releases/assets#upload-a-release-asset"
251+ }
252+ } else if errors.Is(err, domain.ErrPreconditionFailed) {
253+ field = "target_commitish"
254+ } else if field == "" {
255+ field = releaseErrorField(err.Error())
256+ }
257+ b, _ := json.Marshal(struct {
258+ Message string `json:"message"`
259+ Errors []releaseValidationError `json:"errors"`
260+ DocumentationURL string `json:"documentation_url,omitempty"`
261+ }{"Validation Failed", []releaseValidationError{{resource, code, field}}, doc})
262+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
263+ w.WriteHeader(422)
264+ _, _ = w.Write(b)
265+}
266+
267+// releaseErrorField recognises the wire fields and domain validation vocabulary.
268+// Errors without a named field omit it rather than guessing.
269+func releaseErrorField(text string) string {
270+ for _, f := range []string{"target_commitish", "make_latest", "tag_name", "prerelease", "draft", "content_type", "label", "body"} {
271+ if strings.Contains(text, f) {
272+ return f
273+ }
274+ }
275+ if strings.Contains(text, "tag name") {
276+ return "tag_name"
277+ }
278+ if strings.Contains(text, "media type") {
279+ return "content_type"
280+ }
281+ if strings.Contains(text, "name") {
282+ return "name"
283+ }
284+ return ""
285+}
286+
287+func releaseContentDisposition(name string) string {
288+ name = strings.Map(func(r rune) rune {
289+ if unicode.IsControl(r) || r == '"' || r == '\\' || r == '/' || r == unicode.ReplacementChar {
290+ return -1
291+ }
292+ return r
293+ }, name)
294+ if name == "" || name == "." || name == ".." {
295+ name = "asset"
296+ }
297+ ascii := true
298+ fallback := strings.Map(func(r rune) rune {
299+ if r > unicode.MaxASCII {
300+ ascii = false
301+ return '_'
302+ }
303+ return r
304+ }, name)
305+ // Quote even token-safe names, keeping a consistent RFC 6266 representation.
306+ cd := "attachment; filename=" + strconv.Quote(fallback)
307+ if !ascii {
308+ const digits = "0123456789ABCDEF"
309+ var b strings.Builder
310+ for i := 0; i < len(name); i++ {
311+ c := name[i]
312+ if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.IndexByte("!#$&+-.^_`|~", c) >= 0 {
313+ b.WriteByte(c)
314+ } else {
315+ b.WriteByte('%')
316+ b.WriteByte(digits[c>>4])
317+ b.WriteByte(digits[c&15])
318+ }
319+ }
320+ cd += "; filename*=UTF-8''" + b.String()
321+ }
322+ return cd
323+}
324+func setReleaseDownloadHeaders(w http.ResponseWriter, a domain.ReleaseAsset) string {
325+ ct := a.ContentType
326+ media, _, err := mime.ParseMediaType(ct)
327+ media = strings.ToLower(media)
328+ if err != nil || strings.HasPrefix(media, "text/html") || media == "application/javascript" || media == "image/svg+xml" {
329+ ct = "application/octet-stream"
330+ }
331+ w.Header().Set("Content-Type", ct)
332+ w.Header().Set("Content-Disposition", releaseContentDisposition(a.Name))
333+ w.Header().Set("X-Content-Type-Options", "nosniff")
334+ w.Header().Set("Content-Security-Policy", "sandbox")
335+ w.Header().Set("Accept-Ranges", "bytes")
336+ etag := `"sha256-` + strings.TrimPrefix(a.Digest, "sha256:") + `"`
337+ w.Header().Set("ETag", etag)
338+ w.Header().Set("Last-Modified", a.UpdatedAt.UTC().Format(http.TimeFormat))
339+ return etag
340+}
341+func (h *Handler) downloadRelease(w http.ResponseWriter, r *http.Request, p domain.Principal, owner, repo string, id int64, client string) {
342+ ctx := r.Context()
343+ a, err := h.cfg.Releases.AssetByID(ctx, p, owner, repo, id)
344+ if err != nil {
345+ h.failRelease(ctx, w, p, err, "release-asset", "")
346+ return
347+ }
348+ if r.Method != http.MethodHead {
349+ release, ok := h.acquire(w, client)
350+ if !ok {
351+ return
352+ }
353+ defer release()
354+ }
355+ etag := setReleaseDownloadHeaders(w, a)
356+ if r.Header.Get("If-None-Match") == etag {
357+ w.WriteHeader(304)
358+ return
359+ }
360+ offset, length, partial, err := httprange.Select(r, a.Size, etag)
361+ if err != nil {
362+ w.Header().Set("Content-Range", httprange.Unsatisfied(a.Size))
363+ w.WriteHeader(416)
364+ return
365+ }
366+ status := 200
367+ if partial {
368+ status = 206
369+ w.Header().Set("Content-Range", httprange.ContentRange(offset, length, a.Size))
370+ }
371+ w.Header().Set("Content-Length", strconv.FormatInt(length, 10))
372+ if r.Method == http.MethodHead {
373+ w.WriteHeader(status)
374+ return
375+ }
376+ // Slot failures and pre-stream failures are JSON, not a binary response.
377+ clearHeaders := func() {
378+ for _, k := range []string{"Content-Length", "Content-Range", "Content-Disposition", "ETag", "Last-Modified", "Accept-Ranges", "Content-Security-Policy"} {
379+ w.Header().Del(k)
380+ }
381+ }
382+ writer := &idleWriter{w: w, rc: http.NewResponseController(w), idle: h.idle}
383+ started := false
384+ err = h.cfg.ReleaseDownload.DownloadAsset(ctx, p, a.Repo, ports.AssetRef{ID: id}, offset, length, func(chunk ports.AssetChunk) error {
385+ if len(chunk.Data) == 0 {
386+ return nil
387+ }
388+ if !started {
389+ w.WriteHeader(status)
390+ started = true
391+ }
392+ _, e := writer.Write(chunk.Data)
393+ return e
394+ })
395+ if err == nil {
396+ if !started {
397+ w.WriteHeader(status)
398+ }
399+ return
400+ }
401+ if started {
402+ log := zerowrap.FromCtx(ctx)
403+ log.Warn().Err(err).Msg("github release download cut short")
404+ panic(http.ErrAbortHandler)
405+ }
406+ clearHeaders()
407+ if errors.Is(err, domain.ErrOutOfRange) {
408+ w.Header().Set("Content-Range", "bytes */"+strconv.FormatInt(a.Size, 10))
409+ w.WriteHeader(416)
410+ return
411+ }
412+ h.failRelease(ctx, w, p, err, "release-asset", "")
413+}
A internal/adapters/githubapi/releases_test.go +466 -0
@@ -0,0 +1,466 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githubapi
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "net/http"
12+ "net/http/httptest"
13+ "net/url"
14+ "os"
15+ "strings"
16+ "testing"
17+ "time"
18+
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+ "bnema.dev/ermite/internal/ports/mocks"
22+ "github.com/stretchr/testify/mock"
23+ "github.com/stretchr/testify/require"
24+)
25+
26+const releasePath = "/repos/acme/proj/releases"
27+const releaseAssetPath = releasePath + "/assets/7"
28+const releaseUploadPath = "/uploads/repos/acme/proj/releases/4/assets"
29+
30+func releaseRequest(t *testing.T, h *Handler, method, path, body string, headers map[string]string) *httptest.ResponseRecorder {
31+ t.Helper()
32+ req := httptest.NewRequest(method, Prefix+path, strings.NewReader(body))
33+ for k, v := range headers {
34+ req.Header.Set(k, v)
35+ }
36+ w := httptest.NewRecorder()
37+ h.ServeHTTP(w, req)
38+ return w
39+}
40+func TestReleaseRoutes(t *testing.T) {
41+ cases := []struct{ path, kind, ref, allow string }{
42+ {releasePath, "release-list", "", "GET, HEAD, POST"},
43+ {releasePath + "/latest", "release-latest", "", "GET, HEAD"},
44+ {releasePath + "/tags/v1/patch", "release-tag", "v1/patch", "GET, HEAD"},
45+ {releasePath + "/4", "release-id", "4", "GET, HEAD, PATCH, DELETE"},
46+ {releasePath + "/4/assets", "release-assets", "4", "GET, HEAD"},
47+ {releaseAssetPath, "release-asset", "7", "GET, HEAD, PATCH, DELETE"},
48+ {releaseUploadPath, "release-upload", "4", "POST"},
49+ }
50+ for _, tc := range cases {
51+ t.Run(tc.kind, func(t *testing.T) {
52+ kind, owner, repo, ref, ok := route(tc.path)
53+ require.True(t, ok)
54+ require.Equal(t, tc.kind, kind)
55+ require.Equal(t, "acme", owner)
56+ require.Equal(t, "proj", repo)
57+ require.Equal(t, tc.ref, ref)
58+ api := mocks.NewGitHubReleaseAPI(t)
59+ h := New(Config{Releases: api})
60+ for _, method := range []string{"GET", "HEAD", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"} {
61+ if methodAllowed(method, tc.allow) {
62+ continue
63+ }
64+ w := releaseRequest(t, h, method, tc.path, "", nil)
65+ require.Equal(t, 405, w.Code)
66+ require.Equal(t, tc.allow, w.Header().Get("Allow"))
67+ }
68+ w := releaseRequest(t, New(Config{}), "GET", tc.path, "", nil)
69+ require.Equal(t, 404, w.Code)
70+ require.Equal(t, notFoundBody, w.Body.String())
71+ })
72+ }
73+ for _, path := range []string{releasePath + "/0", releasePath + "/-1", releasePath + "/01", releasePath + "/9223372036854775808", releasePath + "/tags/", releasePath + "/4/extra", releaseUploadPath + "/extra", "/uploads" + releasePath} {
74+ _, _, _, _, ok := route(path)
75+ require.False(t, ok, path)
76+ }
77+}
78+
79+// GoReleaser's go-github enterprise client appends api/v3 to the documented
80+// custom API URL. The alias must dispatch writes through the same auth/service.
81+func TestReleaseEnterpriseBase(t *testing.T) {
82+ api := mocks.NewGitHubReleaseAPI(t)
83+ h := New(Config{Releases: api})
84+ body := []byte(`{"tag_name":"v1"}`)
85+ api.EXPECT().CreateRelease(mock.Anything, domain.Anonymous(), "acme", "proj", body).Return([]byte(`{"id":4}`), nil).Once()
86+ w := releaseRequest(t, h, "POST", "/api/v3"+releasePath, string(body), nil)
87+ require.Equal(t, 201, w.Code)
88+ require.JSONEq(t, `{"id":4}`, w.Body.String())
89+ for _, path := range []string{releasePath, releasePath + "/4", releasePath + "/4/assets", releaseAssetPath, releasePath + "/latest", releasePath + "/tags/v1/patch"} {
90+ kind, owner, repo, ref, ok := route(path)
91+ aliasKind, aliasOwner, aliasRepo, aliasRef, aliasOK := route("/api/v3" + path)
92+ require.Equal(t, []any{kind, owner, repo, ref, ok}, []any{aliasKind, aliasOwner, aliasRepo, aliasRef, aliasOK})
93+ }
94+ _, _, _, _, ok := route("/api/v3repos/acme/proj/releases")
95+ require.False(t, ok)
96+}
97+
98+func TestReleaseDispatch(t *testing.T) {
99+ for _, method := range []string{"GET", "HEAD"} {
100+ t.Run(method, func(t *testing.T) {
101+ api := mocks.NewGitHubReleaseAPI(t)
102+ h := New(Config{Releases: api})
103+ api.EXPECT().ListReleases(mock.Anything, domain.Anonymous(), "acme", "proj", 2, 10).Return([]byte(`[]`), `<next>; rel="next"`, nil).Once()
104+ w := releaseRequest(t, h, method, releasePath+"?page=2&per_page=10", "", nil)
105+ require.Equal(t, 200, w.Code)
106+ require.Equal(t, `<next>; rel="next"`, w.Header().Get("Link"))
107+ require.Equal(t, "2", w.Header().Get("Content-Length"))
108+ if method == "HEAD" {
109+ require.Empty(t, w.Body.String())
110+ } else {
111+ require.Equal(t, "[]", w.Body.String())
112+ }
113+ for _, tc := range []struct {
114+ path string
115+ ref ports.ReleaseRef
116+ }{{releasePath + "/latest", ports.ReleaseRef{Latest: true}}, {releasePath + "/tags/v1/x", ports.ReleaseRef{Tag: "v1/x"}}, {releasePath + "/4", ports.ReleaseRef{ID: 4}}} {
117+ api.EXPECT().GetRelease(mock.Anything, domain.Anonymous(), "acme", "proj", tc.ref).Return([]byte(`{"id":4}`), nil).Once()
118+ w = releaseRequest(t, h, method, tc.path, "", nil)
119+ require.Equal(t, 200, w.Code)
120+ }
121+ api.EXPECT().ListAssets(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), 0, 0).Return([]byte(`[]`), "asset-link", nil).Once()
122+ w = releaseRequest(t, h, method, releasePath+"/4/assets", "", nil)
123+ require.Equal(t, 200, w.Code)
124+ require.Equal(t, "asset-link", w.Header().Get("Link"))
125+ api.EXPECT().GetAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return([]byte(`{"id":7}`), nil).Once()
126+ w = releaseRequest(t, h, method, releaseAssetPath, "", map[string]string{"Accept": "application/vnd.github+json"})
127+ require.Equal(t, 200, w.Code)
128+ })
129+ }
130+ api := mocks.NewGitHubReleaseAPI(t)
131+ h := New(Config{Releases: api})
132+ b := []byte(`{"tag_name":"v1"}`)
133+ api.EXPECT().CreateRelease(mock.Anything, domain.Anonymous(), "acme", "proj", b).Return([]byte(`{"id":4}`), nil).Once()
134+ w := releaseRequest(t, h, "POST", releasePath, string(b), nil)
135+ require.Equal(t, 201, w.Code)
136+ require.Equal(t, `{"id":4}`, w.Body.String())
137+ api.EXPECT().UpdateRelease(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), b).Return([]byte(`{"id":4}`), nil).Once()
138+ w = releaseRequest(t, h, "PATCH", releasePath+"/4", string(b), nil)
139+ require.Equal(t, 200, w.Code)
140+ api.EXPECT().UpdateAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7), b).Return([]byte(`{"id":7}`), nil).Once()
141+ w = releaseRequest(t, h, "PATCH", releaseAssetPath, string(b), nil)
142+ require.Equal(t, 200, w.Code)
143+ api.EXPECT().DeleteRelease(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4)).Return(nil).Once()
144+ w = releaseRequest(t, h, "DELETE", releasePath+"/4", "", nil)
145+ require.Equal(t, 204, w.Code)
146+ require.Empty(t, w.Body.String())
147+ api.EXPECT().DeleteAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(nil).Once()
148+ w = releaseRequest(t, h, "DELETE", releaseAssetPath, "", nil)
149+ require.Equal(t, 204, w.Code)
150+ require.Empty(t, w.Body.String())
151+}
152+func TestReleaseUpload(t *testing.T) {
153+ api := mocks.NewGitHubReleaseAPI(t)
154+ h := New(Config{Releases: api})
155+ req := httptest.NewRequest("POST", Prefix+releaseUploadPath+"?name=a", strings.NewReader("abc"))
156+ req.ContentLength = -1
157+ w := httptest.NewRecorder()
158+ h.ServeHTTP(w, req)
159+ require.Equal(t, 411, w.Code)
160+ require.Equal(t, `{"message":"Length Required"}`, w.Body.String())
161+ // HTTP distinguishes an omitted length from an explicitly empty asset.
162+ w = releaseRequest(t, h, "POST", releaseUploadPath+"?name=empty", "", nil)
163+ require.Equal(t, 411, w.Code)
164+ api.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), "empty", "", "application/octet-stream", int64(0), mock.Anything).Return([]byte(`{"id":8}`), nil).Once()
165+ w = releaseRequest(t, h, "POST", releaseUploadPath+"?name=empty", "", map[string]string{"Content-Length": "0"})
166+ require.Equal(t, 201, w.Code)
167+ w = releaseRequest(t, h, "POST", releaseUploadPath, "abc", nil)
168+ require.Equal(t, 422, w.Code)
169+ require.Equal(t, `{"message":"Validation Failed","errors":[{"resource":"ReleaseAsset","code":"invalid","field":"name"}]}`, w.Body.String())
170+ for _, ct := range []string{"", "text/plain"} {
171+ expectedType := ct
172+ if ct == "" {
173+ expectedType = "application/octet-stream"
174+ }
175+ api.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), "a b", "label", expectedType, int64(3), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _, _ string, _ int64, _, _, _ string, size int64, r io.Reader) ([]byte, error) {
176+ got, err := io.ReadAll(r)
177+ require.NoError(t, err)
178+ require.Equal(t, "abc", string(got))
179+ return []byte(`{"id":7}`), nil
180+ }).Once()
181+ req = httptest.NewRequest("POST", Prefix+releaseUploadPath+"?name=a%20b&label=label", strings.NewReader("abcdef"))
182+ req.ContentLength = 3
183+ req.Header.Set("Content-Type", ct)
184+ w = httptest.NewRecorder()
185+ h.ServeHTTP(w, req)
186+ require.Equal(t, 201, w.Code)
187+ require.Equal(t, `{"id":7}`, w.Body.String())
188+ }
189+ // The real response template expands to precisely the upload route used above.
190+ b, err := os.ReadFile("testdata/releases/release.json")
191+ require.NoError(t, err)
192+ var object struct {
193+ UploadURL string `json:"upload_url"`
194+ }
195+ require.NoError(t, json.Unmarshal(b, &object))
196+ expanded := strings.Split(object.UploadURL, "{")[0] + "?name=" + url.QueryEscape("archive.tar.gz")
197+ u, err := url.Parse(expanded)
198+ require.NoError(t, err)
199+ kind, owner, repo, ref, ok := route(strings.TrimPrefix(u.Path, Prefix))
200+ require.True(t, ok)
201+ require.Equal(t, "release-upload", kind)
202+ require.Equal(t, "acme", owner)
203+ require.Equal(t, "releases", repo)
204+ require.Equal(t, "1", ref)
205+}
206+func TestReleaseBodyLimit(t *testing.T) {
207+ h := New(Config{Releases: mocks.NewGitHubReleaseAPI(t)})
208+ for _, tc := range []struct{ method, path string }{{"POST", releasePath}, {"PATCH", releasePath + "/4"}, {"PATCH", releaseAssetPath}} {
209+ w := releaseRequest(t, h, tc.method, tc.path, strings.Repeat("x", (1<<20)+1), nil)
210+ require.Equal(t, 422, w.Code)
211+ require.Equal(t, `{"message":"Body too large"}`, w.Body.String())
212+ }
213+}
214+func TestReleaseErrors(t *testing.T) {
215+ rn, err := domain.ParseRepoName("proj")
216+ require.NoError(t, err)
217+ job := domain.RepoTokenPrincipal(domain.CIRepoToken{Repo: rn, Permissions: map[string]string{domain.PermContents: domain.PermissionRead}})
218+ cases := []struct {
219+ name, kind string
220+ p domain.Principal
221+ err error
222+ status int
223+ body string
224+ }{
225+ {"hidden draft", "release-id", domain.Anonymous(), domain.ErrNotFound, 404, notFoundBody},
226+ {"anonymous write", "release-list", domain.Anonymous(), domain.ErrUnauthenticated, 401, `{"message":"Bad credentials"}`},
227+ {"job read only", "release-list", job, domain.ErrForbidden, 403, `{"message":"Resource not accessible by integration"}`},
228+ {"stored token", "release-list", domain.Root(), domain.ErrForbidden, 403, `{"message":"Forbidden"}`},
229+ {"duplicate release", "release-list", domain.Root(), domain.ErrAlreadyExists, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"already_exists","field":"tag_name"}]}`},
230+ {"duplicate upload", "release-upload", domain.Root(), domain.ErrAlreadyExists, 422, `{"message":"Validation Failed","errors":[{"resource":"ReleaseAsset","code":"already_exists","field":"name"}],"documentation_url":"https://docs.github.com/rest/releases/assets#upload-a-release-asset"}`},
231+ {"invalid field", "release-list", domain.Root(), fmt.Errorf("%w: make_latest", domain.ErrInvalid), 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid","field":"make_latest"}]}`},
232+ {"invalid", "release-list", domain.Root(), domain.ErrInvalid, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid"}]}`},
233+ {"tag missing", "release-id", domain.Root(), domain.ErrPreconditionFailed, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid","field":"target_commitish"}]}`},
234+ {"size", "release-upload", domain.Root(), fmt.Errorf("%w: asset size limit", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: asset size limit"}`},
235+ {"count", "release-upload", domain.Root(), fmt.Errorf("%w: release asset count limit is 1000", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: release asset count limit is 1000"}`},
236+ {"quota", "release-upload", domain.Root(), fmt.Errorf("%w: release quota is 20 GiB", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: release quota is 20 GiB"}`},
237+ {"upload cap", "release-upload", domain.Root(), fmt.Errorf("%w: too many uploads in progress, retry", domain.ErrResourceExhausted), 429, `{"message":"resource exhausted: too many uploads in progress, retry"}`},
238+ {"internal", "release-id", domain.Root(), errors.New("secret internal error"), 500, `{"message":"Server Error"}`},
239+ }
240+ for _, tc := range cases {
241+ t.Run(tc.name, func(t *testing.T) {
242+ w := httptest.NewRecorder()
243+ New(Config{}).failRelease(t.Context(), w, tc.p, tc.err, tc.kind, "")
244+ require.Equal(t, tc.status, w.Code)
245+ require.Equal(t, tc.body, w.Body.String())
246+ if tc.status == 429 {
247+ require.Equal(t, retryAfter, w.Header().Get("Retry-After"))
248+ }
249+ })
250+ }
251+ // Verify the route actually forwards a use-case visibility failure unchanged.
252+ api := mocks.NewGitHubReleaseAPI(t)
253+ api.EXPECT().GetRelease(mock.Anything, domain.Anonymous(), "acme", "proj", ports.ReleaseRef{ID: 4}).Return(nil, domain.ErrNotFound).Once()
254+ w := releaseRequest(t, New(Config{Releases: api}), "GET", releasePath+"/4", "", nil)
255+ require.Equal(t, 404, w.Code)
256+ require.Equal(t, notFoundBody, w.Body.String())
257+}
258+func downloadAsset(t *testing.T) domain.ReleaseAsset {
259+ t.Helper()
260+ rn, err := domain.ParseRepoName("proj")
261+ require.NoError(t, err)
262+ return domain.ReleaseAsset{ID: 7, ReleaseID: 4, Repo: rn, Name: "archive.tar.gz", ContentType: "application/gzip", Size: 10, Digest: "sha256:abcdef", UpdatedAt: time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)}
263+}
264+func TestReleaseDownload(t *testing.T) {
265+ cases := []struct {
266+ name, method, rang, etag string
267+ status int
268+ offset, length int64
269+ body, contentRange string
270+ }{
271+ {"whole", "GET", "", "", 200, 0, 10, "0123456789", ""},
272+ {"bounded", "GET", "bytes=2-5", "", 206, 2, 4, "2345", "bytes 2-5/10"},
273+ {"open ended", "GET", "bytes=7-", "", 206, 7, 3, "789", "bytes 7-9/10"},
274+ {"suffix", "GET", "bytes=-3", "", 206, 7, 3, "789", "bytes 7-9/10"},
275+ {"large suffix", "GET", "bytes=-30", "", 206, 0, 10, "0123456789", "bytes 0-9/10"},
276+ {"clamp", "GET", "bytes=7-100", "", 206, 7, 3, "789", "bytes 7-9/10"},
277+ {"multiple ignored", "GET", "bytes=0-1,3-4", "", 200, 0, 10, "0123456789", ""},
278+ {"unknown unit ignored", "GET", "items=1-2", "", 200, 0, 10, "0123456789", ""},
279+ {"out of bounds", "GET", "bytes=10-", "", 416, 0, 0, "", "bytes */10"},
280+ {"reverse", "GET", "bytes=4-2", "", 416, 0, 0, "", "bytes */10"},
281+ {"invalid", "GET", "bytes=garbage", "", 416, 0, 0, "", "bytes */10"},
282+ {"zero suffix", "GET", "bytes=-0", "", 416, 0, 0, "", "bytes */10"},
283+ {"conditional", "GET", "", `"sha256-abcdef"`, 304, 0, 0, "", ""},
284+ {"head", "HEAD", "", "", 200, 0, 10, "", ""},
285+ {"head ignores range", "HEAD", "bytes=2-5", "", 200, 0, 10, "", ""},
286+ }
287+ for _, tc := range cases {
288+ t.Run(tc.name, func(t *testing.T) {
289+ api := mocks.NewGitHubReleaseAPI(t)
290+ dl := mocks.NewReleaseService(t)
291+ a := downloadAsset(t)
292+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
293+ if tc.method == "GET" && (tc.status == 200 || tc.status == 206) {
294+ dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, tc.offset, tc.length, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error {
295+ require.NoError(t, send(ports.AssetChunk{Asset: &a}))
296+ return send(ports.AssetChunk{Data: []byte("0123456789")[offset : offset+length]})
297+ }).Once()
298+ }
299+ w := releaseRequest(t, New(Config{Releases: api, ReleaseDownload: dl}), tc.method, releaseAssetPath, "", map[string]string{"Accept": "application/vnd.github+json, application/octet-stream", "Range": tc.rang, "If-None-Match": tc.etag})
300+ require.Equal(t, tc.status, w.Code)
301+ require.Equal(t, tc.body, w.Body.String())
302+ require.Equal(t, tc.contentRange, w.Header().Get("Content-Range"))
303+ require.Equal(t, "application/gzip", w.Header().Get("Content-Type"))
304+ require.Equal(t, `attachment; filename="archive.tar.gz"`, w.Header().Get("Content-Disposition"))
305+ require.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options"))
306+ require.Equal(t, "sandbox", w.Header().Get("Content-Security-Policy"))
307+ require.Equal(t, "bytes", w.Header().Get("Accept-Ranges"))
308+ require.Equal(t, `"sha256-abcdef"`, w.Header().Get("ETag"))
309+ require.Equal(t, "Wed, 21 Oct 2026 12:00:00 GMT", w.Header().Get("Last-Modified"))
310+ if tc.status == 200 || tc.status == 206 {
311+ require.Equal(t, fmt.Sprint(tc.length), w.Header().Get("Content-Length"))
312+ }
313+ })
314+ }
315+}
316+func TestReleaseDownloadSafety(t *testing.T) {
317+ for _, ct := range []string{"text/html", "text/html; charset=utf-8", "APPLICATION/JAVASCRIPT", "image/svg+xml"} {
318+ a := downloadAsset(t)
319+ a.ContentType = ct
320+ a.Name = "é space.txt"
321+ w := httptest.NewRecorder()
322+ setReleaseDownloadHeaders(w, a)
323+ require.Equal(t, "application/octet-stream", w.Header().Get("Content-Type"))
324+ require.Equal(t, `attachment; filename="_ space.txt"; filename*=UTF-8''%C3%A9%20space.txt`, w.Header().Get("Content-Disposition"))
325+ }
326+ require.Equal(t, `attachment; filename="evil.txt"`, releaseContentDisposition("\r\n/\\\"evil.txt"))
327+}
328+func TestReleaseDownloadFailures(t *testing.T) {
329+ for _, started := range []bool{false, true} {
330+ t.Run(fmt.Sprint(started), func(t *testing.T) {
331+ api := mocks.NewGitHubReleaseAPI(t)
332+ dl := mocks.NewReleaseService(t)
333+ a := downloadAsset(t)
334+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
335+ dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, int64(0), int64(10), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error {
336+ if started {
337+ require.NoError(t, send(ports.AssetChunk{Data: []byte("x")}))
338+ }
339+ return domain.ErrNotFound
340+ }).Once()
341+ h := New(Config{Releases: api, ReleaseDownload: dl})
342+ run := func() {
343+ w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
344+ require.Equal(t, 404, w.Code)
345+ require.Equal(t, notFoundBody, w.Body.String())
346+ require.Empty(t, w.Header().Get("Content-Length"))
347+ require.Empty(t, w.Header().Get("Content-Range"))
348+ }
349+ if started {
350+ require.PanicsWithValue(t, http.ErrAbortHandler, run)
351+ } else {
352+ run()
353+ }
354+ require.Empty(t, h.archives)
355+ require.Empty(t, h.clients)
356+ })
357+ }
358+}
359+func TestReleaseDownloadSlots(t *testing.T) {
360+ api := mocks.NewGitHubReleaseAPI(t)
361+ a := downloadAsset(t)
362+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Times(2)
363+ h := New(Config{Releases: api, ReleaseDownload: mocks.NewReleaseService(t), MaxArchives: 1, MaxArchivesPerClient: 1})
364+ release, ok := h.acquire(httptest.NewRecorder(), "192.0.2.1")
365+ require.True(t, ok)
366+ defer release()
367+ w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
368+ require.Equal(t, 429, w.Code)
369+ require.Equal(t, retryAfter, w.Header().Get("Retry-After"))
370+ require.Empty(t, w.Header().Get("Content-Length"))
371+ require.Contains(t, w.Body.String(), "Too many archives")
372+ // A HEAD uses metadata, even when every download slot is taken.
373+ w = releaseRequest(t, h, "HEAD", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
374+ require.Equal(t, 200, w.Code)
375+}
376+
377+func TestReleaseEmptyAndMissingDownload(t *testing.T) {
378+ for _, tc := range []struct {
379+ name string
380+ size int64
381+ err error
382+ want int
383+ }{
384+ {"empty", 0, nil, 200},
385+ {"missing empty", 0, domain.ErrNotFound, 404},
386+ {"store range", 10, domain.ErrOutOfRange, 416},
387+ } {
388+ t.Run(tc.name, func(t *testing.T) {
389+ api := mocks.NewGitHubReleaseAPI(t)
390+ dl := mocks.NewReleaseService(t)
391+ a := downloadAsset(t)
392+ a.Size = tc.size
393+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
394+ dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, int64(0), tc.size, mock.Anything).Return(tc.err).Once()
395+ w := releaseRequest(t, New(Config{Releases: api, ReleaseDownload: dl}), "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
396+ require.Equal(t, tc.want, w.Code)
397+ switch tc.want {
398+ case 200:
399+ require.Equal(t, "0", w.Header().Get("Content-Length"))
400+ require.Empty(t, w.Body.String())
401+ case 416:
402+ require.Equal(t, "bytes */10", w.Header().Get("Content-Range"))
403+ case 404:
404+ require.Equal(t, notFoundBody, w.Body.String())
405+ }
406+ })
407+ }
408+ // Missing the stream service leaves JSON available but bytes disabled.
409+ api := mocks.NewGitHubReleaseAPI(t)
410+ w := releaseRequest(t, New(Config{Releases: api}), "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
411+ require.Equal(t, 404, w.Code)
412+ require.Equal(t, notFoundBody, w.Body.String())
413+}
414+
415+func TestReleaseDownloadGlobalSlot(t *testing.T) {
416+ api := mocks.NewGitHubReleaseAPI(t)
417+ a := downloadAsset(t)
418+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
419+ h := New(Config{Releases: api, ReleaseDownload: mocks.NewReleaseService(t), MaxArchives: 1})
420+ release, ok := h.acquire(httptest.NewRecorder(), "another-client")
421+ require.True(t, ok)
422+ defer release()
423+ w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
424+ require.Equal(t, 503, w.Code)
425+ require.Equal(t, retryAfter, w.Header().Get("Retry-After"))
426+ require.Empty(t, w.Header().Get("Content-Length"))
427+ require.Contains(t, w.Body.String(), "Server busy")
428+ require.NotContains(t, h.clients, "192.0.2.1")
429+}
430+
431+func TestReleaseDownloadIfRange(t *testing.T) {
432+ for _, tc := range []struct {
433+ name, ifRange string
434+ status int
435+ offset int64
436+ length int64
437+ body, cr string
438+ }{
439+ {"matching", `"sha256-abcdef"`, 206, 2, 4, "2345", "bytes 2-5/10"},
440+ {"stale", `"sha256-other"`, 200, 0, 10, "0123456789", ""},
441+ {"date", "Wed, 21 Oct 2026 12:00:00 GMT", 200, 0, 10, "0123456789", ""},
442+ } {
443+ t.Run(tc.name, func(t *testing.T) {
444+ api := mocks.NewGitHubReleaseAPI(t)
445+ dl := mocks.NewReleaseService(t)
446+ a := downloadAsset(t)
447+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
448+ dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, tc.offset, tc.length, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error {
449+ require.NoError(t, send(ports.AssetChunk{Asset: &a}))
450+ return send(ports.AssetChunk{Data: []byte("0123456789")[offset : offset+length]})
451+ }).Once()
452+ w := releaseRequest(t, New(Config{Releases: api, ReleaseDownload: dl}), "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream", "Range": "bytes=2-5", "If-Range": tc.ifRange})
453+ require.Equal(t, tc.status, w.Code)
454+ require.Equal(t, tc.body, w.Body.String())
455+ require.Equal(t, tc.cr, w.Header().Get("Content-Range"))
456+ })
457+ }
458+}
459+
460+func TestReleaseAssetValidationResource(t *testing.T) {
461+ api := mocks.NewGitHubReleaseAPI(t)
462+ api.EXPECT().UpdateAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7), mock.Anything).Return(nil, fmt.Errorf("%w: asset name is empty", domain.ErrInvalid)).Once()
463+ w := releaseRequest(t, New(Config{Releases: api}), "PATCH", releaseAssetPath, `{"name":""}`, nil)
464+ require.Equal(t, 422, w.Code)
465+ require.Equal(t, `{"message":"Validation Failed","errors":[{"resource":"ReleaseAsset","code":"invalid","field":"name"}]}`, w.Body.String())
466+}
@@ -0,0 +1,466 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githubapi
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "net/http"
12+ "net/http/httptest"
13+ "net/url"
14+ "os"
15+ "strings"
16+ "testing"
17+ "time"
18+
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+ "bnema.dev/ermite/internal/ports/mocks"
22+ "github.com/stretchr/testify/mock"
23+ "github.com/stretchr/testify/require"
24+)
25+
26+const releasePath = "/repos/acme/proj/releases"
27+const releaseAssetPath = releasePath + "/assets/7"
28+const releaseUploadPath = "/uploads/repos/acme/proj/releases/4/assets"
29+
30+func releaseRequest(t *testing.T, h *Handler, method, path, body string, headers map[string]string) *httptest.ResponseRecorder {
31+ t.Helper()
32+ req := httptest.NewRequest(method, Prefix+path, strings.NewReader(body))
33+ for k, v := range headers {
34+ req.Header.Set(k, v)
35+ }
36+ w := httptest.NewRecorder()
37+ h.ServeHTTP(w, req)
38+ return w
39+}
40+func TestReleaseRoutes(t *testing.T) {
41+ cases := []struct{ path, kind, ref, allow string }{
42+ {releasePath, "release-list", "", "GET, HEAD, POST"},
43+ {releasePath + "/latest", "release-latest", "", "GET, HEAD"},
44+ {releasePath + "/tags/v1/patch", "release-tag", "v1/patch", "GET, HEAD"},
45+ {releasePath + "/4", "release-id", "4", "GET, HEAD, PATCH, DELETE"},
46+ {releasePath + "/4/assets", "release-assets", "4", "GET, HEAD"},
47+ {releaseAssetPath, "release-asset", "7", "GET, HEAD, PATCH, DELETE"},
48+ {releaseUploadPath, "release-upload", "4", "POST"},
49+ }
50+ for _, tc := range cases {
51+ t.Run(tc.kind, func(t *testing.T) {
52+ kind, owner, repo, ref, ok := route(tc.path)
53+ require.True(t, ok)
54+ require.Equal(t, tc.kind, kind)
55+ require.Equal(t, "acme", owner)
56+ require.Equal(t, "proj", repo)
57+ require.Equal(t, tc.ref, ref)
58+ api := mocks.NewGitHubReleaseAPI(t)
59+ h := New(Config{Releases: api})
60+ for _, method := range []string{"GET", "HEAD", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"} {
61+ if methodAllowed(method, tc.allow) {
62+ continue
63+ }
64+ w := releaseRequest(t, h, method, tc.path, "", nil)
65+ require.Equal(t, 405, w.Code)
66+ require.Equal(t, tc.allow, w.Header().Get("Allow"))
67+ }
68+ w := releaseRequest(t, New(Config{}), "GET", tc.path, "", nil)
69+ require.Equal(t, 404, w.Code)
70+ require.Equal(t, notFoundBody, w.Body.String())
71+ })
72+ }
73+ for _, path := range []string{releasePath + "/0", releasePath + "/-1", releasePath + "/01", releasePath + "/9223372036854775808", releasePath + "/tags/", releasePath + "/4/extra", releaseUploadPath + "/extra", "/uploads" + releasePath} {
74+ _, _, _, _, ok := route(path)
75+ require.False(t, ok, path)
76+ }
77+}
78+
79+// GoReleaser's go-github enterprise client appends api/v3 to the documented
80+// custom API URL. The alias must dispatch writes through the same auth/service.
81+func TestReleaseEnterpriseBase(t *testing.T) {
82+ api := mocks.NewGitHubReleaseAPI(t)
83+ h := New(Config{Releases: api})
84+ body := []byte(`{"tag_name":"v1"}`)
85+ api.EXPECT().CreateRelease(mock.Anything, domain.Anonymous(), "acme", "proj", body).Return([]byte(`{"id":4}`), nil).Once()
86+ w := releaseRequest(t, h, "POST", "/api/v3"+releasePath, string(body), nil)
87+ require.Equal(t, 201, w.Code)
88+ require.JSONEq(t, `{"id":4}`, w.Body.String())
89+ for _, path := range []string{releasePath, releasePath + "/4", releasePath + "/4/assets", releaseAssetPath, releasePath + "/latest", releasePath + "/tags/v1/patch"} {
90+ kind, owner, repo, ref, ok := route(path)
91+ aliasKind, aliasOwner, aliasRepo, aliasRef, aliasOK := route("/api/v3" + path)
92+ require.Equal(t, []any{kind, owner, repo, ref, ok}, []any{aliasKind, aliasOwner, aliasRepo, aliasRef, aliasOK})
93+ }
94+ _, _, _, _, ok := route("/api/v3repos/acme/proj/releases")
95+ require.False(t, ok)
96+}
97+
98+func TestReleaseDispatch(t *testing.T) {
99+ for _, method := range []string{"GET", "HEAD"} {
100+ t.Run(method, func(t *testing.T) {
101+ api := mocks.NewGitHubReleaseAPI(t)
102+ h := New(Config{Releases: api})
103+ api.EXPECT().ListReleases(mock.Anything, domain.Anonymous(), "acme", "proj", 2, 10).Return([]byte(`[]`), `<next>; rel="next"`, nil).Once()
104+ w := releaseRequest(t, h, method, releasePath+"?page=2&per_page=10", "", nil)
105+ require.Equal(t, 200, w.Code)
106+ require.Equal(t, `<next>; rel="next"`, w.Header().Get("Link"))
107+ require.Equal(t, "2", w.Header().Get("Content-Length"))
108+ if method == "HEAD" {
109+ require.Empty(t, w.Body.String())
110+ } else {
111+ require.Equal(t, "[]", w.Body.String())
112+ }
113+ for _, tc := range []struct {
114+ path string
115+ ref ports.ReleaseRef
116+ }{{releasePath + "/latest", ports.ReleaseRef{Latest: true}}, {releasePath + "/tags/v1/x", ports.ReleaseRef{Tag: "v1/x"}}, {releasePath + "/4", ports.ReleaseRef{ID: 4}}} {
117+ api.EXPECT().GetRelease(mock.Anything, domain.Anonymous(), "acme", "proj", tc.ref).Return([]byte(`{"id":4}`), nil).Once()
118+ w = releaseRequest(t, h, method, tc.path, "", nil)
119+ require.Equal(t, 200, w.Code)
120+ }
121+ api.EXPECT().ListAssets(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), 0, 0).Return([]byte(`[]`), "asset-link", nil).Once()
122+ w = releaseRequest(t, h, method, releasePath+"/4/assets", "", nil)
123+ require.Equal(t, 200, w.Code)
124+ require.Equal(t, "asset-link", w.Header().Get("Link"))
125+ api.EXPECT().GetAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return([]byte(`{"id":7}`), nil).Once()
126+ w = releaseRequest(t, h, method, releaseAssetPath, "", map[string]string{"Accept": "application/vnd.github+json"})
127+ require.Equal(t, 200, w.Code)
128+ })
129+ }
130+ api := mocks.NewGitHubReleaseAPI(t)
131+ h := New(Config{Releases: api})
132+ b := []byte(`{"tag_name":"v1"}`)
133+ api.EXPECT().CreateRelease(mock.Anything, domain.Anonymous(), "acme", "proj", b).Return([]byte(`{"id":4}`), nil).Once()
134+ w := releaseRequest(t, h, "POST", releasePath, string(b), nil)
135+ require.Equal(t, 201, w.Code)
136+ require.Equal(t, `{"id":4}`, w.Body.String())
137+ api.EXPECT().UpdateRelease(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), b).Return([]byte(`{"id":4}`), nil).Once()
138+ w = releaseRequest(t, h, "PATCH", releasePath+"/4", string(b), nil)
139+ require.Equal(t, 200, w.Code)
140+ api.EXPECT().UpdateAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7), b).Return([]byte(`{"id":7}`), nil).Once()
141+ w = releaseRequest(t, h, "PATCH", releaseAssetPath, string(b), nil)
142+ require.Equal(t, 200, w.Code)
143+ api.EXPECT().DeleteRelease(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4)).Return(nil).Once()
144+ w = releaseRequest(t, h, "DELETE", releasePath+"/4", "", nil)
145+ require.Equal(t, 204, w.Code)
146+ require.Empty(t, w.Body.String())
147+ api.EXPECT().DeleteAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(nil).Once()
148+ w = releaseRequest(t, h, "DELETE", releaseAssetPath, "", nil)
149+ require.Equal(t, 204, w.Code)
150+ require.Empty(t, w.Body.String())
151+}
152+func TestReleaseUpload(t *testing.T) {
153+ api := mocks.NewGitHubReleaseAPI(t)
154+ h := New(Config{Releases: api})
155+ req := httptest.NewRequest("POST", Prefix+releaseUploadPath+"?name=a", strings.NewReader("abc"))
156+ req.ContentLength = -1
157+ w := httptest.NewRecorder()
158+ h.ServeHTTP(w, req)
159+ require.Equal(t, 411, w.Code)
160+ require.Equal(t, `{"message":"Length Required"}`, w.Body.String())
161+ // HTTP distinguishes an omitted length from an explicitly empty asset.
162+ w = releaseRequest(t, h, "POST", releaseUploadPath+"?name=empty", "", nil)
163+ require.Equal(t, 411, w.Code)
164+ api.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), "empty", "", "application/octet-stream", int64(0), mock.Anything).Return([]byte(`{"id":8}`), nil).Once()
165+ w = releaseRequest(t, h, "POST", releaseUploadPath+"?name=empty", "", map[string]string{"Content-Length": "0"})
166+ require.Equal(t, 201, w.Code)
167+ w = releaseRequest(t, h, "POST", releaseUploadPath, "abc", nil)
168+ require.Equal(t, 422, w.Code)
169+ require.Equal(t, `{"message":"Validation Failed","errors":[{"resource":"ReleaseAsset","code":"invalid","field":"name"}]}`, w.Body.String())
170+ for _, ct := range []string{"", "text/plain"} {
171+ expectedType := ct
172+ if ct == "" {
173+ expectedType = "application/octet-stream"
174+ }
175+ api.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), "a b", "label", expectedType, int64(3), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _, _ string, _ int64, _, _, _ string, size int64, r io.Reader) ([]byte, error) {
176+ got, err := io.ReadAll(r)
177+ require.NoError(t, err)
178+ require.Equal(t, "abc", string(got))
179+ return []byte(`{"id":7}`), nil
180+ }).Once()
181+ req = httptest.NewRequest("POST", Prefix+releaseUploadPath+"?name=a%20b&label=label", strings.NewReader("abcdef"))
182+ req.ContentLength = 3
183+ req.Header.Set("Content-Type", ct)
184+ w = httptest.NewRecorder()
185+ h.ServeHTTP(w, req)
186+ require.Equal(t, 201, w.Code)
187+ require.Equal(t, `{"id":7}`, w.Body.String())
188+ }
189+ // The real response template expands to precisely the upload route used above.
190+ b, err := os.ReadFile("testdata/releases/release.json")
191+ require.NoError(t, err)
192+ var object struct {
193+ UploadURL string `json:"upload_url"`
194+ }
195+ require.NoError(t, json.Unmarshal(b, &object))
196+ expanded := strings.Split(object.UploadURL, "{")[0] + "?name=" + url.QueryEscape("archive.tar.gz")
197+ u, err := url.Parse(expanded)
198+ require.NoError(t, err)
199+ kind, owner, repo, ref, ok := route(strings.TrimPrefix(u.Path, Prefix))
200+ require.True(t, ok)
201+ require.Equal(t, "release-upload", kind)
202+ require.Equal(t, "acme", owner)
203+ require.Equal(t, "releases", repo)
204+ require.Equal(t, "1", ref)
205+}
206+func TestReleaseBodyLimit(t *testing.T) {
207+ h := New(Config{Releases: mocks.NewGitHubReleaseAPI(t)})
208+ for _, tc := range []struct{ method, path string }{{"POST", releasePath}, {"PATCH", releasePath + "/4"}, {"PATCH", releaseAssetPath}} {
209+ w := releaseRequest(t, h, tc.method, tc.path, strings.Repeat("x", (1<<20)+1), nil)
210+ require.Equal(t, 422, w.Code)
211+ require.Equal(t, `{"message":"Body too large"}`, w.Body.String())
212+ }
213+}
214+func TestReleaseErrors(t *testing.T) {
215+ rn, err := domain.ParseRepoName("proj")
216+ require.NoError(t, err)
217+ job := domain.RepoTokenPrincipal(domain.CIRepoToken{Repo: rn, Permissions: map[string]string{domain.PermContents: domain.PermissionRead}})
218+ cases := []struct {
219+ name, kind string
220+ p domain.Principal
221+ err error
222+ status int
223+ body string
224+ }{
225+ {"hidden draft", "release-id", domain.Anonymous(), domain.ErrNotFound, 404, notFoundBody},
226+ {"anonymous write", "release-list", domain.Anonymous(), domain.ErrUnauthenticated, 401, `{"message":"Bad credentials"}`},
227+ {"job read only", "release-list", job, domain.ErrForbidden, 403, `{"message":"Resource not accessible by integration"}`},
228+ {"stored token", "release-list", domain.Root(), domain.ErrForbidden, 403, `{"message":"Forbidden"}`},
229+ {"duplicate release", "release-list", domain.Root(), domain.ErrAlreadyExists, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"already_exists","field":"tag_name"}]}`},
230+ {"duplicate upload", "release-upload", domain.Root(), domain.ErrAlreadyExists, 422, `{"message":"Validation Failed","errors":[{"resource":"ReleaseAsset","code":"already_exists","field":"name"}],"documentation_url":"https://docs.github.com/rest/releases/assets#upload-a-release-asset"}`},
231+ {"invalid field", "release-list", domain.Root(), fmt.Errorf("%w: make_latest", domain.ErrInvalid), 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid","field":"make_latest"}]}`},
232+ {"invalid", "release-list", domain.Root(), domain.ErrInvalid, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid"}]}`},
233+ {"tag missing", "release-id", domain.Root(), domain.ErrPreconditionFailed, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid","field":"target_commitish"}]}`},
234+ {"size", "release-upload", domain.Root(), fmt.Errorf("%w: asset size limit", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: asset size limit"}`},
235+ {"count", "release-upload", domain.Root(), fmt.Errorf("%w: release asset count limit is 1000", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: release asset count limit is 1000"}`},
236+ {"quota", "release-upload", domain.Root(), fmt.Errorf("%w: release quota is 20 GiB", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: release quota is 20 GiB"}`},
237+ {"upload cap", "release-upload", domain.Root(), fmt.Errorf("%w: too many uploads in progress, retry", domain.ErrResourceExhausted), 429, `{"message":"resource exhausted: too many uploads in progress, retry"}`},
238+ {"internal", "release-id", domain.Root(), errors.New("secret internal error"), 500, `{"message":"Server Error"}`},
239+ }
240+ for _, tc := range cases {
241+ t.Run(tc.name, func(t *testing.T) {
242+ w := httptest.NewRecorder()
243+ New(Config{}).failRelease(t.Context(), w, tc.p, tc.err, tc.kind, "")
244+ require.Equal(t, tc.status, w.Code)
245+ require.Equal(t, tc.body, w.Body.String())
246+ if tc.status == 429 {
247+ require.Equal(t, retryAfter, w.Header().Get("Retry-After"))
248+ }
249+ })
250+ }
251+ // Verify the route actually forwards a use-case visibility failure unchanged.
252+ api := mocks.NewGitHubReleaseAPI(t)
253+ api.EXPECT().GetRelease(mock.Anything, domain.Anonymous(), "acme", "proj", ports.ReleaseRef{ID: 4}).Return(nil, domain.ErrNotFound).Once()
254+ w := releaseRequest(t, New(Config{Releases: api}), "GET", releasePath+"/4", "", nil)
255+ require.Equal(t, 404, w.Code)
256+ require.Equal(t, notFoundBody, w.Body.String())
257+}
258+func downloadAsset(t *testing.T) domain.ReleaseAsset {
259+ t.Helper()
260+ rn, err := domain.ParseRepoName("proj")
261+ require.NoError(t, err)
262+ return domain.ReleaseAsset{ID: 7, ReleaseID: 4, Repo: rn, Name: "archive.tar.gz", ContentType: "application/gzip", Size: 10, Digest: "sha256:abcdef", UpdatedAt: time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)}
263+}
264+func TestReleaseDownload(t *testing.T) {
265+ cases := []struct {
266+ name, method, rang, etag string
267+ status int
268+ offset, length int64
269+ body, contentRange string
270+ }{
271+ {"whole", "GET", "", "", 200, 0, 10, "0123456789", ""},
272+ {"bounded", "GET", "bytes=2-5", "", 206, 2, 4, "2345", "bytes 2-5/10"},
273+ {"open ended", "GET", "bytes=7-", "", 206, 7, 3, "789", "bytes 7-9/10"},
274+ {"suffix", "GET", "bytes=-3", "", 206, 7, 3, "789", "bytes 7-9/10"},
275+ {"large suffix", "GET", "bytes=-30", "", 206, 0, 10, "0123456789", "bytes 0-9/10"},
276+ {"clamp", "GET", "bytes=7-100", "", 206, 7, 3, "789", "bytes 7-9/10"},
277+ {"multiple ignored", "GET", "bytes=0-1,3-4", "", 200, 0, 10, "0123456789", ""},
278+ {"unknown unit ignored", "GET", "items=1-2", "", 200, 0, 10, "0123456789", ""},
279+ {"out of bounds", "GET", "bytes=10-", "", 416, 0, 0, "", "bytes */10"},
280+ {"reverse", "GET", "bytes=4-2", "", 416, 0, 0, "", "bytes */10"},
281+ {"invalid", "GET", "bytes=garbage", "", 416, 0, 0, "", "bytes */10"},
282+ {"zero suffix", "GET", "bytes=-0", "", 416, 0, 0, "", "bytes */10"},
283+ {"conditional", "GET", "", `"sha256-abcdef"`, 304, 0, 0, "", ""},
284+ {"head", "HEAD", "", "", 200, 0, 10, "", ""},
285+ {"head ignores range", "HEAD", "bytes=2-5", "", 200, 0, 10, "", ""},
286+ }
287+ for _, tc := range cases {
288+ t.Run(tc.name, func(t *testing.T) {
289+ api := mocks.NewGitHubReleaseAPI(t)
290+ dl := mocks.NewReleaseService(t)
291+ a := downloadAsset(t)
292+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
293+ if tc.method == "GET" && (tc.status == 200 || tc.status == 206) {
294+ dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, tc.offset, tc.length, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error {
295+ require.NoError(t, send(ports.AssetChunk{Asset: &a}))
296+ return send(ports.AssetChunk{Data: []byte("0123456789")[offset : offset+length]})
297+ }).Once()
298+ }
299+ w := releaseRequest(t, New(Config{Releases: api, ReleaseDownload: dl}), tc.method, releaseAssetPath, "", map[string]string{"Accept": "application/vnd.github+json, application/octet-stream", "Range": tc.rang, "If-None-Match": tc.etag})
300+ require.Equal(t, tc.status, w.Code)
301+ require.Equal(t, tc.body, w.Body.String())
302+ require.Equal(t, tc.contentRange, w.Header().Get("Content-Range"))
303+ require.Equal(t, "application/gzip", w.Header().Get("Content-Type"))
304+ require.Equal(t, `attachment; filename="archive.tar.gz"`, w.Header().Get("Content-Disposition"))
305+ require.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options"))
306+ require.Equal(t, "sandbox", w.Header().Get("Content-Security-Policy"))
307+ require.Equal(t, "bytes", w.Header().Get("Accept-Ranges"))
308+ require.Equal(t, `"sha256-abcdef"`, w.Header().Get("ETag"))
309+ require.Equal(t, "Wed, 21 Oct 2026 12:00:00 GMT", w.Header().Get("Last-Modified"))
310+ if tc.status == 200 || tc.status == 206 {
311+ require.Equal(t, fmt.Sprint(tc.length), w.Header().Get("Content-Length"))
312+ }
313+ })
314+ }
315+}
316+func TestReleaseDownloadSafety(t *testing.T) {
317+ for _, ct := range []string{"text/html", "text/html; charset=utf-8", "APPLICATION/JAVASCRIPT", "image/svg+xml"} {
318+ a := downloadAsset(t)
319+ a.ContentType = ct
320+ a.Name = "é space.txt"
321+ w := httptest.NewRecorder()
322+ setReleaseDownloadHeaders(w, a)
323+ require.Equal(t, "application/octet-stream", w.Header().Get("Content-Type"))
324+ require.Equal(t, `attachment; filename="_ space.txt"; filename*=UTF-8''%C3%A9%20space.txt`, w.Header().Get("Content-Disposition"))
325+ }
326+ require.Equal(t, `attachment; filename="evil.txt"`, releaseContentDisposition("\r\n/\\\"evil.txt"))
327+}
328+func TestReleaseDownloadFailures(t *testing.T) {
329+ for _, started := range []bool{false, true} {
330+ t.Run(fmt.Sprint(started), func(t *testing.T) {
331+ api := mocks.NewGitHubReleaseAPI(t)
332+ dl := mocks.NewReleaseService(t)
333+ a := downloadAsset(t)
334+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
335+ dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, int64(0), int64(10), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error {
336+ if started {
337+ require.NoError(t, send(ports.AssetChunk{Data: []byte("x")}))
338+ }
339+ return domain.ErrNotFound
340+ }).Once()
341+ h := New(Config{Releases: api, ReleaseDownload: dl})
342+ run := func() {
343+ w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
344+ require.Equal(t, 404, w.Code)
345+ require.Equal(t, notFoundBody, w.Body.String())
346+ require.Empty(t, w.Header().Get("Content-Length"))
347+ require.Empty(t, w.Header().Get("Content-Range"))
348+ }
349+ if started {
350+ require.PanicsWithValue(t, http.ErrAbortHandler, run)
351+ } else {
352+ run()
353+ }
354+ require.Empty(t, h.archives)
355+ require.Empty(t, h.clients)
356+ })
357+ }
358+}
359+func TestReleaseDownloadSlots(t *testing.T) {
360+ api := mocks.NewGitHubReleaseAPI(t)
361+ a := downloadAsset(t)
362+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Times(2)
363+ h := New(Config{Releases: api, ReleaseDownload: mocks.NewReleaseService(t), MaxArchives: 1, MaxArchivesPerClient: 1})
364+ release, ok := h.acquire(httptest.NewRecorder(), "192.0.2.1")
365+ require.True(t, ok)
366+ defer release()
367+ w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
368+ require.Equal(t, 429, w.Code)
369+ require.Equal(t, retryAfter, w.Header().Get("Retry-After"))
370+ require.Empty(t, w.Header().Get("Content-Length"))
371+ require.Contains(t, w.Body.String(), "Too many archives")
372+ // A HEAD uses metadata, even when every download slot is taken.
373+ w = releaseRequest(t, h, "HEAD", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
374+ require.Equal(t, 200, w.Code)
375+}
376+
377+func TestReleaseEmptyAndMissingDownload(t *testing.T) {
378+ for _, tc := range []struct {
379+ name string
380+ size int64
381+ err error
382+ want int
383+ }{
384+ {"empty", 0, nil, 200},
385+ {"missing empty", 0, domain.ErrNotFound, 404},
386+ {"store range", 10, domain.ErrOutOfRange, 416},
387+ } {
388+ t.Run(tc.name, func(t *testing.T) {
389+ api := mocks.NewGitHubReleaseAPI(t)
390+ dl := mocks.NewReleaseService(t)
391+ a := downloadAsset(t)
392+ a.Size = tc.size
393+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
394+ dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, int64(0), tc.size, mock.Anything).Return(tc.err).Once()
395+ w := releaseRequest(t, New(Config{Releases: api, ReleaseDownload: dl}), "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
396+ require.Equal(t, tc.want, w.Code)
397+ switch tc.want {
398+ case 200:
399+ require.Equal(t, "0", w.Header().Get("Content-Length"))
400+ require.Empty(t, w.Body.String())
401+ case 416:
402+ require.Equal(t, "bytes */10", w.Header().Get("Content-Range"))
403+ case 404:
404+ require.Equal(t, notFoundBody, w.Body.String())
405+ }
406+ })
407+ }
408+ // Missing the stream service leaves JSON available but bytes disabled.
409+ api := mocks.NewGitHubReleaseAPI(t)
410+ w := releaseRequest(t, New(Config{Releases: api}), "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
411+ require.Equal(t, 404, w.Code)
412+ require.Equal(t, notFoundBody, w.Body.String())
413+}
414+
415+func TestReleaseDownloadGlobalSlot(t *testing.T) {
416+ api := mocks.NewGitHubReleaseAPI(t)
417+ a := downloadAsset(t)
418+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
419+ h := New(Config{Releases: api, ReleaseDownload: mocks.NewReleaseService(t), MaxArchives: 1})
420+ release, ok := h.acquire(httptest.NewRecorder(), "another-client")
421+ require.True(t, ok)
422+ defer release()
423+ w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
424+ require.Equal(t, 503, w.Code)
425+ require.Equal(t, retryAfter, w.Header().Get("Retry-After"))
426+ require.Empty(t, w.Header().Get("Content-Length"))
427+ require.Contains(t, w.Body.String(), "Server busy")
428+ require.NotContains(t, h.clients, "192.0.2.1")
429+}
430+
431+func TestReleaseDownloadIfRange(t *testing.T) {
432+ for _, tc := range []struct {
433+ name, ifRange string
434+ status int
435+ offset int64
436+ length int64
437+ body, cr string
438+ }{
439+ {"matching", `"sha256-abcdef"`, 206, 2, 4, "2345", "bytes 2-5/10"},
440+ {"stale", `"sha256-other"`, 200, 0, 10, "0123456789", ""},
441+ {"date", "Wed, 21 Oct 2026 12:00:00 GMT", 200, 0, 10, "0123456789", ""},
442+ } {
443+ t.Run(tc.name, func(t *testing.T) {
444+ api := mocks.NewGitHubReleaseAPI(t)
445+ dl := mocks.NewReleaseService(t)
446+ a := downloadAsset(t)
447+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
448+ dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, tc.offset, tc.length, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error {
449+ require.NoError(t, send(ports.AssetChunk{Asset: &a}))
450+ return send(ports.AssetChunk{Data: []byte("0123456789")[offset : offset+length]})
451+ }).Once()
452+ w := releaseRequest(t, New(Config{Releases: api, ReleaseDownload: dl}), "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream", "Range": "bytes=2-5", "If-Range": tc.ifRange})
453+ require.Equal(t, tc.status, w.Code)
454+ require.Equal(t, tc.body, w.Body.String())
455+ require.Equal(t, tc.cr, w.Header().Get("Content-Range"))
456+ })
457+ }
458+}
459+
460+func TestReleaseAssetValidationResource(t *testing.T) {
461+ api := mocks.NewGitHubReleaseAPI(t)
462+ api.EXPECT().UpdateAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7), mock.Anything).Return(nil, fmt.Errorf("%w: asset name is empty", domain.ErrInvalid)).Once()
463+ w := releaseRequest(t, New(Config{Releases: api}), "PATCH", releaseAssetPath, `{"name":""}`, nil)
464+ require.Equal(t, 422, w.Code)
465+ require.Equal(t, `{"message":"Validation Failed","errors":[{"resource":"ReleaseAsset","code":"invalid","field":"name"}]}`, w.Body.String())
466+}
A internal/adapters/githubapi/testdata/releases/draft.json +40 -0
@@ -0,0 +1,40 @@
1+{
2+ "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/4",
3+ "assets_url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/4/assets",
4+ "upload_url": "http://localhost:7341/-/github/api/uploads/repos/acme/releases/releases/4/assets{?name,label}",
5+ "html_url": "http://localhost:7341/releases/releases/tag/next",
6+ "id": 4,
7+ "author": {
8+ "avatar_url": "",
9+ "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}",
10+ "followers_url": "http://localhost:7341/-/github/api/users/acme/followers",
11+ "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}",
12+ "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}",
13+ "gravatar_id": "",
14+ "html_url": "http://localhost:7341/acme",
15+ "id": 13112345534056,
16+ "login": "acme",
17+ "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2",
18+ "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs",
19+ "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events",
20+ "repos_url": "http://localhost:7341/-/github/api/users/acme/repos",
21+ "site_admin": false,
22+ "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}",
23+ "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions",
24+ "type": "User",
25+ "url": "http://localhost:7341/-/github/api/users/acme",
26+ "user_view_type": "public"
27+ },
28+ "node_id": "",
29+ "tag_name": "next",
30+ "target_commitish": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
31+ "name": "",
32+ "draft": true,
33+ "prerelease": false,
34+ "created_at": "2026-10-10T08:00:00Z",
35+ "published_at": null,
36+ "assets": [],
37+ "tarball_url": "http://localhost:7341/-/github/api/repos/acme/releases/tarball/next",
38+ "zipball_url": null,
39+ "body": ""
40+}
@@ -0,0 +1,40 @@
1+{
2+ "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/4",
3+ "assets_url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/4/assets",
4+ "upload_url": "http://localhost:7341/-/github/api/uploads/repos/acme/releases/releases/4/assets{?name,label}",
5+ "html_url": "http://localhost:7341/releases/releases/tag/next",
6+ "id": 4,
7+ "author": {
8+ "avatar_url": "",
9+ "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}",
10+ "followers_url": "http://localhost:7341/-/github/api/users/acme/followers",
11+ "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}",
12+ "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}",
13+ "gravatar_id": "",
14+ "html_url": "http://localhost:7341/acme",
15+ "id": 13112345534056,
16+ "login": "acme",
17+ "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2",
18+ "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs",
19+ "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events",
20+ "repos_url": "http://localhost:7341/-/github/api/users/acme/repos",
21+ "site_admin": false,
22+ "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}",
23+ "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions",
24+ "type": "User",
25+ "url": "http://localhost:7341/-/github/api/users/acme",
26+ "user_view_type": "public"
27+ },
28+ "node_id": "",
29+ "tag_name": "next",
30+ "target_commitish": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
31+ "name": "",
32+ "draft": true,
33+ "prerelease": false,
34+ "created_at": "2026-10-10T08:00:00Z",
35+ "published_at": null,
36+ "assets": [],
37+ "tarball_url": "http://localhost:7341/-/github/api/repos/acme/releases/tarball/next",
38+ "zipball_url": null,
39+ "body": ""
40+}
A internal/adapters/githubapi/testdata/releases/release.json +113 -0
@@ -0,0 +1,113 @@
1+{
2+ "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/1",
3+ "assets_url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/1/assets",
4+ "upload_url": "http://localhost:7341/-/github/api/uploads/repos/acme/releases/releases/1/assets{?name,label}",
5+ "html_url": "http://localhost:7341/releases/releases/tag/v1",
6+ "id": 1,
7+ "author": {
8+ "avatar_url": "",
9+ "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}",
10+ "followers_url": "http://localhost:7341/-/github/api/users/acme/followers",
11+ "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}",
12+ "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}",
13+ "gravatar_id": "",
14+ "html_url": "http://localhost:7341/acme",
15+ "id": 13112345534056,
16+ "login": "acme",
17+ "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2",
18+ "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs",
19+ "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events",
20+ "repos_url": "http://localhost:7341/-/github/api/users/acme/repos",
21+ "site_admin": false,
22+ "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}",
23+ "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions",
24+ "type": "User",
25+ "url": "http://localhost:7341/-/github/api/users/acme",
26+ "user_view_type": "public"
27+ },
28+ "node_id": "",
29+ "tag_name": "v1",
30+ "target_commitish": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
31+ "name": "",
32+ "draft": false,
33+ "prerelease": false,
34+ "created_at": "2026-10-10T08:00:00Z",
35+ "published_at": "2026-10-10T08:00:00Z",
36+ "assets": [
37+ {
38+ "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/assets/3",
39+ "id": 3,
40+ "node_id": "",
41+ "name": "checksums.txt",
42+ "label": "",
43+ "uploader": {
44+ "avatar_url": "",
45+ "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}",
46+ "followers_url": "http://localhost:7341/-/github/api/users/acme/followers",
47+ "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}",
48+ "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}",
49+ "gravatar_id": "",
50+ "html_url": "http://localhost:7341/acme",
51+ "id": 13112345534056,
52+ "login": "acme",
53+ "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2",
54+ "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs",
55+ "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events",
56+ "repos_url": "http://localhost:7341/-/github/api/users/acme/repos",
57+ "site_admin": false,
58+ "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}",
59+ "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions",
60+ "type": "User",
61+ "url": "http://localhost:7341/-/github/api/users/acme",
62+ "user_view_type": "public"
63+ },
64+ "content_type": "application/octet-stream",
65+ "state": "uploaded",
66+ "size": 3,
67+ "digest": "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
68+ "download_count": 0,
69+ "created_at": "2026-10-10T08:00:00Z",
70+ "updated_at": "2026-10-10T08:00:00Z",
71+ "browser_download_url": "http://localhost:7341/releases/releases/download/v1/checksums.txt"
72+ },
73+ {
74+ "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/assets/2",
75+ "id": 2,
76+ "node_id": "",
77+ "name": "ermite.tar.gz",
78+ "label": "",
79+ "uploader": {
80+ "avatar_url": "",
81+ "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}",
82+ "followers_url": "http://localhost:7341/-/github/api/users/acme/followers",
83+ "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}",
84+ "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}",
85+ "gravatar_id": "",
86+ "html_url": "http://localhost:7341/acme",
87+ "id": 13112345534056,
88+ "login": "acme",
89+ "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2",
90+ "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs",
91+ "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events",
92+ "repos_url": "http://localhost:7341/-/github/api/users/acme/repos",
93+ "site_admin": false,
94+ "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}",
95+ "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions",
96+ "type": "User",
97+ "url": "http://localhost:7341/-/github/api/users/acme",
98+ "user_view_type": "public"
99+ },
100+ "content_type": "application/octet-stream",
101+ "state": "uploaded",
102+ "size": 3,
103+ "digest": "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
104+ "download_count": 0,
105+ "created_at": "2026-10-10T08:00:00Z",
106+ "updated_at": "2026-10-10T08:00:00Z",
107+ "browser_download_url": "http://localhost:7341/releases/releases/download/v1/ermite.tar.gz"
108+ }
109+ ],
110+ "tarball_url": "http://localhost:7341/-/github/api/repos/acme/releases/tarball/v1",
111+ "zipball_url": null,
112+ "body": ""
113+}
@@ -0,0 +1,113 @@
1+{
2+ "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/1",
3+ "assets_url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/1/assets",
4+ "upload_url": "http://localhost:7341/-/github/api/uploads/repos/acme/releases/releases/1/assets{?name,label}",
5+ "html_url": "http://localhost:7341/releases/releases/tag/v1",
6+ "id": 1,
7+ "author": {
8+ "avatar_url": "",
9+ "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}",
10+ "followers_url": "http://localhost:7341/-/github/api/users/acme/followers",
11+ "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}",
12+ "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}",
13+ "gravatar_id": "",
14+ "html_url": "http://localhost:7341/acme",
15+ "id": 13112345534056,
16+ "login": "acme",
17+ "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2",
18+ "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs",
19+ "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events",
20+ "repos_url": "http://localhost:7341/-/github/api/users/acme/repos",
21+ "site_admin": false,
22+ "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}",
23+ "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions",
24+ "type": "User",
25+ "url": "http://localhost:7341/-/github/api/users/acme",
26+ "user_view_type": "public"
27+ },
28+ "node_id": "",
29+ "tag_name": "v1",
30+ "target_commitish": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
31+ "name": "",
32+ "draft": false,
33+ "prerelease": false,
34+ "created_at": "2026-10-10T08:00:00Z",
35+ "published_at": "2026-10-10T08:00:00Z",
36+ "assets": [
37+ {
38+ "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/assets/3",
39+ "id": 3,
40+ "node_id": "",
41+ "name": "checksums.txt",
42+ "label": "",
43+ "uploader": {
44+ "avatar_url": "",
45+ "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}",
46+ "followers_url": "http://localhost:7341/-/github/api/users/acme/followers",
47+ "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}",
48+ "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}",
49+ "gravatar_id": "",
50+ "html_url": "http://localhost:7341/acme",
51+ "id": 13112345534056,
52+ "login": "acme",
53+ "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2",
54+ "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs",
55+ "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events",
56+ "repos_url": "http://localhost:7341/-/github/api/users/acme/repos",
57+ "site_admin": false,
58+ "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}",
59+ "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions",
60+ "type": "User",
61+ "url": "http://localhost:7341/-/github/api/users/acme",
62+ "user_view_type": "public"
63+ },
64+ "content_type": "application/octet-stream",
65+ "state": "uploaded",
66+ "size": 3,
67+ "digest": "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
68+ "download_count": 0,
69+ "created_at": "2026-10-10T08:00:00Z",
70+ "updated_at": "2026-10-10T08:00:00Z",
71+ "browser_download_url": "http://localhost:7341/releases/releases/download/v1/checksums.txt"
72+ },
73+ {
74+ "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/assets/2",
75+ "id": 2,
76+ "node_id": "",
77+ "name": "ermite.tar.gz",
78+ "label": "",
79+ "uploader": {
80+ "avatar_url": "",
81+ "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}",
82+ "followers_url": "http://localhost:7341/-/github/api/users/acme/followers",
83+ "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}",
84+ "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}",
85+ "gravatar_id": "",
86+ "html_url": "http://localhost:7341/acme",
87+ "id": 13112345534056,
88+ "login": "acme",
89+ "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2",
90+ "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs",
91+ "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events",
92+ "repos_url": "http://localhost:7341/-/github/api/users/acme/repos",
93+ "site_admin": false,
94+ "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}",
95+ "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions",
96+ "type": "User",
97+ "url": "http://localhost:7341/-/github/api/users/acme",
98+ "user_view_type": "public"
99+ },
100+ "content_type": "application/octet-stream",
101+ "state": "uploaded",
102+ "size": 3,
103+ "digest": "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
104+ "download_count": 0,
105+ "created_at": "2026-10-10T08:00:00Z",
106+ "updated_at": "2026-10-10T08:00:00Z",
107+ "browser_download_url": "http://localhost:7341/releases/releases/download/v1/ermite.tar.gz"
108+ }
109+ ],
110+ "tarball_url": "http://localhost:7341/-/github/api/repos/acme/releases/tarball/v1",
111+ "zipball_url": null,
112+ "body": ""
113+}
M internal/adapters/kvstore/ci_artifact.go +2 -1
@@ -93,7 +93,7 @@ func encodeArtifact(a domain.CIArtifact) ([]byte, error) {
9393 Id: a.ID, Repo: a.Repo.String(), RunId: a.RunID, Attempt: a.Attempt, JobId: a.JobID,
9494 Name: a.Name, MediaType: a.MediaType, Size: a.Size, Digest: a.Digest,
9595 CreatedAt: encodeTime(a.CreatedAt), ExpiresAt: encodeTime(a.ExpiresAt),
96- Charged: a.Charged, ListDigest: a.ListDigest,
96+ Charged: a.Charged, ListDigest: a.ListDigest, Store: string(a.Store),
9797 }
9898 if a.Assignment != (domain.TokenHash{}) {
9999 pb.Assignment = a.Assignment[:]
@@ -123,6 +123,7 @@ func decodeArtifact(item kv.Item) (domain.CIArtifact, error) {
123123 Name: pb.GetName(), MediaType: pb.GetMediaType(), Size: pb.GetSize(), Digest: pb.GetDigest(),
124124 CreatedAt: decodeTime(pb.GetCreatedAt()), ExpiresAt: decodeTime(pb.GetExpiresAt()),
125125 Charged: pb.GetCharged(), ListDigest: pb.GetListDigest(), Version: item.Version,
126+ Store: domain.CIArtifactStore(pb.GetStore()),
126127 }
127128 copy(a.Assignment[:], pb.GetAssignment())
128129 for _, s := range artifactStates {
@@ -93,7 +93,7 @@ func encodeArtifact(a domain.CIArtifact) ([]byte, error) {
93 Id: a.ID, Repo: a.Repo.String(), RunId: a.RunID, Attempt: a.Attempt, JobId: a.JobID,93 Id: a.ID, Repo: a.Repo.String(), RunId: a.RunID, Attempt: a.Attempt, JobId: a.JobID,
94 Name: a.Name, MediaType: a.MediaType, Size: a.Size, Digest: a.Digest,94 Name: a.Name, MediaType: a.MediaType, Size: a.Size, Digest: a.Digest,
95 CreatedAt: encodeTime(a.CreatedAt), ExpiresAt: encodeTime(a.ExpiresAt),95 CreatedAt: encodeTime(a.CreatedAt), ExpiresAt: encodeTime(a.ExpiresAt),
96- Charged: a.Charged, ListDigest: a.ListDigest,96+ Charged: a.Charged, ListDigest: a.ListDigest, Store: string(a.Store),
97 }97 }
98 if a.Assignment != (domain.TokenHash{}) {98 if a.Assignment != (domain.TokenHash{}) {
99 pb.Assignment = a.Assignment[:]99 pb.Assignment = a.Assignment[:]
@@ -123,6 +123,7 @@ func decodeArtifact(item kv.Item) (domain.CIArtifact, error) {
123 Name: pb.GetName(), MediaType: pb.GetMediaType(), Size: pb.GetSize(), Digest: pb.GetDigest(),123 Name: pb.GetName(), MediaType: pb.GetMediaType(), Size: pb.GetSize(), Digest: pb.GetDigest(),
124 CreatedAt: decodeTime(pb.GetCreatedAt()), ExpiresAt: decodeTime(pb.GetExpiresAt()),124 CreatedAt: decodeTime(pb.GetCreatedAt()), ExpiresAt: decodeTime(pb.GetExpiresAt()),
125 Charged: pb.GetCharged(), ListDigest: pb.GetListDigest(), Version: item.Version,125 Charged: pb.GetCharged(), ListDigest: pb.GetListDigest(), Version: item.Version,
126+ Store: domain.CIArtifactStore(pb.GetStore()),
126 }127 }
127 copy(a.Assignment[:], pb.GetAssignment())128 copy(a.Assignment[:], pb.GetAssignment())
128 for _, s := range artifactStates {129 for _, s := range artifactStates {
M internal/adapters/kvstore/ci_artifact_test.go +5 -0
@@ -40,12 +40,17 @@ func TestCIArtifactsRoundTripAndIndexes(t *testing.T) {
4040 assert.Greater(t, id2, id1, "ids increase")
4141
4242 a := pendingArtifact(n, 7, id1, "dist")
43+ a.Store = domain.CIArtifactStoreBlob
4344 b := pendingArtifact(n, 7, id2, "logs")
4445 require.NoError(t, arts.ApplyArtifacts(ctx, ports.CIArtifactWrite{Repo: n, Put: []domain.CIArtifact{a, b}}))
4546
4647 got, err := arts.GetArtifact(ctx, id1)
4748 require.NoError(t, err)
4849 assert.Equal(t, "dist", got.Name)
50+ assert.Equal(t, domain.CIArtifactStoreBlob, got.Store)
51+ legacy, err := arts.GetArtifact(ctx, id2)
52+ require.NoError(t, err)
53+ assert.Equal(t, domain.CIArtifactStoreLegacy, legacy.Store, "an absent proto field keeps the legacy layout")
4954 assert.Equal(t, a.Assignment, got.Assignment)
5055 assert.Equal(t, domain.CIArtifactPending, got.State)
5156 assert.True(t, got.ExpiresAt.Equal(a.ExpiresAt))
@@ -40,12 +40,17 @@ func TestCIArtifactsRoundTripAndIndexes(t *testing.T) {
40 assert.Greater(t, id2, id1, "ids increase")40 assert.Greater(t, id2, id1, "ids increase")
41 41
42 a := pendingArtifact(n, 7, id1, "dist")42 a := pendingArtifact(n, 7, id1, "dist")
43+ a.Store = domain.CIArtifactStoreBlob
43 b := pendingArtifact(n, 7, id2, "logs")44 b := pendingArtifact(n, 7, id2, "logs")
44 require.NoError(t, arts.ApplyArtifacts(ctx, ports.CIArtifactWrite{Repo: n, Put: []domain.CIArtifact{a, b}}))45 require.NoError(t, arts.ApplyArtifacts(ctx, ports.CIArtifactWrite{Repo: n, Put: []domain.CIArtifact{a, b}}))
45 46
46 got, err := arts.GetArtifact(ctx, id1)47 got, err := arts.GetArtifact(ctx, id1)
47 require.NoError(t, err)48 require.NoError(t, err)
48 assert.Equal(t, "dist", got.Name)49 assert.Equal(t, "dist", got.Name)
50+ assert.Equal(t, domain.CIArtifactStoreBlob, got.Store)
51+ legacy, err := arts.GetArtifact(ctx, id2)
52+ require.NoError(t, err)
53+ assert.Equal(t, domain.CIArtifactStoreLegacy, legacy.Store, "an absent proto field keeps the legacy layout")
49 assert.Equal(t, a.Assignment, got.Assignment)54 assert.Equal(t, a.Assignment, got.Assignment)
50 assert.Equal(t, domain.CIArtifactPending, got.State)55 assert.Equal(t, domain.CIArtifactPending, got.State)
51 assert.True(t, got.ExpiresAt.Equal(a.ExpiresAt))56 assert.True(t, got.ExpiresAt.Equal(a.ExpiresAt))
M internal/adapters/kvstore/issues_test.go +16 -8
@@ -271,7 +271,8 @@ func TestRepoSettingsKeepTheIssuesMode(t *testing.T) {
271271
272272 // TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every
273273 // original scope keep their owner rights, gaining mr:review and issue:write
274-// (each only if missing), then mr:force-merge in schema 5; no other token changes.
274+// (each only if missing), then mr:force-merge in schema 5 and release:write in
275+// schema 6; no other token changes.
275276 func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
276277 ctx := context.Background()
277278 dir := t.TempDir()
@@ -309,9 +310,14 @@ func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
309310 full := get("full")
310311 assert.Contains(t, full.Scopes, domain.ScopeIssueWrite)
311312 assert.Contains(t, full.Scopes, domain.ScopeMRReview)
312- assert.Len(t, full.Scopes, 12)
313+ assert.Len(t, full.Scopes, 13)
314+ assert.Contains(t, full.Scopes, domain.ScopeReleaseWrite)
313315 full10 := get("full10")
314- assert.Len(t, full10.Scopes, 12)
316+ assert.Len(t, full10.Scopes, 13)
317+ assert.Contains(t, full10.Scopes, domain.ScopeReleaseWrite)
318+ for _, id := range []string{"repos", "prefix", "missing", "reposmr", "small"} {
319+ assert.NotContains(t, get(id).Scopes, domain.ScopeReleaseWrite)
320+ }
315321 assert.Equal(t, 1, countScope(full10.Scopes, domain.ScopeMRReview))
316322 assert.Contains(t, full10.Scopes, domain.ScopeIssueWrite)
317323 assert.True(t, domain.TokenPrincipal(full10).HasOwnerRights())
@@ -338,7 +344,7 @@ func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
338344 assert.Equal(t, "full", full.ID)
339345
340346 require.NoError(t, s.Close(ctx))
341- assert.Equal(t, uint32(5), readSchema(t, dir))
347+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
342348
343349 // A second open changes nothing.
344350 s2, err := kvstore.Open(ctx, dir)
@@ -358,7 +364,7 @@ func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) {
358364 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1}))
359365 _, err := kvstore.Open(ctx, dir)
360366 require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
361- assert.Equal(t, uint32(5), uint32(kvstore.SchemaVersion))
367+ assert.Equal(t, uint32(6), uint32(kvstore.SchemaVersion))
362368 }
363369
364370 // A reopen racing the delete of the repository either lands before the delete
@@ -571,7 +577,7 @@ func TestSchema5UpgradesOwnerEquivalentTokens(t *testing.T) {
571577 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 4}))
572578 var previous []string
573579 for _, sc := range domain.AllScopes() {
574- if sc != domain.ScopeMRForceMerge {
580+ if sc != domain.ScopeMRForceMerge && sc != domain.ScopeReleaseWrite {
575581 previous = append(previous, sc.String())
576582 }
577583 }
@@ -594,9 +600,11 @@ func TestSchema5UpgradesOwnerEquivalentTokens(t *testing.T) {
594600 if id == "owner" {
595601 assert.True(t, domain.TokenPrincipal(tok).HasOwnerRights())
596602 assert.Equal(t, 1, countScope(tok.Scopes, domain.ScopeMRForceMerge))
597- assert.Len(t, tok.Scopes, 12)
603+ assert.Equal(t, 1, countScope(tok.Scopes, domain.ScopeReleaseWrite))
604+ assert.Len(t, tok.Scopes, 13)
598605 } else {
599606 assert.NotContains(t, tok.Scopes, domain.ScopeMRForceMerge)
607+ assert.NotContains(t, tok.Scopes, domain.ScopeReleaseWrite)
600608 var got []string
601609 for _, sc := range tok.Scopes {
602610 got = append(got, sc.String())
@@ -605,6 +613,6 @@ func TestSchema5UpgradesOwnerEquivalentTokens(t *testing.T) {
605613 }
606614 }
607615 require.NoError(t, s.Close(ctx))
608- assert.Equal(t, uint32(5), readSchema(t, dir))
616+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
609617 }
610618 }
@@ -271,7 +271,8 @@ func TestRepoSettingsKeepTheIssuesMode(t *testing.T) {
271 271
272 // TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every272 // TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every
273 // original scope keep their owner rights, gaining mr:review and issue:write273 // original scope keep their owner rights, gaining mr:review and issue:write
274-// (each only if missing), then mr:force-merge in schema 5; no other token changes.274+// (each only if missing), then mr:force-merge in schema 5 and release:write in
275+// schema 6; no other token changes.
275 func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {276 func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
276 ctx := context.Background()277 ctx := context.Background()
277 dir := t.TempDir()278 dir := t.TempDir()
@@ -309,9 +310,14 @@ func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
309 full := get("full")310 full := get("full")
310 assert.Contains(t, full.Scopes, domain.ScopeIssueWrite)311 assert.Contains(t, full.Scopes, domain.ScopeIssueWrite)
311 assert.Contains(t, full.Scopes, domain.ScopeMRReview)312 assert.Contains(t, full.Scopes, domain.ScopeMRReview)
312- assert.Len(t, full.Scopes, 12)313+ assert.Len(t, full.Scopes, 13)
314+ assert.Contains(t, full.Scopes, domain.ScopeReleaseWrite)
313 full10 := get("full10")315 full10 := get("full10")
314- assert.Len(t, full10.Scopes, 12)316+ assert.Len(t, full10.Scopes, 13)
317+ assert.Contains(t, full10.Scopes, domain.ScopeReleaseWrite)
318+ for _, id := range []string{"repos", "prefix", "missing", "reposmr", "small"} {
319+ assert.NotContains(t, get(id).Scopes, domain.ScopeReleaseWrite)
320+ }
315 assert.Equal(t, 1, countScope(full10.Scopes, domain.ScopeMRReview))321 assert.Equal(t, 1, countScope(full10.Scopes, domain.ScopeMRReview))
316 assert.Contains(t, full10.Scopes, domain.ScopeIssueWrite)322 assert.Contains(t, full10.Scopes, domain.ScopeIssueWrite)
317 assert.True(t, domain.TokenPrincipal(full10).HasOwnerRights())323 assert.True(t, domain.TokenPrincipal(full10).HasOwnerRights())
@@ -338,7 +344,7 @@ func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
338 assert.Equal(t, "full", full.ID)344 assert.Equal(t, "full", full.ID)
339 345
340 require.NoError(t, s.Close(ctx))346 require.NoError(t, s.Close(ctx))
341- assert.Equal(t, uint32(5), readSchema(t, dir))347+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
342 348
343 // A second open changes nothing.349 // A second open changes nothing.
344 s2, err := kvstore.Open(ctx, dir)350 s2, err := kvstore.Open(ctx, dir)
@@ -358,7 +364,7 @@ func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) {
358 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1}))364 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1}))
359 _, err := kvstore.Open(ctx, dir)365 _, err := kvstore.Open(ctx, dir)
360 require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)366 require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
361- assert.Equal(t, uint32(5), uint32(kvstore.SchemaVersion))367+ assert.Equal(t, uint32(6), uint32(kvstore.SchemaVersion))
362 }368 }
363 369
364 // A reopen racing the delete of the repository either lands before the delete370 // A reopen racing the delete of the repository either lands before the delete
@@ -571,7 +577,7 @@ func TestSchema5UpgradesOwnerEquivalentTokens(t *testing.T) {
571 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 4}))577 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 4}))
572 var previous []string578 var previous []string
573 for _, sc := range domain.AllScopes() {579 for _, sc := range domain.AllScopes() {
574- if sc != domain.ScopeMRForceMerge {580+ if sc != domain.ScopeMRForceMerge && sc != domain.ScopeReleaseWrite {
575 previous = append(previous, sc.String())581 previous = append(previous, sc.String())
576 }582 }
577 }583 }
@@ -594,9 +600,11 @@ func TestSchema5UpgradesOwnerEquivalentTokens(t *testing.T) {
594 if id == "owner" {600 if id == "owner" {
595 assert.True(t, domain.TokenPrincipal(tok).HasOwnerRights())601 assert.True(t, domain.TokenPrincipal(tok).HasOwnerRights())
596 assert.Equal(t, 1, countScope(tok.Scopes, domain.ScopeMRForceMerge))602 assert.Equal(t, 1, countScope(tok.Scopes, domain.ScopeMRForceMerge))
597- assert.Len(t, tok.Scopes, 12)603+ assert.Equal(t, 1, countScope(tok.Scopes, domain.ScopeReleaseWrite))
604+ assert.Len(t, tok.Scopes, 13)
598 } else {605 } else {
599 assert.NotContains(t, tok.Scopes, domain.ScopeMRForceMerge)606 assert.NotContains(t, tok.Scopes, domain.ScopeMRForceMerge)
607+ assert.NotContains(t, tok.Scopes, domain.ScopeReleaseWrite)
600 var got []string608 var got []string
601 for _, sc := range tok.Scopes {609 for _, sc := range tok.Scopes {
602 got = append(got, sc.String())610 got = append(got, sc.String())
@@ -605,6 +613,6 @@ func TestSchema5UpgradesOwnerEquivalentTokens(t *testing.T) {
605 }613 }
606 }614 }
607 require.NoError(t, s.Close(ctx))615 require.NoError(t, s.Close(ctx))
608- assert.Equal(t, uint32(5), readSchema(t, dir))616+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
609 }617 }
610 }618 }
A internal/adapters/kvstore/releases.go +408 -0
@@ -0,0 +1,408 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package kvstore
3+
4+import (
5+ kv "bnema.dev/ermite-kv"
6+ storagepb "bnema.dev/ermite/gen/ermite/storage"
7+ "bnema.dev/ermite/internal/domain"
8+ "bnema.dev/ermite/internal/ports"
9+ "context"
10+ "errors"
11+ "fmt"
12+ "github.com/bnema/zerowrap"
13+ "google.golang.org/protobuf/proto"
14+ "slices"
15+ "strconv"
16+)
17+
18+// Releases implements ports.ReleaseStore over the shared metadata database.
19+type Releases struct{ db *kv.DB }
20+
21+var _ ports.ReleaseStore = (*Releases)(nil)
22+
23+// Releases returns the release metadata store.
24+func (s *Store) Releases() *Releases { return &Releases{db: s.db} }
25+func relKey(repo domain.RepoName, id int64) []byte {
26+ return []byte("rel/" + repo.String() + "/" + fmt.Sprintf(idFormat, id))
27+}
28+func relIDKey(id int64) []byte { return []byte("relid/" + fmt.Sprintf(idFormat, id)) }
29+func relTagKey(repo domain.RepoName, tag string) []byte {
30+ return []byte("reltag/" + repo.String() + "/" + tag)
31+}
32+func relAssetKey(id int64) []byte { return []byte("relasset/" + fmt.Sprintf(idFormat, id)) }
33+func relNamePrefix(repo domain.RepoName, id int64) string {
34+ return "relassets/" + repo.String() + "/" + fmt.Sprintf(idFormat, id) + "/"
35+}
36+func relNameKey(a domain.ReleaseAsset) []byte {
37+ return []byte(relNamePrefix(a.Repo, a.ReleaseID) + a.Name)
38+}
39+func relUseKey(repo domain.RepoName) []byte { return []byte("reluse/" + repo.String()) }
40+func encodeRelease(r domain.Release) []byte {
41+ return mustMarshal(&storagepb.Release{
42+ Id: r.ID,
43+ Repo: r.Repo.String(),
44+ Tag: r.Tag,
45+ Title: r.Title,
46+ Notes: r.Notes,
47+ Draft: r.Draft,
48+ Prerelease: r.Prerelease,
49+ MakeLatest: r.MakeLatest,
50+ TargetCommitish: r.TargetCommitish,
51+ Author: r.Author,
52+ CreatedAt: encodeTime(r.CreatedAt),
53+ PublishedAt: encodeTime(r.PublishedAt),
54+ UpdatedAt: encodeTime(r.UpdatedAt),
55+ })
56+}
57+func decodeRelease(item kv.Item) (domain.Release, error) {
58+ var pb storagepb.Release
59+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
60+ return domain.Release{}, err
61+ }
62+ repo, err := domain.ParseRepoName(pb.GetRepo())
63+ if err != nil {
64+ return domain.Release{}, err
65+ }
66+ return domain.Release{Version: item.Version,
67+ ID: pb.GetId(),
68+ Repo: repo,
69+ Tag: pb.GetTag(),
70+ Title: pb.GetTitle(),
71+ Notes: pb.GetNotes(),
72+ Draft: pb.GetDraft(),
73+ Prerelease: pb.GetPrerelease(),
74+ MakeLatest: pb.GetMakeLatest(),
75+ TargetCommitish: pb.GetTargetCommitish(),
76+ Author: pb.GetAuthor(),
77+ CreatedAt: decodeTime(pb.GetCreatedAt()),
78+ PublishedAt: decodeTime(pb.GetPublishedAt()),
79+ UpdatedAt: decodeTime(pb.GetUpdatedAt()),
80+ }, nil
81+}
82+func encodeReleaseAsset(r domain.ReleaseAsset) []byte {
83+ return mustMarshal(&storagepb.ReleaseAsset{
84+ Id: r.ID,
85+ ReleaseId: r.ReleaseID,
86+ Repo: r.Repo.String(),
87+ Name: r.Name,
88+ Label: r.Label,
89+ ContentType: r.ContentType,
90+ Size: r.Size,
91+ Digest: r.Digest,
92+ State: string(r.State),
93+ Uploader: r.Uploader,
94+ Charged: r.Charged,
95+ MultipartId: r.MultipartID,
96+ CreatedAt: encodeTime(r.CreatedAt),
97+ UpdatedAt: encodeTime(r.UpdatedAt),
98+ })
99+}
100+func decodeReleaseAsset(item kv.Item) (domain.ReleaseAsset, error) {
101+ var pb storagepb.ReleaseAsset
102+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
103+ return domain.ReleaseAsset{}, err
104+ }
105+ repo, err := domain.ParseRepoName(pb.GetRepo())
106+ if err != nil {
107+ return domain.ReleaseAsset{}, err
108+ }
109+ return domain.ReleaseAsset{Version: item.Version,
110+ ID: pb.GetId(),
111+ ReleaseID: pb.GetReleaseId(),
112+ Repo: repo,
113+ Name: pb.GetName(),
114+ Label: pb.GetLabel(),
115+ ContentType: pb.GetContentType(),
116+ Size: pb.GetSize(),
117+ Digest: pb.GetDigest(),
118+ State: domain.ReleaseAssetState(pb.GetState()),
119+ Uploader: pb.GetUploader(),
120+ Charged: pb.GetCharged(),
121+ MultipartID: pb.GetMultipartId(),
122+ CreatedAt: decodeTime(pb.GetCreatedAt()),
123+ UpdatedAt: decodeTime(pb.GetUpdatedAt()),
124+ }, nil
125+}
126+
127+// NextReleaseID allocates a global monotonically increasing id.
128+func (c *Releases) NextReleaseID(ctx context.Context) (int64, error) {
129+ for range artifactIDTries {
130+ last, item, ok, err := (&CI{db: c.db}).readSeq(ctx, []byte("relseq"))
131+ if err != nil {
132+ return 0, err
133+ }
134+ var b kv.Batch
135+ stageSeq(&b, []byte("relseq"), item, ok, last+1)
136+ _, err = c.db.Apply(ctx, &b)
137+ if errors.Is(err, kv.ErrConflict) {
138+ continue
139+ }
140+ if err != nil {
141+ return 0, err
142+ }
143+ return last + 1, nil
144+ }
145+ return 0, domain.ErrConflict
146+}
147+
148+// ReleaseUsage reads the repository's quota gate.
149+func (c *Releases) ReleaseUsage(ctx context.Context, repo domain.RepoName) (int64, uint64, error) {
150+ item, ok := c.db.Get(ctx, relUseKey(repo))
151+ if !ok {
152+ return 0, 0, nil
153+ }
154+ var pb storagepb.ReleaseUsage
155+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
156+ return 0, 0, err
157+ }
158+ return pb.GetUsed(), item.Version, nil
159+}
160+
161+// GetRelease reads a release in its repository.
162+func (c *Releases) GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error) {
163+ item, ok := c.db.Get(ctx, relKey(repo, id))
164+ if !ok {
165+ return domain.Release{}, domain.ErrNotFound
166+ }
167+ return decodeRelease(item)
168+}
169+
170+// ReleaseRepo resolves the global id index.
171+func (c *Releases) ReleaseRepo(ctx context.Context, id int64) (domain.RepoName, error) {
172+ item, ok := c.db.Get(ctx, relIDKey(id))
173+ if !ok {
174+ return domain.RepoName{}, domain.ErrNotFound
175+ }
176+ return domain.ParseRepoName(string(item.Value))
177+}
178+
179+// GetReleaseByTag resolves a repository tag.
180+func (c *Releases) GetReleaseByTag(ctx context.Context, repo domain.RepoName, tag string) (domain.Release, error) {
181+ snap := c.db.Snapshot(ctx)
182+ idx, ok := snap.Get(ctx, relTagKey(repo, tag))
183+ if !ok {
184+ return domain.Release{}, domain.ErrNotFound
185+ }
186+ id, err := strconv.ParseInt(string(idx.Value), 10, 64)
187+ if err != nil {
188+ return domain.Release{}, err
189+ }
190+ item, ok := snap.Get(ctx, relKey(repo, id))
191+ if !ok {
192+ return domain.Release{}, domain.ErrNotFound
193+ }
194+ return decodeRelease(item)
195+}
196+
197+// ListReleases returns all releases by descending id.
198+func (c *Releases) ListReleases(ctx context.Context, repo domain.RepoName) ([]domain.Release, error) {
199+ var out []domain.Release
200+ for _, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte("rel/"+repo.String()+"/")) {
201+ r, err := decodeRelease(item)
202+ if err != nil {
203+ return nil, err
204+ }
205+ out = append(out, r)
206+ }
207+ slices.Reverse(out)
208+ return out, nil
209+}
210+
211+// GetAsset reads asset metadata in every state.
212+func (c *Releases) GetAsset(ctx context.Context, id int64) (domain.ReleaseAsset, error) {
213+ item, ok := c.db.Get(ctx, relAssetKey(id))
214+ if !ok {
215+ return domain.ReleaseAsset{}, domain.ErrNotFound
216+ }
217+ return decodeReleaseAsset(item)
218+}
219+
220+// GetAssetByName resolves the unique name index.
221+func (c *Releases) GetAssetByName(ctx context.Context, repo domain.RepoName, releaseID int64, name string) (domain.ReleaseAsset, error) {
222+ snap := c.db.Snapshot(ctx)
223+ idx, ok := snap.Get(ctx, relNameKey(domain.ReleaseAsset{Repo: repo, ReleaseID: releaseID, Name: name}))
224+ if !ok {
225+ return domain.ReleaseAsset{}, domain.ErrNotFound
226+ }
227+ id, err := strconv.ParseInt(string(idx.Value), 10, 64)
228+ if err != nil {
229+ return domain.ReleaseAsset{}, err
230+ }
231+ item, ok := snap.Get(ctx, relAssetKey(id))
232+ if !ok {
233+ return domain.ReleaseAsset{}, domain.ErrNotFound
234+ }
235+ return decodeReleaseAsset(item)
236+}
237+
238+// ListAssets returns every state in name order.
239+func (c *Releases) ListAssets(ctx context.Context, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error) {
240+ snap := c.db.Snapshot(ctx)
241+ var out []domain.ReleaseAsset
242+ for _, idx := range snap.Prefix(ctx, []byte(relNamePrefix(repo, releaseID))) {
243+ id, err := strconv.ParseInt(string(idx.Value), 10, 64)
244+ if err != nil {
245+ return nil, err
246+ }
247+ item, ok := snap.Get(ctx, relAssetKey(id))
248+ if !ok {
249+ return nil, domain.ErrNotFound
250+ }
251+ a, err := decodeReleaseAsset(item)
252+ if err != nil {
253+ return nil, err
254+ }
255+ out = append(out, a)
256+ }
257+ return out, nil
258+}
259+
260+// AllAssets returns every asset for garbage collection.
261+func (c *Releases) AllAssets(ctx context.Context) ([]domain.ReleaseAsset, error) {
262+ var out []domain.ReleaseAsset
263+ for _, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte("relasset/")) {
264+ a, err := decodeReleaseAsset(item)
265+ if err != nil {
266+ return nil, err
267+ }
268+ out = append(out, a)
269+ }
270+ return out, nil
271+}
272+
273+// ApplyReleases atomically changes metadata and its quota gate.
274+//
275+//hexcheck:ignore suspicious-business-logic-in-adapter enforces storage version preconditions and unique secondary indexes, not release policy
276+func (c *Releases) ApplyReleases(ctx context.Context, w ports.ReleaseWrite) error {
277+ log := zerowrap.FromCtx(ctx)
278+ var b kv.Batch
279+ if err := guardRepoKey(ctx, c.db, &b, w.Repo); err != nil {
280+ return err
281+ }
282+ if w.UsageVersion == 0 {
283+ b.RequireAbsent(relUseKey(w.Repo))
284+ } else {
285+ b.RequireVersion(relUseKey(w.Repo), w.UsageVersion)
286+ }
287+ b.Put(relUseKey(w.Repo), mustMarshal(&storagepb.ReleaseUsage{Used: w.Used}))
288+ snap := c.db.Snapshot(ctx)
289+ for _, r := range w.DeleteReleases {
290+ if r.Repo != w.Repo || r.Version == 0 {
291+ return domain.ErrInvalid
292+ }
293+ b.RequireVersion(relKey(r.Repo, r.ID), r.Version)
294+ b.Delete(relKey(r.Repo, r.ID))
295+ b.Delete(relTagKey(r.Repo, r.Tag))
296+ b.Delete(relIDKey(r.ID))
297+ }
298+ for _, r := range w.DeleteAssets {
299+ if r.Repo != w.Repo || r.Version == 0 {
300+ return domain.ErrInvalid
301+ }
302+ b.RequireVersion(relAssetKey(r.ID), r.Version)
303+ b.Delete(relAssetKey(r.ID))
304+ b.Delete(relNameKey(r))
305+ }
306+ seenReleases := map[string]int64{}
307+ for _, r := range w.PutReleases {
308+ if r.Repo != w.Repo || r.ID < 1 {
309+ return domain.ErrInvalid
310+ }
311+ idxKey := relTagKey(r.Repo, r.Tag)
312+ if id, ok := seenReleases[string(idxKey)]; ok && id != r.ID {
313+ return domain.ErrAlreadyExists
314+ }
315+ seenReleases[string(idxKey)] = r.ID
316+ if item, ok := snap.Get(ctx, idxKey); ok && string(item.Value) != fmt.Sprintf(idFormat, r.ID) {
317+ removed := false
318+ for _, d := range w.DeleteReleases {
319+ if string(relTagKey(d.Repo, d.Tag)) == string(idxKey) {
320+ removed = true
321+ }
322+ }
323+ if !removed {
324+ return domain.ErrAlreadyExists
325+ }
326+ }
327+ if old, ok := snap.Get(ctx, relKey(r.Repo, r.ID)); ok {
328+ previous, err := decodeRelease(old)
329+ if err != nil {
330+ return err
331+ }
332+ oldKey := relTagKey(previous.Repo, previous.Tag)
333+ if string(oldKey) != string(idxKey) {
334+ b.Delete(oldKey)
335+ }
336+ }
337+ stage(&b, relKey(r.Repo, r.ID), encodeRelease(r), r.Version)
338+ b.Put(idxKey, []byte(fmt.Sprintf(idFormat, r.ID)))
339+ if r.Version == 0 {
340+ b.RequireAbsent(relIDKey(r.ID))
341+ }
342+ b.Put(relIDKey(r.ID), []byte(r.Repo.String()))
343+ }
344+ seenAssets := map[string]int64{}
345+ for _, r := range w.PutAssets {
346+ if r.Repo != w.Repo || r.ID < 1 {
347+ return domain.ErrInvalid
348+ }
349+ idxKey := relNameKey(r)
350+ if id, ok := seenAssets[string(idxKey)]; ok && id != r.ID {
351+ return domain.ErrAlreadyExists
352+ }
353+ seenAssets[string(idxKey)] = r.ID
354+ if item, ok := snap.Get(ctx, idxKey); ok && string(item.Value) != fmt.Sprintf(idFormat, r.ID) {
355+ removed := false
356+ for _, d := range w.DeleteAssets {
357+ if string(relNameKey(d)) == string(idxKey) {
358+ removed = true
359+ }
360+ }
361+ if !removed {
362+ return domain.ErrAlreadyExists
363+ }
364+ }
365+ if old, ok := snap.Get(ctx, relAssetKey(r.ID)); ok {
366+ previous, err := decodeReleaseAsset(old)
367+ if err != nil {
368+ return err
369+ }
370+ oldKey := relNameKey(previous)
371+ if string(oldKey) != string(idxKey) {
372+ b.Delete(oldKey)
373+ }
374+ }
375+ stage(&b, relAssetKey(r.ID), encodeReleaseAsset(r), r.Version)
376+ b.Put(idxKey, []byte(fmt.Sprintf(idFormat, r.ID)))
377+ }
378+ _, err := c.db.Apply(ctx, &b)
379+ if errors.Is(err, kv.ErrConflict) {
380+ return domain.ErrConflict
381+ }
382+ if err != nil {
383+ return fmt.Errorf("write releases: %w", err)
384+ }
385+ log.Debug().Str("repo", w.Repo.String()).Msg("release metadata updated")
386+ return nil
387+}
388+
389+// releaseKeys collects all repository-owned release keys, including global indexes.
390+func releaseKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
391+ keys := [][]byte{relUseKey(repo)}
392+ for _, prefix := range []string{"rel/", "reltag/", "relassets/"} {
393+ for key, item := range snap.Prefix(ctx, []byte(prefix+repo.String()+"/")) {
394+ keys = append(keys, slices.Clone(key))
395+ if prefix == "rel/" {
396+ if r, err := decodeRelease(item); err == nil {
397+ keys = append(keys, relIDKey(r.ID))
398+ }
399+ }
400+ }
401+ }
402+ for key, item := range snap.Prefix(ctx, []byte("relasset/")) {
403+ if a, err := decodeReleaseAsset(item); err == nil && a.Repo == repo {
404+ keys = append(keys, slices.Clone(key))
405+ }
406+ }
407+ return keys
408+}
@@ -0,0 +1,408 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package kvstore
3+
4+import (
5+ kv "bnema.dev/ermite-kv"
6+ storagepb "bnema.dev/ermite/gen/ermite/storage"
7+ "bnema.dev/ermite/internal/domain"
8+ "bnema.dev/ermite/internal/ports"
9+ "context"
10+ "errors"
11+ "fmt"
12+ "github.com/bnema/zerowrap"
13+ "google.golang.org/protobuf/proto"
14+ "slices"
15+ "strconv"
16+)
17+
18+// Releases implements ports.ReleaseStore over the shared metadata database.
19+type Releases struct{ db *kv.DB }
20+
21+var _ ports.ReleaseStore = (*Releases)(nil)
22+
23+// Releases returns the release metadata store.
24+func (s *Store) Releases() *Releases { return &Releases{db: s.db} }
25+func relKey(repo domain.RepoName, id int64) []byte {
26+ return []byte("rel/" + repo.String() + "/" + fmt.Sprintf(idFormat, id))
27+}
28+func relIDKey(id int64) []byte { return []byte("relid/" + fmt.Sprintf(idFormat, id)) }
29+func relTagKey(repo domain.RepoName, tag string) []byte {
30+ return []byte("reltag/" + repo.String() + "/" + tag)
31+}
32+func relAssetKey(id int64) []byte { return []byte("relasset/" + fmt.Sprintf(idFormat, id)) }
33+func relNamePrefix(repo domain.RepoName, id int64) string {
34+ return "relassets/" + repo.String() + "/" + fmt.Sprintf(idFormat, id) + "/"
35+}
36+func relNameKey(a domain.ReleaseAsset) []byte {
37+ return []byte(relNamePrefix(a.Repo, a.ReleaseID) + a.Name)
38+}
39+func relUseKey(repo domain.RepoName) []byte { return []byte("reluse/" + repo.String()) }
40+func encodeRelease(r domain.Release) []byte {
41+ return mustMarshal(&storagepb.Release{
42+ Id: r.ID,
43+ Repo: r.Repo.String(),
44+ Tag: r.Tag,
45+ Title: r.Title,
46+ Notes: r.Notes,
47+ Draft: r.Draft,
48+ Prerelease: r.Prerelease,
49+ MakeLatest: r.MakeLatest,
50+ TargetCommitish: r.TargetCommitish,
51+ Author: r.Author,
52+ CreatedAt: encodeTime(r.CreatedAt),
53+ PublishedAt: encodeTime(r.PublishedAt),
54+ UpdatedAt: encodeTime(r.UpdatedAt),
55+ })
56+}
57+func decodeRelease(item kv.Item) (domain.Release, error) {
58+ var pb storagepb.Release
59+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
60+ return domain.Release{}, err
61+ }
62+ repo, err := domain.ParseRepoName(pb.GetRepo())
63+ if err != nil {
64+ return domain.Release{}, err
65+ }
66+ return domain.Release{Version: item.Version,
67+ ID: pb.GetId(),
68+ Repo: repo,
69+ Tag: pb.GetTag(),
70+ Title: pb.GetTitle(),
71+ Notes: pb.GetNotes(),
72+ Draft: pb.GetDraft(),
73+ Prerelease: pb.GetPrerelease(),
74+ MakeLatest: pb.GetMakeLatest(),
75+ TargetCommitish: pb.GetTargetCommitish(),
76+ Author: pb.GetAuthor(),
77+ CreatedAt: decodeTime(pb.GetCreatedAt()),
78+ PublishedAt: decodeTime(pb.GetPublishedAt()),
79+ UpdatedAt: decodeTime(pb.GetUpdatedAt()),
80+ }, nil
81+}
82+func encodeReleaseAsset(r domain.ReleaseAsset) []byte {
83+ return mustMarshal(&storagepb.ReleaseAsset{
84+ Id: r.ID,
85+ ReleaseId: r.ReleaseID,
86+ Repo: r.Repo.String(),
87+ Name: r.Name,
88+ Label: r.Label,
89+ ContentType: r.ContentType,
90+ Size: r.Size,
91+ Digest: r.Digest,
92+ State: string(r.State),
93+ Uploader: r.Uploader,
94+ Charged: r.Charged,
95+ MultipartId: r.MultipartID,
96+ CreatedAt: encodeTime(r.CreatedAt),
97+ UpdatedAt: encodeTime(r.UpdatedAt),
98+ })
99+}
100+func decodeReleaseAsset(item kv.Item) (domain.ReleaseAsset, error) {
101+ var pb storagepb.ReleaseAsset
102+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
103+ return domain.ReleaseAsset{}, err
104+ }
105+ repo, err := domain.ParseRepoName(pb.GetRepo())
106+ if err != nil {
107+ return domain.ReleaseAsset{}, err
108+ }
109+ return domain.ReleaseAsset{Version: item.Version,
110+ ID: pb.GetId(),
111+ ReleaseID: pb.GetReleaseId(),
112+ Repo: repo,
113+ Name: pb.GetName(),
114+ Label: pb.GetLabel(),
115+ ContentType: pb.GetContentType(),
116+ Size: pb.GetSize(),
117+ Digest: pb.GetDigest(),
118+ State: domain.ReleaseAssetState(pb.GetState()),
119+ Uploader: pb.GetUploader(),
120+ Charged: pb.GetCharged(),
121+ MultipartID: pb.GetMultipartId(),
122+ CreatedAt: decodeTime(pb.GetCreatedAt()),
123+ UpdatedAt: decodeTime(pb.GetUpdatedAt()),
124+ }, nil
125+}
126+
127+// NextReleaseID allocates a global monotonically increasing id.
128+func (c *Releases) NextReleaseID(ctx context.Context) (int64, error) {
129+ for range artifactIDTries {
130+ last, item, ok, err := (&CI{db: c.db}).readSeq(ctx, []byte("relseq"))
131+ if err != nil {
132+ return 0, err
133+ }
134+ var b kv.Batch
135+ stageSeq(&b, []byte("relseq"), item, ok, last+1)
136+ _, err = c.db.Apply(ctx, &b)
137+ if errors.Is(err, kv.ErrConflict) {
138+ continue
139+ }
140+ if err != nil {
141+ return 0, err
142+ }
143+ return last + 1, nil
144+ }
145+ return 0, domain.ErrConflict
146+}
147+
148+// ReleaseUsage reads the repository's quota gate.
149+func (c *Releases) ReleaseUsage(ctx context.Context, repo domain.RepoName) (int64, uint64, error) {
150+ item, ok := c.db.Get(ctx, relUseKey(repo))
151+ if !ok {
152+ return 0, 0, nil
153+ }
154+ var pb storagepb.ReleaseUsage
155+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
156+ return 0, 0, err
157+ }
158+ return pb.GetUsed(), item.Version, nil
159+}
160+
161+// GetRelease reads a release in its repository.
162+func (c *Releases) GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error) {
163+ item, ok := c.db.Get(ctx, relKey(repo, id))
164+ if !ok {
165+ return domain.Release{}, domain.ErrNotFound
166+ }
167+ return decodeRelease(item)
168+}
169+
170+// ReleaseRepo resolves the global id index.
171+func (c *Releases) ReleaseRepo(ctx context.Context, id int64) (domain.RepoName, error) {
172+ item, ok := c.db.Get(ctx, relIDKey(id))
173+ if !ok {
174+ return domain.RepoName{}, domain.ErrNotFound
175+ }
176+ return domain.ParseRepoName(string(item.Value))
177+}
178+
179+// GetReleaseByTag resolves a repository tag.
180+func (c *Releases) GetReleaseByTag(ctx context.Context, repo domain.RepoName, tag string) (domain.Release, error) {
181+ snap := c.db.Snapshot(ctx)
182+ idx, ok := snap.Get(ctx, relTagKey(repo, tag))
183+ if !ok {
184+ return domain.Release{}, domain.ErrNotFound
185+ }
186+ id, err := strconv.ParseInt(string(idx.Value), 10, 64)
187+ if err != nil {
188+ return domain.Release{}, err
189+ }
190+ item, ok := snap.Get(ctx, relKey(repo, id))
191+ if !ok {
192+ return domain.Release{}, domain.ErrNotFound
193+ }
194+ return decodeRelease(item)
195+}
196+
197+// ListReleases returns all releases by descending id.
198+func (c *Releases) ListReleases(ctx context.Context, repo domain.RepoName) ([]domain.Release, error) {
199+ var out []domain.Release
200+ for _, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte("rel/"+repo.String()+"/")) {
201+ r, err := decodeRelease(item)
202+ if err != nil {
203+ return nil, err
204+ }
205+ out = append(out, r)
206+ }
207+ slices.Reverse(out)
208+ return out, nil
209+}
210+
211+// GetAsset reads asset metadata in every state.
212+func (c *Releases) GetAsset(ctx context.Context, id int64) (domain.ReleaseAsset, error) {
213+ item, ok := c.db.Get(ctx, relAssetKey(id))
214+ if !ok {
215+ return domain.ReleaseAsset{}, domain.ErrNotFound
216+ }
217+ return decodeReleaseAsset(item)
218+}
219+
220+// GetAssetByName resolves the unique name index.
221+func (c *Releases) GetAssetByName(ctx context.Context, repo domain.RepoName, releaseID int64, name string) (domain.ReleaseAsset, error) {
222+ snap := c.db.Snapshot(ctx)
223+ idx, ok := snap.Get(ctx, relNameKey(domain.ReleaseAsset{Repo: repo, ReleaseID: releaseID, Name: name}))
224+ if !ok {
225+ return domain.ReleaseAsset{}, domain.ErrNotFound
226+ }
227+ id, err := strconv.ParseInt(string(idx.Value), 10, 64)
228+ if err != nil {
229+ return domain.ReleaseAsset{}, err
230+ }
231+ item, ok := snap.Get(ctx, relAssetKey(id))
232+ if !ok {
233+ return domain.ReleaseAsset{}, domain.ErrNotFound
234+ }
235+ return decodeReleaseAsset(item)
236+}
237+
238+// ListAssets returns every state in name order.
239+func (c *Releases) ListAssets(ctx context.Context, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error) {
240+ snap := c.db.Snapshot(ctx)
241+ var out []domain.ReleaseAsset
242+ for _, idx := range snap.Prefix(ctx, []byte(relNamePrefix(repo, releaseID))) {
243+ id, err := strconv.ParseInt(string(idx.Value), 10, 64)
244+ if err != nil {
245+ return nil, err
246+ }
247+ item, ok := snap.Get(ctx, relAssetKey(id))
248+ if !ok {
249+ return nil, domain.ErrNotFound
250+ }
251+ a, err := decodeReleaseAsset(item)
252+ if err != nil {
253+ return nil, err
254+ }
255+ out = append(out, a)
256+ }
257+ return out, nil
258+}
259+
260+// AllAssets returns every asset for garbage collection.
261+func (c *Releases) AllAssets(ctx context.Context) ([]domain.ReleaseAsset, error) {
262+ var out []domain.ReleaseAsset
263+ for _, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte("relasset/")) {
264+ a, err := decodeReleaseAsset(item)
265+ if err != nil {
266+ return nil, err
267+ }
268+ out = append(out, a)
269+ }
270+ return out, nil
271+}
272+
273+// ApplyReleases atomically changes metadata and its quota gate.
274+//
275+//hexcheck:ignore suspicious-business-logic-in-adapter enforces storage version preconditions and unique secondary indexes, not release policy
276+func (c *Releases) ApplyReleases(ctx context.Context, w ports.ReleaseWrite) error {
277+ log := zerowrap.FromCtx(ctx)
278+ var b kv.Batch
279+ if err := guardRepoKey(ctx, c.db, &b, w.Repo); err != nil {
280+ return err
281+ }
282+ if w.UsageVersion == 0 {
283+ b.RequireAbsent(relUseKey(w.Repo))
284+ } else {
285+ b.RequireVersion(relUseKey(w.Repo), w.UsageVersion)
286+ }
287+ b.Put(relUseKey(w.Repo), mustMarshal(&storagepb.ReleaseUsage{Used: w.Used}))
288+ snap := c.db.Snapshot(ctx)
289+ for _, r := range w.DeleteReleases {
290+ if r.Repo != w.Repo || r.Version == 0 {
291+ return domain.ErrInvalid
292+ }
293+ b.RequireVersion(relKey(r.Repo, r.ID), r.Version)
294+ b.Delete(relKey(r.Repo, r.ID))
295+ b.Delete(relTagKey(r.Repo, r.Tag))
296+ b.Delete(relIDKey(r.ID))
297+ }
298+ for _, r := range w.DeleteAssets {
299+ if r.Repo != w.Repo || r.Version == 0 {
300+ return domain.ErrInvalid
301+ }
302+ b.RequireVersion(relAssetKey(r.ID), r.Version)
303+ b.Delete(relAssetKey(r.ID))
304+ b.Delete(relNameKey(r))
305+ }
306+ seenReleases := map[string]int64{}
307+ for _, r := range w.PutReleases {
308+ if r.Repo != w.Repo || r.ID < 1 {
309+ return domain.ErrInvalid
310+ }
311+ idxKey := relTagKey(r.Repo, r.Tag)
312+ if id, ok := seenReleases[string(idxKey)]; ok && id != r.ID {
313+ return domain.ErrAlreadyExists
314+ }
315+ seenReleases[string(idxKey)] = r.ID
316+ if item, ok := snap.Get(ctx, idxKey); ok && string(item.Value) != fmt.Sprintf(idFormat, r.ID) {
317+ removed := false
318+ for _, d := range w.DeleteReleases {
319+ if string(relTagKey(d.Repo, d.Tag)) == string(idxKey) {
320+ removed = true
321+ }
322+ }
323+ if !removed {
324+ return domain.ErrAlreadyExists
325+ }
326+ }
327+ if old, ok := snap.Get(ctx, relKey(r.Repo, r.ID)); ok {
328+ previous, err := decodeRelease(old)
329+ if err != nil {
330+ return err
331+ }
332+ oldKey := relTagKey(previous.Repo, previous.Tag)
333+ if string(oldKey) != string(idxKey) {
334+ b.Delete(oldKey)
335+ }
336+ }
337+ stage(&b, relKey(r.Repo, r.ID), encodeRelease(r), r.Version)
338+ b.Put(idxKey, []byte(fmt.Sprintf(idFormat, r.ID)))
339+ if r.Version == 0 {
340+ b.RequireAbsent(relIDKey(r.ID))
341+ }
342+ b.Put(relIDKey(r.ID), []byte(r.Repo.String()))
343+ }
344+ seenAssets := map[string]int64{}
345+ for _, r := range w.PutAssets {
346+ if r.Repo != w.Repo || r.ID < 1 {
347+ return domain.ErrInvalid
348+ }
349+ idxKey := relNameKey(r)
350+ if id, ok := seenAssets[string(idxKey)]; ok && id != r.ID {
351+ return domain.ErrAlreadyExists
352+ }
353+ seenAssets[string(idxKey)] = r.ID
354+ if item, ok := snap.Get(ctx, idxKey); ok && string(item.Value) != fmt.Sprintf(idFormat, r.ID) {
355+ removed := false
356+ for _, d := range w.DeleteAssets {
357+ if string(relNameKey(d)) == string(idxKey) {
358+ removed = true
359+ }
360+ }
361+ if !removed {
362+ return domain.ErrAlreadyExists
363+ }
364+ }
365+ if old, ok := snap.Get(ctx, relAssetKey(r.ID)); ok {
366+ previous, err := decodeReleaseAsset(old)
367+ if err != nil {
368+ return err
369+ }
370+ oldKey := relNameKey(previous)
371+ if string(oldKey) != string(idxKey) {
372+ b.Delete(oldKey)
373+ }
374+ }
375+ stage(&b, relAssetKey(r.ID), encodeReleaseAsset(r), r.Version)
376+ b.Put(idxKey, []byte(fmt.Sprintf(idFormat, r.ID)))
377+ }
378+ _, err := c.db.Apply(ctx, &b)
379+ if errors.Is(err, kv.ErrConflict) {
380+ return domain.ErrConflict
381+ }
382+ if err != nil {
383+ return fmt.Errorf("write releases: %w", err)
384+ }
385+ log.Debug().Str("repo", w.Repo.String()).Msg("release metadata updated")
386+ return nil
387+}
388+
389+// releaseKeys collects all repository-owned release keys, including global indexes.
390+func releaseKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
391+ keys := [][]byte{relUseKey(repo)}
392+ for _, prefix := range []string{"rel/", "reltag/", "relassets/"} {
393+ for key, item := range snap.Prefix(ctx, []byte(prefix+repo.String()+"/")) {
394+ keys = append(keys, slices.Clone(key))
395+ if prefix == "rel/" {
396+ if r, err := decodeRelease(item); err == nil {
397+ keys = append(keys, relIDKey(r.ID))
398+ }
399+ }
400+ }
401+ }
402+ for key, item := range snap.Prefix(ctx, []byte("relasset/")) {
403+ if a, err := decodeReleaseAsset(item); err == nil && a.Repo == repo {
404+ keys = append(keys, slices.Clone(key))
405+ }
406+ }
407+ return keys
408+}
A internal/adapters/kvstore/releases_test.go +131 -0
@@ -0,0 +1,131 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package kvstore_test
3+
4+import (
5+ "context"
6+ "sync"
7+ "testing"
8+
9+ kv "bnema.dev/ermite-kv"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+func TestReleaseStorage(t *testing.T) {
17+ ctx := context.Background()
18+ dir := t.TempDir()
19+ s := openStore(t, dir)
20+ repo := name(t, "release-test")
21+ require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()}))
22+ st := s.Releases()
23+ id, err := st.NextReleaseID(ctx)
24+ require.NoError(t, err)
25+ r := domain.Release{ID: id, Repo: repo, Tag: "v1", Title: "title", Notes: "notes", MakeLatest: true, TargetCommitish: "abc", Author: "owner", CreatedAt: created, PublishedAt: created, UpdatedAt: created}
26+ assetID, err := st.NextReleaseID(ctx)
27+ require.NoError(t, err)
28+ a := domain.ReleaseAsset{ID: assetID, ReleaseID: id, Repo: repo, Name: "binary", Label: "label", ContentType: "application/octet-stream", Size: 3, Digest: "sha256:abc", State: domain.ReleaseAssetUploading, Uploader: "owner", Charged: 3, MultipartID: "multipart", CreatedAt: created, UpdatedAt: created}
29+ w := ports.ReleaseWrite{Repo: repo, Used: 3, PutReleases: []domain.Release{r}, PutAssets: []domain.ReleaseAsset{a}}
30+ require.NoError(t, st.ApplyReleases(ctx, w))
31+ got, err := st.GetReleaseByTag(ctx, repo, "v1")
32+ require.NoError(t, err)
33+ r.Version = got.Version
34+ require.Equal(t, r, got)
35+ ar, err := st.GetAssetByName(ctx, repo, id, "binary")
36+ require.NoError(t, err)
37+ a.Version = ar.Version
38+ require.Equal(t, a, ar)
39+ rr, err := st.ReleaseRepo(ctx, id)
40+ require.NoError(t, err)
41+ require.Equal(t, repo, rr)
42+ rs, err := st.ListReleases(ctx, repo)
43+ require.NoError(t, err)
44+ require.Equal(t, []domain.Release{r}, rs)
45+ as, err := st.ListAssets(ctx, repo, id)
46+ require.NoError(t, err)
47+ require.Equal(t, []domain.ReleaseAsset{a}, as)
48+ all, err := st.AllAssets(ctx)
49+ require.NoError(t, err)
50+ require.Equal(t, as, all)
51+ require.ErrorIs(t, st.ApplyReleases(ctx, w), domain.ErrConflict)
52+ used, version, err := st.ReleaseUsage(ctx, repo)
53+ require.NoError(t, err)
54+ require.EqualValues(t, 3, used)
55+ duplicate := r
56+ duplicate.ID += 10
57+ duplicate.Version = 0
58+ require.ErrorIs(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, PutReleases: []domain.Release{duplicate}}), domain.ErrAlreadyExists)
59+ da := a
60+ da.ID += 10
61+ da.Version = 0
62+ require.ErrorIs(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, PutAssets: []domain.ReleaseAsset{da}}), domain.ErrAlreadyExists)
63+ stale := r
64+ stale.Version++
65+ require.ErrorIs(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, PutReleases: []domain.Release{stale}}), domain.ErrConflict)
66+ staleAsset := a
67+ staleAsset.Version++
68+ require.ErrorIs(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, DeleteAssets: []domain.ReleaseAsset{staleAsset}}), domain.ErrConflict)
69+ r.Tag = "v2"
70+ a.Name = "renamed"
71+ require.NoError(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, Used: 3, PutReleases: []domain.Release{r}, PutAssets: []domain.ReleaseAsset{a}}))
72+ _, err = st.GetReleaseByTag(ctx, repo, "v1")
73+ require.ErrorIs(t, err, domain.ErrNotFound)
74+ _, err = st.GetAssetByName(ctx, repo, id, "binary")
75+ require.ErrorIs(t, err, domain.ErrNotFound)
76+ renamedRelease, err := st.GetReleaseByTag(ctx, repo, "v2")
77+ require.NoError(t, err)
78+ renamedAsset, err := st.GetAssetByName(ctx, repo, id, "renamed")
79+ require.NoError(t, err)
80+ _, version, err = st.ReleaseUsage(ctx, repo)
81+ require.NoError(t, err)
82+ require.NoError(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, DeleteReleases: []domain.Release{renamedRelease}, DeleteAssets: []domain.ReleaseAsset{renamedAsset}}))
83+ _, err = st.GetRelease(ctx, repo, id)
84+ require.ErrorIs(t, err, domain.ErrNotFound)
85+ _, err = st.GetAsset(ctx, a.ID)
86+ require.ErrorIs(t, err, domain.ErrNotFound)
87+ _, version, err = st.ReleaseUsage(ctx, repo)
88+ require.NoError(t, err)
89+ r.Version, a.Version = 0, 0
90+ require.NoError(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, Used: 3, PutReleases: []domain.Release{r}, PutAssets: []domain.ReleaseAsset{a}}))
91+ require.NoError(t, s.Delete(ctx, repo))
92+ _, err = st.GetRelease(ctx, repo, id)
93+ require.ErrorIs(t, err, domain.ErrNotFound)
94+ _, err = st.ReleaseRepo(ctx, id)
95+ require.ErrorIs(t, err, domain.ErrNotFound)
96+ _, err = st.GetAsset(ctx, a.ID)
97+ require.ErrorIs(t, err, domain.ErrNotFound)
98+ all, err = st.AllAssets(ctx)
99+ require.NoError(t, err)
100+ require.Empty(t, all)
101+ used, version, err = st.ReleaseUsage(ctx, repo)
102+ require.NoError(t, err)
103+ require.Zero(t, used)
104+ require.Zero(t, version)
105+ require.ErrorIs(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo}), domain.ErrNotFound)
106+ require.NoError(t, s.Close(ctx))
107+ db, err := kv.Open(ctx, dir, kv.Options{})
108+ require.NoError(t, err)
109+ defer func() { _ = db.Close(ctx) }()
110+ for _, prefix := range []string{"rel/", "reltag/", "relid/", "relasset/", "relassets/", "reluse/"} {
111+ for key := range db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
112+ t.Errorf("repository deletion left release key %q", key)
113+ }
114+ }
115+}
116+func TestConcurrentReleaseIDs(t *testing.T) {
117+ st := openStore(t, t.TempDir()).Releases()
118+ var wg sync.WaitGroup
119+ ids := make(chan int64, 32)
120+ for range 32 {
121+ wg.Go(func() { id, err := st.NextReleaseID(context.Background()); require.NoError(t, err); ids <- id })
122+ }
123+ wg.Wait()
124+ close(ids)
125+ seen := map[int64]bool{}
126+ for id := range ids {
127+ require.False(t, seen[id])
128+ seen[id] = true
129+ }
130+ require.Len(t, seen, 32)
131+}
@@ -0,0 +1,131 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package kvstore_test
3+
4+import (
5+ "context"
6+ "sync"
7+ "testing"
8+
9+ kv "bnema.dev/ermite-kv"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+func TestReleaseStorage(t *testing.T) {
17+ ctx := context.Background()
18+ dir := t.TempDir()
19+ s := openStore(t, dir)
20+ repo := name(t, "release-test")
21+ require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()}))
22+ st := s.Releases()
23+ id, err := st.NextReleaseID(ctx)
24+ require.NoError(t, err)
25+ r := domain.Release{ID: id, Repo: repo, Tag: "v1", Title: "title", Notes: "notes", MakeLatest: true, TargetCommitish: "abc", Author: "owner", CreatedAt: created, PublishedAt: created, UpdatedAt: created}
26+ assetID, err := st.NextReleaseID(ctx)
27+ require.NoError(t, err)
28+ a := domain.ReleaseAsset{ID: assetID, ReleaseID: id, Repo: repo, Name: "binary", Label: "label", ContentType: "application/octet-stream", Size: 3, Digest: "sha256:abc", State: domain.ReleaseAssetUploading, Uploader: "owner", Charged: 3, MultipartID: "multipart", CreatedAt: created, UpdatedAt: created}
29+ w := ports.ReleaseWrite{Repo: repo, Used: 3, PutReleases: []domain.Release{r}, PutAssets: []domain.ReleaseAsset{a}}
30+ require.NoError(t, st.ApplyReleases(ctx, w))
31+ got, err := st.GetReleaseByTag(ctx, repo, "v1")
32+ require.NoError(t, err)
33+ r.Version = got.Version
34+ require.Equal(t, r, got)
35+ ar, err := st.GetAssetByName(ctx, repo, id, "binary")
36+ require.NoError(t, err)
37+ a.Version = ar.Version
38+ require.Equal(t, a, ar)
39+ rr, err := st.ReleaseRepo(ctx, id)
40+ require.NoError(t, err)
41+ require.Equal(t, repo, rr)
42+ rs, err := st.ListReleases(ctx, repo)
43+ require.NoError(t, err)
44+ require.Equal(t, []domain.Release{r}, rs)
45+ as, err := st.ListAssets(ctx, repo, id)
46+ require.NoError(t, err)
47+ require.Equal(t, []domain.ReleaseAsset{a}, as)
48+ all, err := st.AllAssets(ctx)
49+ require.NoError(t, err)
50+ require.Equal(t, as, all)
51+ require.ErrorIs(t, st.ApplyReleases(ctx, w), domain.ErrConflict)
52+ used, version, err := st.ReleaseUsage(ctx, repo)
53+ require.NoError(t, err)
54+ require.EqualValues(t, 3, used)
55+ duplicate := r
56+ duplicate.ID += 10
57+ duplicate.Version = 0
58+ require.ErrorIs(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, PutReleases: []domain.Release{duplicate}}), domain.ErrAlreadyExists)
59+ da := a
60+ da.ID += 10
61+ da.Version = 0
62+ require.ErrorIs(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, PutAssets: []domain.ReleaseAsset{da}}), domain.ErrAlreadyExists)
63+ stale := r
64+ stale.Version++
65+ require.ErrorIs(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, PutReleases: []domain.Release{stale}}), domain.ErrConflict)
66+ staleAsset := a
67+ staleAsset.Version++
68+ require.ErrorIs(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, DeleteAssets: []domain.ReleaseAsset{staleAsset}}), domain.ErrConflict)
69+ r.Tag = "v2"
70+ a.Name = "renamed"
71+ require.NoError(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, Used: 3, PutReleases: []domain.Release{r}, PutAssets: []domain.ReleaseAsset{a}}))
72+ _, err = st.GetReleaseByTag(ctx, repo, "v1")
73+ require.ErrorIs(t, err, domain.ErrNotFound)
74+ _, err = st.GetAssetByName(ctx, repo, id, "binary")
75+ require.ErrorIs(t, err, domain.ErrNotFound)
76+ renamedRelease, err := st.GetReleaseByTag(ctx, repo, "v2")
77+ require.NoError(t, err)
78+ renamedAsset, err := st.GetAssetByName(ctx, repo, id, "renamed")
79+ require.NoError(t, err)
80+ _, version, err = st.ReleaseUsage(ctx, repo)
81+ require.NoError(t, err)
82+ require.NoError(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, DeleteReleases: []domain.Release{renamedRelease}, DeleteAssets: []domain.ReleaseAsset{renamedAsset}}))
83+ _, err = st.GetRelease(ctx, repo, id)
84+ require.ErrorIs(t, err, domain.ErrNotFound)
85+ _, err = st.GetAsset(ctx, a.ID)
86+ require.ErrorIs(t, err, domain.ErrNotFound)
87+ _, version, err = st.ReleaseUsage(ctx, repo)
88+ require.NoError(t, err)
89+ r.Version, a.Version = 0, 0
90+ require.NoError(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, Used: 3, PutReleases: []domain.Release{r}, PutAssets: []domain.ReleaseAsset{a}}))
91+ require.NoError(t, s.Delete(ctx, repo))
92+ _, err = st.GetRelease(ctx, repo, id)
93+ require.ErrorIs(t, err, domain.ErrNotFound)
94+ _, err = st.ReleaseRepo(ctx, id)
95+ require.ErrorIs(t, err, domain.ErrNotFound)
96+ _, err = st.GetAsset(ctx, a.ID)
97+ require.ErrorIs(t, err, domain.ErrNotFound)
98+ all, err = st.AllAssets(ctx)
99+ require.NoError(t, err)
100+ require.Empty(t, all)
101+ used, version, err = st.ReleaseUsage(ctx, repo)
102+ require.NoError(t, err)
103+ require.Zero(t, used)
104+ require.Zero(t, version)
105+ require.ErrorIs(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo}), domain.ErrNotFound)
106+ require.NoError(t, s.Close(ctx))
107+ db, err := kv.Open(ctx, dir, kv.Options{})
108+ require.NoError(t, err)
109+ defer func() { _ = db.Close(ctx) }()
110+ for _, prefix := range []string{"rel/", "reltag/", "relid/", "relasset/", "relassets/", "reluse/"} {
111+ for key := range db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
112+ t.Errorf("repository deletion left release key %q", key)
113+ }
114+ }
115+}
116+func TestConcurrentReleaseIDs(t *testing.T) {
117+ st := openStore(t, t.TempDir()).Releases()
118+ var wg sync.WaitGroup
119+ ids := make(chan int64, 32)
120+ for range 32 {
121+ wg.Go(func() { id, err := st.NextReleaseID(context.Background()); require.NoError(t, err); ids <- id })
122+ }
123+ wg.Wait()
124+ close(ids)
125+ seen := map[int64]bool{}
126+ for id := range ids {
127+ require.False(t, seen[id])
128+ seen[id] = true
129+ }
130+ require.Len(t, seen, 32)
131+}
M internal/adapters/kvstore/repos.go +1 -0
@@ -163,6 +163,7 @@ func (s *Store) Delete(ctx context.Context, name domain.RepoName) error {
163163 keys := append(mergeRequestKeys(ctx, snap, name), issueKeys(ctx, snap, name)...)
164164 keys = append(keys, pendingKeys(ctx, snap, name)...)
165165 keys = append(keys, ciKeys(ctx, snap, name)...)
166+ keys = append(keys, releaseKeys(ctx, snap, name)...)
166167 keys = append(keys, settingsKeys(ctx, snap, name)...)
167168 keys = append(keys, mirrorKeys(ctx, snap, name)...)
168169 for len(keys) > deleteChunk {
@@ -163,6 +163,7 @@ func (s *Store) Delete(ctx context.Context, name domain.RepoName) error {
163 keys := append(mergeRequestKeys(ctx, snap, name), issueKeys(ctx, snap, name)...)163 keys := append(mergeRequestKeys(ctx, snap, name), issueKeys(ctx, snap, name)...)
164 keys = append(keys, pendingKeys(ctx, snap, name)...)164 keys = append(keys, pendingKeys(ctx, snap, name)...)
165 keys = append(keys, ciKeys(ctx, snap, name)...)165 keys = append(keys, ciKeys(ctx, snap, name)...)
166+ keys = append(keys, releaseKeys(ctx, snap, name)...)
166 keys = append(keys, settingsKeys(ctx, snap, name)...)167 keys = append(keys, settingsKeys(ctx, snap, name)...)
167 keys = append(keys, mirrorKeys(ctx, snap, name)...)168 keys = append(keys, mirrorKeys(ctx, snap, name)...)
168 for len(keys) > deleteChunk {169 for len(keys) > deleteChunk {
M internal/adapters/kvstore/store.go +25 -4
@@ -40,8 +40,10 @@ import (
4040 // if missing) to the stored tokens that held all nine original scopes with no
4141 // repository list and no branch prefix, so that they keep the owner rights (see
4242 // upgradeTokens). Version 5 grants mr:force-merge to unrestricted tokens
43-// holding all eleven previous scopes, preserving their owner rights.
44-const SchemaVersion = 5
43+// holding all eleven previous scopes, preserving their owner rights. Version 6
44+// adds releases and release:write; older builds cannot clean up their metadata
45+// or preserve the owner rights of upgraded tokens (see upgradeReleaseTokens).
46+const SchemaVersion = 6
4547
4648 const schemaKey = "meta/schema"
4749
@@ -110,7 +112,7 @@ func (s *Store) Check(ctx context.Context) error {
110112 func (s *Store) checkSchema(ctx context.Context) error {
111113 log := zerowrap.FromCtx(ctx)
112114 key := []byte(schemaKey)
113- for range 4 {
115+ for range 5 {
114116 item, ok := s.db.Get(ctx, key)
115117 if !ok {
116118 var b kv.Batch
@@ -142,8 +144,11 @@ func (s *Store) checkSchema(ctx context.Context) error {
142144 // The tokens are upgraded in the same batch, so that a crash
143145 // leaves the old version and the upgrade runs again.
144146 target := uint32(SchemaVersion)
145- if sv.GetVersion() < 4 {
147+ switch {
148+ case sv.GetVersion() < 4:
146149 target = 4
150+ case sv.GetVersion() == 4:
151+ target = 5
147152 }
148153 var b kv.Batch
149154 b.RequireVersion(key, item.Version)
@@ -157,6 +162,11 @@ func (s *Store) checkSchema(ctx context.Context) error {
157162 return err
158163 }
159164 }
165+ if sv.GetVersion() == 5 {
166+ if err := s.upgradeReleaseTokens(ctx, &b); err != nil {
167+ return err
168+ }
169+ }
160170 b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: target}))
161171 _, err := s.db.Apply(ctx, &b)
162172 if errors.Is(err, kv.ErrConflict) {
@@ -214,6 +224,17 @@ func (s *Store) upgradeForceMergeTokens(ctx context.Context, b *kv.Batch) error
214224 return s.upgradeTokenScopes(ctx, b, preForceMergeScopes, []domain.Scope{domain.ScopeMRForceMerge})
215225 }
216226
227+// preReleaseScopes are the twelve scopes of schema 5, before release:write.
228+var preReleaseScopes = append(slices.Clone(preForceMergeScopes), domain.ScopeMRForceMerge)
229+
230+// upgradeReleaseTokens adds to b the schema 6 upgrade of the tokens: a token
231+// that holds all of preReleaseScopes, limited to no repository and no branch
232+// prefix, had the owner rights, which AllScopes also needs release:write for,
233+// so it gets it. Restricted or partial tokens are left as they are.
234+func (s *Store) upgradeReleaseTokens(ctx context.Context, b *kv.Batch) error {
235+ return s.upgradeTokenScopes(ctx, b, preReleaseScopes, []domain.Scope{domain.ScopeReleaseWrite})
236+}
237+
217238 func (s *Store) upgradeTokenScopes(ctx context.Context, b *kv.Batch, required, addedScopes []domain.Scope) error {
218239 for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) {
219240 pb := &storagepb.Token{}
@@ -40,8 +40,10 @@ import (
40 // if missing) to the stored tokens that held all nine original scopes with no40 // if missing) to the stored tokens that held all nine original scopes with no
41 // repository list and no branch prefix, so that they keep the owner rights (see41 // repository list and no branch prefix, so that they keep the owner rights (see
42 // upgradeTokens). Version 5 grants mr:force-merge to unrestricted tokens42 // upgradeTokens). Version 5 grants mr:force-merge to unrestricted tokens
43-// holding all eleven previous scopes, preserving their owner rights.43+// holding all eleven previous scopes, preserving their owner rights. Version 6
44-const SchemaVersion = 544+// adds releases and release:write; older builds cannot clean up their metadata
45+// or preserve the owner rights of upgraded tokens (see upgradeReleaseTokens).
46+const SchemaVersion = 6
45 47
46 const schemaKey = "meta/schema"48 const schemaKey = "meta/schema"
47 49
@@ -110,7 +112,7 @@ func (s *Store) Check(ctx context.Context) error {
110 func (s *Store) checkSchema(ctx context.Context) error {112 func (s *Store) checkSchema(ctx context.Context) error {
111 log := zerowrap.FromCtx(ctx)113 log := zerowrap.FromCtx(ctx)
112 key := []byte(schemaKey)114 key := []byte(schemaKey)
113- for range 4 {115+ for range 5 {
114 item, ok := s.db.Get(ctx, key)116 item, ok := s.db.Get(ctx, key)
115 if !ok {117 if !ok {
116 var b kv.Batch118 var b kv.Batch
@@ -142,8 +144,11 @@ func (s *Store) checkSchema(ctx context.Context) error {
142 // The tokens are upgraded in the same batch, so that a crash144 // The tokens are upgraded in the same batch, so that a crash
143 // leaves the old version and the upgrade runs again.145 // leaves the old version and the upgrade runs again.
144 target := uint32(SchemaVersion)146 target := uint32(SchemaVersion)
145- if sv.GetVersion() < 4 {147+ switch {
148+ case sv.GetVersion() < 4:
146 target = 4149 target = 4
150+ case sv.GetVersion() == 4:
151+ target = 5
147 }152 }
148 var b kv.Batch153 var b kv.Batch
149 b.RequireVersion(key, item.Version)154 b.RequireVersion(key, item.Version)
@@ -157,6 +162,11 @@ func (s *Store) checkSchema(ctx context.Context) error {
157 return err162 return err
158 }163 }
159 }164 }
165+ if sv.GetVersion() == 5 {
166+ if err := s.upgradeReleaseTokens(ctx, &b); err != nil {
167+ return err
168+ }
169+ }
160 b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: target}))170 b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: target}))
161 _, err := s.db.Apply(ctx, &b)171 _, err := s.db.Apply(ctx, &b)
162 if errors.Is(err, kv.ErrConflict) {172 if errors.Is(err, kv.ErrConflict) {
@@ -214,6 +224,17 @@ func (s *Store) upgradeForceMergeTokens(ctx context.Context, b *kv.Batch) error
214 return s.upgradeTokenScopes(ctx, b, preForceMergeScopes, []domain.Scope{domain.ScopeMRForceMerge})224 return s.upgradeTokenScopes(ctx, b, preForceMergeScopes, []domain.Scope{domain.ScopeMRForceMerge})
215 }225 }
216 226
227+// preReleaseScopes are the twelve scopes of schema 5, before release:write.
228+var preReleaseScopes = append(slices.Clone(preForceMergeScopes), domain.ScopeMRForceMerge)
229+
230+// upgradeReleaseTokens adds to b the schema 6 upgrade of the tokens: a token
231+// that holds all of preReleaseScopes, limited to no repository and no branch
232+// prefix, had the owner rights, which AllScopes also needs release:write for,
233+// so it gets it. Restricted or partial tokens are left as they are.
234+func (s *Store) upgradeReleaseTokens(ctx context.Context, b *kv.Batch) error {
235+ return s.upgradeTokenScopes(ctx, b, preReleaseScopes, []domain.Scope{domain.ScopeReleaseWrite})
236+}
237+
217 func (s *Store) upgradeTokenScopes(ctx context.Context, b *kv.Batch, required, addedScopes []domain.Scope) error {238 func (s *Store) upgradeTokenScopes(ctx context.Context, b *kv.Batch, required, addedScopes []domain.Scope) error {
218 for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) {239 for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) {
219 pb := &storagepb.Token{}240 pb := &storagepb.Token{}
M internal/adapters/kvstore/store_test.go +57 -0
@@ -363,3 +363,60 @@ func TestRepoWithoutTopicsReadsNil(t *testing.T) {
363363 require.NoError(t, err)
364364 assert.Equal(t, []string{"a", "b"}, got.Topics)
365365 }
366+
367+func TestSchema6TokenUpgrade(t *testing.T) {
368+ ctx := context.Background()
369+ for _, from := range []uint32{3, 4, 5} {
370+ t.Run(fmt.Sprint(from), func(t *testing.T) {
371+ dir := t.TempDir()
372+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: from}))
373+ all := domain.AllScopes()
374+ scopes := make([]string, 0, len(all)-1)
375+ for _, sc := range all {
376+ if sc != domain.ScopeReleaseWrite {
377+ scopes = append(scopes, sc.String())
378+ }
379+ }
380+ for _, tc := range []struct {
381+ id string
382+ scopes []string
383+ repos, prefixes []string
384+ upgraded bool
385+ }{
386+ {id: "full", scopes: scopes, upgraded: true},
387+ {id: "partial", scopes: []string{"repo:read"}},
388+ {id: "missing-review", scopes: func() []string {
389+ var out []string
390+ for _, s := range scopes {
391+ if s != "mr:review" {
392+ out = append(out, s)
393+ }
394+ }
395+ return out
396+ }(), upgraded: from < 4},
397+ {id: "repo", scopes: scopes, repos: []string{"a"}},
398+ {id: "branch", scopes: scopes, prefixes: []string{"bot/"}},
399+ } {
400+ putRaw(t, dir, "token/"+tc.id, mustProto(t, &storagepb.Token{Id: tc.id, Scopes: tc.scopes, Repos: tc.repos, BranchPrefixes: tc.prefixes}))
401+ }
402+ s, err := kvstore.Open(ctx, dir)
403+ require.NoError(t, err)
404+ require.NoError(t, s.Close(ctx))
405+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
406+ db, err := kv.Open(ctx, dir, kv.Options{})
407+ require.NoError(t, err)
408+ defer func() { _ = db.Close(ctx) }()
409+ for _, id := range []string{"full", "partial", "missing-review", "repo", "branch"} {
410+ item, ok := db.Get(ctx, []byte("token/"+id))
411+ require.True(t, ok)
412+ var pb storagepb.Token
413+ require.NoError(t, proto.Unmarshal(item.Value, &pb))
414+ if id == "full" || (id == "missing-review" && from < 4) {
415+ assert.Contains(t, pb.Scopes, "release:write")
416+ } else {
417+ assert.NotContains(t, pb.Scopes, "release:write")
418+ }
419+ }
420+ })
421+ }
422+}
@@ -363,3 +363,60 @@ func TestRepoWithoutTopicsReadsNil(t *testing.T) {
363 require.NoError(t, err)363 require.NoError(t, err)
364 assert.Equal(t, []string{"a", "b"}, got.Topics)364 assert.Equal(t, []string{"a", "b"}, got.Topics)
365 }365 }
366+
367+func TestSchema6TokenUpgrade(t *testing.T) {
368+ ctx := context.Background()
369+ for _, from := range []uint32{3, 4, 5} {
370+ t.Run(fmt.Sprint(from), func(t *testing.T) {
371+ dir := t.TempDir()
372+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: from}))
373+ all := domain.AllScopes()
374+ scopes := make([]string, 0, len(all)-1)
375+ for _, sc := range all {
376+ if sc != domain.ScopeReleaseWrite {
377+ scopes = append(scopes, sc.String())
378+ }
379+ }
380+ for _, tc := range []struct {
381+ id string
382+ scopes []string
383+ repos, prefixes []string
384+ upgraded bool
385+ }{
386+ {id: "full", scopes: scopes, upgraded: true},
387+ {id: "partial", scopes: []string{"repo:read"}},
388+ {id: "missing-review", scopes: func() []string {
389+ var out []string
390+ for _, s := range scopes {
391+ if s != "mr:review" {
392+ out = append(out, s)
393+ }
394+ }
395+ return out
396+ }(), upgraded: from < 4},
397+ {id: "repo", scopes: scopes, repos: []string{"a"}},
398+ {id: "branch", scopes: scopes, prefixes: []string{"bot/"}},
399+ } {
400+ putRaw(t, dir, "token/"+tc.id, mustProto(t, &storagepb.Token{Id: tc.id, Scopes: tc.scopes, Repos: tc.repos, BranchPrefixes: tc.prefixes}))
401+ }
402+ s, err := kvstore.Open(ctx, dir)
403+ require.NoError(t, err)
404+ require.NoError(t, s.Close(ctx))
405+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
406+ db, err := kv.Open(ctx, dir, kv.Options{})
407+ require.NoError(t, err)
408+ defer func() { _ = db.Close(ctx) }()
409+ for _, id := range []string{"full", "partial", "missing-review", "repo", "branch"} {
410+ item, ok := db.Get(ctx, []byte("token/"+id))
411+ require.True(t, ok)
412+ var pb storagepb.Token
413+ require.NoError(t, proto.Unmarshal(item.Value, &pb))
414+ if id == "full" || (id == "missing-review" && from < 4) {
415+ assert.Contains(t, pb.Scopes, "release:write")
416+ } else {
417+ assert.NotContains(t, pb.Scopes, "release:write")
418+ }
419+ }
420+ })
421+ }
422+}
M internal/adapters/rpc/api.go +9 -0
@@ -59,6 +59,8 @@ type Config struct {
5959 SigningKey ports.SigningKeyService
6060 // Issues serves IssueService.
6161 Issues ports.IssueService
62+ // Releases serves ReleaseService when set; leave unset rather than a typed nil.
63+ Releases ports.ReleaseService
6264 // IssueModeration serves the moderation calls of IssueService.
6365 IssueModeration ports.IssueModerationService
6466 Version string
@@ -142,6 +144,9 @@ func New(cfg Config) *API {
142144 if artifacts {
143145 features = append(features, FeatureCIArtifacts)
144146 }
147+ if cfg.Releases != nil {
148+ features = append(features, FeatureReleases)
149+ }
145150 if cfg.OIDCKeys != nil && cfg.OIDCKeys.Enabled() {
146151 features = append(features, FeatureCIOIDC)
147152 }
@@ -162,6 +167,10 @@ func New(cfg Config) *API {
162167 mux.Handle(ermiteconnect.NewVariableServiceHandler(&variableService{cfg.Variables}, opts...))
163168 mux.Handle(ermiteconnect.NewEnvironmentServiceHandler(&environmentService{cfg.Environments}, opts...))
164169 }
170+ if cfg.Releases != nil {
171+ releaseOpts := append(slices.Clone(opts), connect.WithReadMaxBytes(RunnerMaxRequestBytes))
172+ mux.Handle(ermiteconnect.NewReleaseServiceHandler(&releaseService{cfg.Releases}, releaseOpts...))
173+ }
165174 if cfg.Runner != nil {
166175 runnerOpts := append(slices.Clone(opts), connect.WithReadMaxBytes(RunnerMaxRequestBytes))
167176 runners := &runnerService{runners: cfg.Runner, limiter: cfg.Limiter}
@@ -59,6 +59,8 @@ type Config struct {
59 SigningKey ports.SigningKeyService59 SigningKey ports.SigningKeyService
60 // Issues serves IssueService.60 // Issues serves IssueService.
61 Issues ports.IssueService61 Issues ports.IssueService
62+ // Releases serves ReleaseService when set; leave unset rather than a typed nil.
63+ Releases ports.ReleaseService
62 // IssueModeration serves the moderation calls of IssueService.64 // IssueModeration serves the moderation calls of IssueService.
63 IssueModeration ports.IssueModerationService65 IssueModeration ports.IssueModerationService
64 Version string66 Version string
@@ -142,6 +144,9 @@ func New(cfg Config) *API {
142 if artifacts {144 if artifacts {
143 features = append(features, FeatureCIArtifacts)145 features = append(features, FeatureCIArtifacts)
144 }146 }
147+ if cfg.Releases != nil {
148+ features = append(features, FeatureReleases)
149+ }
145 if cfg.OIDCKeys != nil && cfg.OIDCKeys.Enabled() {150 if cfg.OIDCKeys != nil && cfg.OIDCKeys.Enabled() {
146 features = append(features, FeatureCIOIDC)151 features = append(features, FeatureCIOIDC)
147 }152 }
@@ -162,6 +167,10 @@ func New(cfg Config) *API {
162 mux.Handle(ermiteconnect.NewVariableServiceHandler(&variableService{cfg.Variables}, opts...))167 mux.Handle(ermiteconnect.NewVariableServiceHandler(&variableService{cfg.Variables}, opts...))
163 mux.Handle(ermiteconnect.NewEnvironmentServiceHandler(&environmentService{cfg.Environments}, opts...))168 mux.Handle(ermiteconnect.NewEnvironmentServiceHandler(&environmentService{cfg.Environments}, opts...))
164 }169 }
170+ if cfg.Releases != nil {
171+ releaseOpts := append(slices.Clone(opts), connect.WithReadMaxBytes(RunnerMaxRequestBytes))
172+ mux.Handle(ermiteconnect.NewReleaseServiceHandler(&releaseService{cfg.Releases}, releaseOpts...))
173+ }
165 if cfg.Runner != nil {174 if cfg.Runner != nil {
166 runnerOpts := append(slices.Clone(opts), connect.WithReadMaxBytes(RunnerMaxRequestBytes))175 runnerOpts := append(slices.Clone(opts), connect.WithReadMaxBytes(RunnerMaxRequestBytes))
167 runners := &runnerService{runners: cfg.Runner, limiter: cfg.Limiter}176 runners := &runnerService{runners: cfg.Runner, limiter: cfg.Limiter}
M internal/adapters/rpc/convert.go +28 -3
@@ -137,6 +137,7 @@ var scopeToPB = map[domain.Scope]pb.Scope{
137137 domain.ScopeRunnerRegister: pb.Scope_SCOPE_RUNNER_REGISTER,
138138 domain.ScopeTokenAdmin: pb.Scope_SCOPE_TOKEN_ADMIN,
139139 domain.ScopeIssueWrite: pb.Scope_SCOPE_ISSUE_WRITE,
140+ domain.ScopeReleaseWrite: pb.Scope_SCOPE_RELEASE_WRITE,
140141 }
141142
142143 func scopeFromPB(s pb.Scope) (domain.Scope, error) {
@@ -537,9 +538,9 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
537538 out.Time = timestamp(ev.At)
538539 out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}}
539540 default:
540- // Web-only events, such as domain.IssueReferenced and
541- // domain.IssuePendingRemoved: the API has no messages for them.
542- return nil
541+ // Release lifecycle conversions live alongside the event service.
542+ // Web-only events have no representation in the API.
543+ return releaseEventToPB(we)
543544 }
544545 return out
545546 }
@@ -566,3 +567,27 @@ func passkeysToPB(list []domain.Passkey) []*pb.Passkey {
566567 }
567568 return out
568569 }
570+
571+func releaseToPB(r domain.Release) *pb.Release {
572+ return &pb.Release{
573+ Id: r.ID, Repo: r.Repo.String(), Tag: r.Tag, Title: r.Title, Notes: r.Notes,
574+ Draft: r.Draft, Prerelease: r.Prerelease, TargetCommitish: r.TargetCommitish, Author: r.Author,
575+ CreatedAt: timestamp(r.CreatedAt), PublishedAt: timestamp(r.PublishedAt), UpdatedAt: timestamp(r.UpdatedAt),
576+ }
577+}
578+
579+func assetToPB(a domain.ReleaseAsset) *pb.ReleaseAsset {
580+ return &pb.ReleaseAsset{
581+ Id: a.ID, ReleaseId: a.ReleaseID, Name: a.Name, Label: a.Label, ContentType: a.ContentType,
582+ Size: a.Size, Digest: a.Digest, Uploader: a.Uploader,
583+ CreatedAt: timestamp(a.CreatedAt), UpdatedAt: timestamp(a.UpdatedAt),
584+ }
585+}
586+
587+func assetsToPB(assets []domain.ReleaseAsset) []*pb.ReleaseAsset {
588+ out := make([]*pb.ReleaseAsset, 0, len(assets))
589+ for _, a := range assets {
590+ out = append(out, assetToPB(a))
591+ }
592+ return out
593+}
@@ -137,6 +137,7 @@ var scopeToPB = map[domain.Scope]pb.Scope{
137 domain.ScopeRunnerRegister: pb.Scope_SCOPE_RUNNER_REGISTER,137 domain.ScopeRunnerRegister: pb.Scope_SCOPE_RUNNER_REGISTER,
138 domain.ScopeTokenAdmin: pb.Scope_SCOPE_TOKEN_ADMIN,138 domain.ScopeTokenAdmin: pb.Scope_SCOPE_TOKEN_ADMIN,
139 domain.ScopeIssueWrite: pb.Scope_SCOPE_ISSUE_WRITE,139 domain.ScopeIssueWrite: pb.Scope_SCOPE_ISSUE_WRITE,
140+ domain.ScopeReleaseWrite: pb.Scope_SCOPE_RELEASE_WRITE,
140 }141 }
141 142
142 func scopeFromPB(s pb.Scope) (domain.Scope, error) {143 func scopeFromPB(s pb.Scope) (domain.Scope, error) {
@@ -537,9 +538,9 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
537 out.Time = timestamp(ev.At)538 out.Time = timestamp(ev.At)
538 out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}}539 out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}}
539 default:540 default:
540- // Web-only events, such as domain.IssueReferenced and541+ // Release lifecycle conversions live alongside the event service.
541- // domain.IssuePendingRemoved: the API has no messages for them.542+ // Web-only events have no representation in the API.
542- return nil543+ return releaseEventToPB(we)
543 }544 }
544 return out545 return out
545 }546 }
@@ -566,3 +567,27 @@ func passkeysToPB(list []domain.Passkey) []*pb.Passkey {
566 }567 }
567 return out568 return out
568 }569 }
570+
571+func releaseToPB(r domain.Release) *pb.Release {
572+ return &pb.Release{
573+ Id: r.ID, Repo: r.Repo.String(), Tag: r.Tag, Title: r.Title, Notes: r.Notes,
574+ Draft: r.Draft, Prerelease: r.Prerelease, TargetCommitish: r.TargetCommitish, Author: r.Author,
575+ CreatedAt: timestamp(r.CreatedAt), PublishedAt: timestamp(r.PublishedAt), UpdatedAt: timestamp(r.UpdatedAt),
576+ }
577+}
578+
579+func assetToPB(a domain.ReleaseAsset) *pb.ReleaseAsset {
580+ return &pb.ReleaseAsset{
581+ Id: a.ID, ReleaseId: a.ReleaseID, Name: a.Name, Label: a.Label, ContentType: a.ContentType,
582+ Size: a.Size, Digest: a.Digest, Uploader: a.Uploader,
583+ CreatedAt: timestamp(a.CreatedAt), UpdatedAt: timestamp(a.UpdatedAt),
584+ }
585+}
586+
587+func assetsToPB(assets []domain.ReleaseAsset) []*pb.ReleaseAsset {
588+ out := make([]*pb.ReleaseAsset, 0, len(assets))
589+ for _, a := range assets {
590+ out = append(out, assetToPB(a))
591+ }
592+ return out
593+}
M internal/adapters/rpc/errors.go +2 -0
@@ -55,6 +55,8 @@ func codeOf(err error) (connect.Code, bool) {
5555 return connect.CodeAborted, true
5656 case errors.Is(err, domain.ErrResourceExhausted):
5757 return connect.CodeResourceExhausted, true
58+ case errors.Is(err, domain.ErrOutOfRange):
59+ return connect.CodeOutOfRange, true
5860 case errors.Is(err, domain.ErrUnavailable):
5961 return connect.CodeUnavailable, true
6062 }
@@ -55,6 +55,8 @@ func codeOf(err error) (connect.Code, bool) {
55 return connect.CodeAborted, true55 return connect.CodeAborted, true
56 case errors.Is(err, domain.ErrResourceExhausted):56 case errors.Is(err, domain.ErrResourceExhausted):
57 return connect.CodeResourceExhausted, true57 return connect.CodeResourceExhausted, true
58+ case errors.Is(err, domain.ErrOutOfRange):
59+ return connect.CodeOutOfRange, true
58 case errors.Is(err, domain.ErrUnavailable):60 case errors.Is(err, domain.ErrUnavailable):
59 return connect.CodeUnavailable, true61 return connect.CodeUnavailable, true
60 }62 }
M internal/adapters/rpc/event.go +26 -0
@@ -8,6 +8,7 @@ import (
88 "connectrpc.com/connect"
99
1010 pb "bnema.dev/ermite-api/gen/ermite"
11+ "bnema.dev/ermite/internal/domain"
1112 "bnema.dev/ermite/internal/ports"
1213 )
1314
@@ -37,3 +38,28 @@ func (s *eventService) Watch(ctx context.Context, req *connect.Request[pb.WatchR
3738 })
3839 return connectError(ctx, err)
3940 }
41+
42+func releaseEventToPB(we ports.WatchEvent) *pb.Event {
43+ out := &pb.Event{Id: we.ID}
44+ switch ev := we.Event.(type) {
45+ case domain.ReleasePublished:
46+ r := ev.Release
47+ out.Time = timestamp(ev.At)
48+ out.Kind = &pb.Event_ReleasePublished{ReleasePublished: &pb.ReleasePublished{Repo: r.Repo.String(), Id: r.ID, Tag: r.Tag, Draft: r.Draft, Prerelease: r.Prerelease}}
49+ case domain.ReleaseUpdated:
50+ r := ev.Release
51+ out.Time = timestamp(ev.At)
52+ out.Kind = &pb.Event_ReleaseUpdated{ReleaseUpdated: &pb.ReleaseUpdated{Repo: r.Repo.String(), Id: r.ID, Tag: r.Tag, Draft: r.Draft, Prerelease: r.Prerelease}}
53+ case domain.ReleaseDeleted:
54+ r := ev.Release
55+ out.Time = timestamp(ev.At)
56+ out.Kind = &pb.Event_ReleaseDeleted{ReleaseDeleted: &pb.ReleaseDeleted{Repo: r.Repo.String(), Id: r.ID, Tag: r.Tag}}
57+ case domain.ReleaseUnpublished:
58+ r := ev.Release
59+ out.Time = timestamp(ev.At)
60+ out.Kind = &pb.Event_ReleaseUnpublished{ReleaseUnpublished: &pb.ReleaseUnpublished{Repo: r.Repo.String(), Id: r.ID, Tag: r.Tag, Draft: r.Draft, Prerelease: r.Prerelease}}
61+ default:
62+ return nil
63+ }
64+ return out
65+}
@@ -8,6 +8,7 @@ import (
8 "connectrpc.com/connect"8 "connectrpc.com/connect"
9 9
10 pb "bnema.dev/ermite-api/gen/ermite"10 pb "bnema.dev/ermite-api/gen/ermite"
11+ "bnema.dev/ermite/internal/domain"
11 "bnema.dev/ermite/internal/ports"12 "bnema.dev/ermite/internal/ports"
12 )13 )
13 14
@@ -37,3 +38,28 @@ func (s *eventService) Watch(ctx context.Context, req *connect.Request[pb.WatchR
37 })38 })
38 return connectError(ctx, err)39 return connectError(ctx, err)
39 }40 }
41+
42+func releaseEventToPB(we ports.WatchEvent) *pb.Event {
43+ out := &pb.Event{Id: we.ID}
44+ switch ev := we.Event.(type) {
45+ case domain.ReleasePublished:
46+ r := ev.Release
47+ out.Time = timestamp(ev.At)
48+ out.Kind = &pb.Event_ReleasePublished{ReleasePublished: &pb.ReleasePublished{Repo: r.Repo.String(), Id: r.ID, Tag: r.Tag, Draft: r.Draft, Prerelease: r.Prerelease}}
49+ case domain.ReleaseUpdated:
50+ r := ev.Release
51+ out.Time = timestamp(ev.At)
52+ out.Kind = &pb.Event_ReleaseUpdated{ReleaseUpdated: &pb.ReleaseUpdated{Repo: r.Repo.String(), Id: r.ID, Tag: r.Tag, Draft: r.Draft, Prerelease: r.Prerelease}}
53+ case domain.ReleaseDeleted:
54+ r := ev.Release
55+ out.Time = timestamp(ev.At)
56+ out.Kind = &pb.Event_ReleaseDeleted{ReleaseDeleted: &pb.ReleaseDeleted{Repo: r.Repo.String(), Id: r.ID, Tag: r.Tag}}
57+ case domain.ReleaseUnpublished:
58+ r := ev.Release
59+ out.Time = timestamp(ev.At)
60+ out.Kind = &pb.Event_ReleaseUnpublished{ReleaseUnpublished: &pb.ReleaseUnpublished{Repo: r.Repo.String(), Id: r.ID, Tag: r.Tag, Draft: r.Draft, Prerelease: r.Prerelease}}
61+ default:
62+ return nil
63+ }
64+ return out
65+}
A internal/adapters/rpc/release.go +358 -0
@@ -0,0 +1,358 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "io"
10+
11+ "connectrpc.com/connect"
12+
13+ pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// FeatureReleases is listed by GetVersion when ReleaseService is served.
20+const FeatureReleases = "releases"
21+
22+// releaseService implements ermiteconnect.ReleaseServiceHandler.
23+type releaseService struct{ rel ports.ReleaseService }
24+
25+var _ ermiteconnect.ReleaseServiceHandler = (*releaseService)(nil)
26+
27+// releaseMessage fills the computed latest flag and uploaded assets using the
28+// same caller's read permissions. No latest release is a normal repository state.
29+func (s *releaseService) releaseMessage(ctx context.Context, repo domain.RepoName, r domain.Release) (*pb.Release, error) {
30+ latest, err := s.latestID(ctx, repo)
31+ if err != nil {
32+ return nil, err
33+ }
34+ return s.releaseRow(ctx, repo, r, latest)
35+}
36+
37+// latestID is the id of the latest release, or 0 when the repository has none.
38+func (s *releaseService) latestID(ctx context.Context, repo domain.RepoName) (int64, error) {
39+ latest, err := s.rel.GetRelease(ctx, caller(ctx), repo, ports.ReleaseRef{Latest: true})
40+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
41+ return 0, err
42+ }
43+ return latest.ID, nil
44+}
45+
46+// releaseRow converts one release given the id of the latest one, so a list
47+// resolves the latest release once.
48+func (s *releaseService) releaseRow(ctx context.Context, repo domain.RepoName, r domain.Release, latest int64) (*pb.Release, error) {
49+ assets, err := s.rel.ListAssets(ctx, caller(ctx), repo, r.ID)
50+ if err != nil {
51+ return nil, err
52+ }
53+ out := releaseToPB(r)
54+ out.Latest = latest != 0 && latest == r.ID
55+ out.Assets = assetsToPB(assets)
56+ return out, nil
57+}
58+
59+func (s *releaseService) CreateRelease(ctx context.Context, req *connect.Request[pb.CreateReleaseRequest]) (*connect.Response[pb.CreateReleaseResponse], error) {
60+ m := req.Msg
61+ repo, err := repoOf(m.GetRepo())
62+ if err != nil {
63+ return nil, connectError(ctx, err)
64+ }
65+ r, err := s.rel.CreateRelease(ctx, caller(ctx), repo, ports.CreateReleaseParams{Tag: m.GetTag(), TargetCommitish: m.GetTargetCommitish(), Title: m.GetTitle(), Notes: m.GetNotes(), Draft: m.GetDraft(), Prerelease: m.GetPrerelease(), MakeLatest: m.GetMakeLatest()})
66+ if err != nil {
67+ return nil, connectError(ctx, err)
68+ }
69+ out, err := s.releaseMessage(ctx, repo, r)
70+ if err != nil {
71+ return nil, connectError(ctx, err)
72+ }
73+ return connect.NewResponse(&pb.CreateReleaseResponse{Release: out}), nil
74+}
75+
76+func releaseRef(m *pb.GetReleaseRequest) (ports.ReleaseRef, error) {
77+ switch v := m.GetSelector().(type) {
78+ case *pb.GetReleaseRequest_Id:
79+ if v.Id > 0 {
80+ return ports.ReleaseRef{ID: v.Id}, nil
81+ }
82+ case *pb.GetReleaseRequest_Tag:
83+ if v.Tag != "" {
84+ return ports.ReleaseRef{Tag: v.Tag}, nil
85+ }
86+ case *pb.GetReleaseRequest_Latest:
87+ if v.Latest {
88+ return ports.ReleaseRef{Latest: true}, nil
89+ }
90+ }
91+ return ports.ReleaseRef{}, fmt.Errorf("%w: select an id, tag or latest=true", domain.ErrInvalid)
92+}
93+
94+func (s *releaseService) GetRelease(ctx context.Context, req *connect.Request[pb.GetReleaseRequest]) (*connect.Response[pb.GetReleaseResponse], error) {
95+ repo, err := repoOf(req.Msg.GetRepo())
96+ if err != nil {
97+ return nil, connectError(ctx, err)
98+ }
99+ ref, err := releaseRef(req.Msg)
100+ if err != nil {
101+ return nil, connectError(ctx, err)
102+ }
103+ r, err := s.rel.GetRelease(ctx, caller(ctx), repo, ref)
104+ if err != nil {
105+ return nil, connectError(ctx, err)
106+ }
107+ out, err := s.releaseMessage(ctx, repo, r)
108+ if err != nil {
109+ return nil, connectError(ctx, err)
110+ }
111+ return connect.NewResponse(&pb.GetReleaseResponse{Release: out}), nil
112+}
113+
114+func (s *releaseService) ListReleases(ctx context.Context, req *connect.Request[pb.ListReleasesRequest]) (*connect.Response[pb.ListReleasesResponse], error) {
115+ repo, err := repoOf(req.Msg.GetRepo())
116+ if err != nil {
117+ return nil, connectError(ctx, err)
118+ }
119+ page, err := s.rel.ListReleases(ctx, caller(ctx), repo, int(req.Msg.GetPageSize()), req.Msg.GetPageToken())
120+ if err != nil {
121+ return nil, connectError(ctx, err)
122+ }
123+ out := &pb.ListReleasesResponse{Releases: make([]*pb.Release, 0, len(page.Releases)), NextPageToken: page.NextPageToken}
124+ latest, err := s.latestID(ctx, repo)
125+ if err != nil {
126+ return nil, connectError(ctx, err)
127+ }
128+ for _, r := range page.Releases {
129+ m, err := s.releaseRow(ctx, repo, r, latest)
130+ if err != nil {
131+ return nil, connectError(ctx, err)
132+ }
133+ out.Releases = append(out.Releases, m)
134+ }
135+ return connect.NewResponse(out), nil
136+}
137+
138+func releaseUpdateParams(m *pb.UpdateReleaseRequest) (ports.UpdateReleaseParams, error) {
139+ var p ports.UpdateReleaseParams
140+ if len(m.GetUpdateMask().GetPaths()) == 0 {
141+ return p, fmt.Errorf("%w: update_mask is empty", domain.ErrInvalid)
142+ }
143+ for _, path := range m.GetUpdateMask().GetPaths() {
144+ switch path {
145+ case "tag":
146+ p.Tag = new(m.GetTag())
147+ case "target_commitish":
148+ p.TargetCommitish = new(m.GetTargetCommitish())
149+ case "title":
150+ p.Title = new(m.GetTitle())
151+ case "notes":
152+ p.Notes = new(m.GetNotes())
153+ case "draft":
154+ p.Draft = new(m.GetDraft())
155+ case "prerelease":
156+ p.Prerelease = new(m.GetPrerelease())
157+ case "make_latest":
158+ p.MakeLatest = new(m.GetMakeLatest())
159+ default:
160+ return p, fmt.Errorf("%w: unknown update_mask path %q", domain.ErrInvalid, path)
161+ }
162+ }
163+ return p, nil
164+}
165+
166+func (s *releaseService) UpdateRelease(ctx context.Context, req *connect.Request[pb.UpdateReleaseRequest]) (*connect.Response[pb.UpdateReleaseResponse], error) {
167+ repo, err := repoOf(req.Msg.GetRepo())
168+ if err != nil {
169+ return nil, connectError(ctx, err)
170+ }
171+ p, err := releaseUpdateParams(req.Msg)
172+ if err != nil {
173+ return nil, connectError(ctx, err)
174+ }
175+ r, err := s.rel.UpdateRelease(ctx, caller(ctx), repo, req.Msg.GetId(), p)
176+ if err != nil {
177+ return nil, connectError(ctx, err)
178+ }
179+ out, err := s.releaseMessage(ctx, repo, r)
180+ if err != nil {
181+ return nil, connectError(ctx, err)
182+ }
183+ return connect.NewResponse(&pb.UpdateReleaseResponse{Release: out}), nil
184+}
185+
186+func (s *releaseService) DeleteRelease(ctx context.Context, req *connect.Request[pb.DeleteReleaseRequest]) (*connect.Response[pb.DeleteReleaseResponse], error) {
187+ repo, err := repoOf(req.Msg.GetRepo())
188+ if err != nil {
189+ return nil, connectError(ctx, err)
190+ }
191+ if err := s.rel.DeleteRelease(ctx, caller(ctx), repo, req.Msg.GetId()); err != nil {
192+ return nil, connectError(ctx, err)
193+ }
194+ return connect.NewResponse(&pb.DeleteReleaseResponse{}), nil
195+}
196+
197+// chunkReader retains at most one message; metadata belongs to the first only.
198+type chunkReader struct {
199+ stream *connect.ClientStream[pb.UploadReleaseAssetRequest]
200+ buf []byte
201+}
202+
203+func (r *chunkReader) Read(p []byte) (int, error) {
204+ if len(p) == 0 {
205+ return 0, nil
206+ }
207+ for len(r.buf) == 0 {
208+ if !r.stream.Receive() {
209+ if err := r.stream.Err(); err != nil {
210+ return 0, err
211+ }
212+ return 0, io.EOF
213+ }
214+ m := r.stream.Msg()
215+ if m.GetRepo() != "" || m.GetReleaseId() != 0 || m.GetName() != "" || m.GetLabel() != "" || m.GetContentType() != "" || m.GetSize() != 0 || m.GetReplace() {
216+ return 0, fmt.Errorf("%w: upload metadata is in the first message only", domain.ErrInvalid)
217+ }
218+ if err := checkBlockData(m.GetData()); err != nil {
219+ return 0, err
220+ }
221+ r.buf = m.GetData()
222+ }
223+ n := copy(p, r.buf)
224+ r.buf = r.buf[n:]
225+ return n, nil
226+}
227+
228+func (s *releaseService) UploadReleaseAsset(ctx context.Context, stream *connect.ClientStream[pb.UploadReleaseAssetRequest]) (*connect.Response[pb.UploadReleaseAssetResponse], error) {
229+ if !stream.Receive() {
230+ err := stream.Err()
231+ if err == nil {
232+ err = fmt.Errorf("%w: the stream has no message", domain.ErrInvalid)
233+ }
234+ return nil, connectError(ctx, err)
235+ }
236+ m := stream.Msg()
237+ repo, err := repoOf(m.GetRepo())
238+ if err != nil {
239+ return nil, connectError(ctx, err)
240+ }
241+ if m.GetSize() < 0 {
242+ return nil, connectError(ctx, fmt.Errorf("%w: size must be nonnegative", domain.ErrInvalid))
243+ }
244+ if err := checkBlockData(m.GetData()); err != nil {
245+ return nil, connectError(ctx, err)
246+ }
247+ a, err := s.rel.UploadAsset(ctx, caller(ctx), repo, ports.UploadAssetParams{ReleaseID: m.GetReleaseId(), Name: m.GetName(), Label: m.GetLabel(), ContentType: m.GetContentType(), Size: m.GetSize(), Replace: m.GetReplace()}, &chunkReader{stream: stream, buf: m.GetData()})
248+ if err != nil {
249+ return nil, connectError(ctx, err)
250+ }
251+ return connect.NewResponse(&pb.UploadReleaseAssetResponse{Asset: assetToPB(a)}), nil
252+}
253+
254+func assetUpdateParams(m *pb.UpdateReleaseAssetRequest) (ports.UpdateAssetParams, error) {
255+ var p ports.UpdateAssetParams
256+ if len(m.GetUpdateMask().GetPaths()) == 0 {
257+ return p, fmt.Errorf("%w: update_mask is empty", domain.ErrInvalid)
258+ }
259+ for _, path := range m.GetUpdateMask().GetPaths() {
260+ switch path {
261+ case "name":
262+ p.Name = new(m.GetName())
263+ case "label":
264+ p.Label = new(m.GetLabel())
265+ default:
266+ return p, fmt.Errorf("%w: unknown update_mask path %q", domain.ErrInvalid, path)
267+ }
268+ }
269+ return p, nil
270+}
271+
272+func (s *releaseService) UpdateReleaseAsset(ctx context.Context, req *connect.Request[pb.UpdateReleaseAssetRequest]) (*connect.Response[pb.UpdateReleaseAssetResponse], error) {
273+ repo, err := repoOf(req.Msg.GetRepo())
274+ if err != nil {
275+ return nil, connectError(ctx, err)
276+ }
277+ p, err := assetUpdateParams(req.Msg)
278+ if err != nil {
279+ return nil, connectError(ctx, err)
280+ }
281+ a, err := s.rel.UpdateAsset(ctx, caller(ctx), repo, req.Msg.GetAssetId(), p)
282+ if err != nil {
283+ return nil, connectError(ctx, err)
284+ }
285+ return connect.NewResponse(&pb.UpdateReleaseAssetResponse{Asset: assetToPB(a)}), nil
286+}
287+
288+func (s *releaseService) DeleteReleaseAsset(ctx context.Context, req *connect.Request[pb.DeleteReleaseAssetRequest]) (*connect.Response[pb.DeleteReleaseAssetResponse], error) {
289+ repo, err := repoOf(req.Msg.GetRepo())
290+ if err != nil {
291+ return nil, connectError(ctx, err)
292+ }
293+ if err := s.rel.DeleteAsset(ctx, caller(ctx), repo, req.Msg.GetAssetId()); err != nil {
294+ return nil, connectError(ctx, err)
295+ }
296+ return connect.NewResponse(&pb.DeleteReleaseAssetResponse{}), nil
297+}
298+
299+func assetRef(m *pb.DownloadReleaseAssetRequest) (ports.AssetRef, error) {
300+ switch v := m.GetSelector().(type) {
301+ case *pb.DownloadReleaseAssetRequest_AssetId:
302+ if v.AssetId > 0 {
303+ return ports.AssetRef{ID: v.AssetId}, nil
304+ }
305+ case *pb.DownloadReleaseAssetRequest_Tag:
306+ if v.Tag.GetTag() != "" && v.Tag.GetName() != "" {
307+ return ports.AssetRef{Tag: v.Tag.GetTag(), Name: v.Tag.GetName()}, nil
308+ }
309+ case *pb.DownloadReleaseAssetRequest_Latest:
310+ if v.Latest.GetLatest() && v.Latest.GetName() != "" {
311+ return ports.AssetRef{Latest: true, Name: v.Latest.GetName()}, nil
312+ }
313+ }
314+ return ports.AssetRef{}, fmt.Errorf("%w: select an asset id, tag/name or latest=true/name", domain.ErrInvalid)
315+}
316+
317+func (s *releaseService) DownloadReleaseAsset(ctx context.Context, req *connect.Request[pb.DownloadReleaseAssetRequest], stream *connect.ServerStream[pb.DownloadReleaseAssetResponse]) error {
318+ repo, err := repoOf(req.Msg.GetRepo())
319+ if err != nil {
320+ return connectError(ctx, err)
321+ }
322+ ref, err := assetRef(req.Msg)
323+ if err != nil {
324+ return connectError(ctx, err)
325+ }
326+ err = s.rel.DownloadAsset(ctx, caller(ctx), repo, ref, req.Msg.GetOffset(), -1, func(c ports.AssetChunk) error {
327+ m := &pb.DownloadReleaseAssetResponse{Chunk: c.Data}
328+ if c.Asset != nil {
329+ m.Asset = assetToPB(*c.Asset)
330+ }
331+ return stream.Send(m)
332+ })
333+ return connectError(ctx, err)
334+}
335+
336+func (s *releaseService) ListReleaseAssets(ctx context.Context, req *connect.Request[pb.ListReleaseAssetsRequest]) (*connect.Response[pb.ListReleaseAssetsResponse], error) {
337+ repo, err := repoOf(req.Msg.GetRepo())
338+ if err != nil {
339+ return nil, connectError(ctx, err)
340+ }
341+ assets, err := s.rel.ListAssets(ctx, caller(ctx), repo, req.Msg.GetReleaseId())
342+ if err != nil {
343+ return nil, connectError(ctx, err)
344+ }
345+ return connect.NewResponse(&pb.ListReleaseAssetsResponse{Assets: assetsToPB(assets)}), nil
346+}
347+
348+func (s *releaseService) GetReleaseUsage(ctx context.Context, req *connect.Request[pb.GetReleaseUsageRequest]) (*connect.Response[pb.GetReleaseUsageResponse], error) {
349+ repo, err := repoOf(req.Msg.GetRepo())
350+ if err != nil {
351+ return nil, connectError(ctx, err)
352+ }
353+ u, err := s.rel.Usage(ctx, caller(ctx), repo)
354+ if err != nil {
355+ return nil, connectError(ctx, err)
356+ }
357+ return connect.NewResponse(&pb.GetReleaseUsageResponse{UsedBytes: u.Used, QuotaBytes: u.Limits.Quota, AssetMaxBytes: u.Limits.AssetMax, AssetsMax: int32(u.Limits.AssetsMax)}), nil
358+}
@@ -0,0 +1,358 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "io"
10+
11+ "connectrpc.com/connect"
12+
13+ pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// FeatureReleases is listed by GetVersion when ReleaseService is served.
20+const FeatureReleases = "releases"
21+
22+// releaseService implements ermiteconnect.ReleaseServiceHandler.
23+type releaseService struct{ rel ports.ReleaseService }
24+
25+var _ ermiteconnect.ReleaseServiceHandler = (*releaseService)(nil)
26+
27+// releaseMessage fills the computed latest flag and uploaded assets using the
28+// same caller's read permissions. No latest release is a normal repository state.
29+func (s *releaseService) releaseMessage(ctx context.Context, repo domain.RepoName, r domain.Release) (*pb.Release, error) {
30+ latest, err := s.latestID(ctx, repo)
31+ if err != nil {
32+ return nil, err
33+ }
34+ return s.releaseRow(ctx, repo, r, latest)
35+}
36+
37+// latestID is the id of the latest release, or 0 when the repository has none.
38+func (s *releaseService) latestID(ctx context.Context, repo domain.RepoName) (int64, error) {
39+ latest, err := s.rel.GetRelease(ctx, caller(ctx), repo, ports.ReleaseRef{Latest: true})
40+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
41+ return 0, err
42+ }
43+ return latest.ID, nil
44+}
45+
46+// releaseRow converts one release given the id of the latest one, so a list
47+// resolves the latest release once.
48+func (s *releaseService) releaseRow(ctx context.Context, repo domain.RepoName, r domain.Release, latest int64) (*pb.Release, error) {
49+ assets, err := s.rel.ListAssets(ctx, caller(ctx), repo, r.ID)
50+ if err != nil {
51+ return nil, err
52+ }
53+ out := releaseToPB(r)
54+ out.Latest = latest != 0 && latest == r.ID
55+ out.Assets = assetsToPB(assets)
56+ return out, nil
57+}
58+
59+func (s *releaseService) CreateRelease(ctx context.Context, req *connect.Request[pb.CreateReleaseRequest]) (*connect.Response[pb.CreateReleaseResponse], error) {
60+ m := req.Msg
61+ repo, err := repoOf(m.GetRepo())
62+ if err != nil {
63+ return nil, connectError(ctx, err)
64+ }
65+ r, err := s.rel.CreateRelease(ctx, caller(ctx), repo, ports.CreateReleaseParams{Tag: m.GetTag(), TargetCommitish: m.GetTargetCommitish(), Title: m.GetTitle(), Notes: m.GetNotes(), Draft: m.GetDraft(), Prerelease: m.GetPrerelease(), MakeLatest: m.GetMakeLatest()})
66+ if err != nil {
67+ return nil, connectError(ctx, err)
68+ }
69+ out, err := s.releaseMessage(ctx, repo, r)
70+ if err != nil {
71+ return nil, connectError(ctx, err)
72+ }
73+ return connect.NewResponse(&pb.CreateReleaseResponse{Release: out}), nil
74+}
75+
76+func releaseRef(m *pb.GetReleaseRequest) (ports.ReleaseRef, error) {
77+ switch v := m.GetSelector().(type) {
78+ case *pb.GetReleaseRequest_Id:
79+ if v.Id > 0 {
80+ return ports.ReleaseRef{ID: v.Id}, nil
81+ }
82+ case *pb.GetReleaseRequest_Tag:
83+ if v.Tag != "" {
84+ return ports.ReleaseRef{Tag: v.Tag}, nil
85+ }
86+ case *pb.GetReleaseRequest_Latest:
87+ if v.Latest {
88+ return ports.ReleaseRef{Latest: true}, nil
89+ }
90+ }
91+ return ports.ReleaseRef{}, fmt.Errorf("%w: select an id, tag or latest=true", domain.ErrInvalid)
92+}
93+
94+func (s *releaseService) GetRelease(ctx context.Context, req *connect.Request[pb.GetReleaseRequest]) (*connect.Response[pb.GetReleaseResponse], error) {
95+ repo, err := repoOf(req.Msg.GetRepo())
96+ if err != nil {
97+ return nil, connectError(ctx, err)
98+ }
99+ ref, err := releaseRef(req.Msg)
100+ if err != nil {
101+ return nil, connectError(ctx, err)
102+ }
103+ r, err := s.rel.GetRelease(ctx, caller(ctx), repo, ref)
104+ if err != nil {
105+ return nil, connectError(ctx, err)
106+ }
107+ out, err := s.releaseMessage(ctx, repo, r)
108+ if err != nil {
109+ return nil, connectError(ctx, err)
110+ }
111+ return connect.NewResponse(&pb.GetReleaseResponse{Release: out}), nil
112+}
113+
114+func (s *releaseService) ListReleases(ctx context.Context, req *connect.Request[pb.ListReleasesRequest]) (*connect.Response[pb.ListReleasesResponse], error) {
115+ repo, err := repoOf(req.Msg.GetRepo())
116+ if err != nil {
117+ return nil, connectError(ctx, err)
118+ }
119+ page, err := s.rel.ListReleases(ctx, caller(ctx), repo, int(req.Msg.GetPageSize()), req.Msg.GetPageToken())
120+ if err != nil {
121+ return nil, connectError(ctx, err)
122+ }
123+ out := &pb.ListReleasesResponse{Releases: make([]*pb.Release, 0, len(page.Releases)), NextPageToken: page.NextPageToken}
124+ latest, err := s.latestID(ctx, repo)
125+ if err != nil {
126+ return nil, connectError(ctx, err)
127+ }
128+ for _, r := range page.Releases {
129+ m, err := s.releaseRow(ctx, repo, r, latest)
130+ if err != nil {
131+ return nil, connectError(ctx, err)
132+ }
133+ out.Releases = append(out.Releases, m)
134+ }
135+ return connect.NewResponse(out), nil
136+}
137+
138+func releaseUpdateParams(m *pb.UpdateReleaseRequest) (ports.UpdateReleaseParams, error) {
139+ var p ports.UpdateReleaseParams
140+ if len(m.GetUpdateMask().GetPaths()) == 0 {
141+ return p, fmt.Errorf("%w: update_mask is empty", domain.ErrInvalid)
142+ }
143+ for _, path := range m.GetUpdateMask().GetPaths() {
144+ switch path {
145+ case "tag":
146+ p.Tag = new(m.GetTag())
147+ case "target_commitish":
148+ p.TargetCommitish = new(m.GetTargetCommitish())
149+ case "title":
150+ p.Title = new(m.GetTitle())
151+ case "notes":
152+ p.Notes = new(m.GetNotes())
153+ case "draft":
154+ p.Draft = new(m.GetDraft())
155+ case "prerelease":
156+ p.Prerelease = new(m.GetPrerelease())
157+ case "make_latest":
158+ p.MakeLatest = new(m.GetMakeLatest())
159+ default:
160+ return p, fmt.Errorf("%w: unknown update_mask path %q", domain.ErrInvalid, path)
161+ }
162+ }
163+ return p, nil
164+}
165+
166+func (s *releaseService) UpdateRelease(ctx context.Context, req *connect.Request[pb.UpdateReleaseRequest]) (*connect.Response[pb.UpdateReleaseResponse], error) {
167+ repo, err := repoOf(req.Msg.GetRepo())
168+ if err != nil {
169+ return nil, connectError(ctx, err)
170+ }
171+ p, err := releaseUpdateParams(req.Msg)
172+ if err != nil {
173+ return nil, connectError(ctx, err)
174+ }
175+ r, err := s.rel.UpdateRelease(ctx, caller(ctx), repo, req.Msg.GetId(), p)
176+ if err != nil {
177+ return nil, connectError(ctx, err)
178+ }
179+ out, err := s.releaseMessage(ctx, repo, r)
180+ if err != nil {
181+ return nil, connectError(ctx, err)
182+ }
183+ return connect.NewResponse(&pb.UpdateReleaseResponse{Release: out}), nil
184+}
185+
186+func (s *releaseService) DeleteRelease(ctx context.Context, req *connect.Request[pb.DeleteReleaseRequest]) (*connect.Response[pb.DeleteReleaseResponse], error) {
187+ repo, err := repoOf(req.Msg.GetRepo())
188+ if err != nil {
189+ return nil, connectError(ctx, err)
190+ }
191+ if err := s.rel.DeleteRelease(ctx, caller(ctx), repo, req.Msg.GetId()); err != nil {
192+ return nil, connectError(ctx, err)
193+ }
194+ return connect.NewResponse(&pb.DeleteReleaseResponse{}), nil
195+}
196+
197+// chunkReader retains at most one message; metadata belongs to the first only.
198+type chunkReader struct {
199+ stream *connect.ClientStream[pb.UploadReleaseAssetRequest]
200+ buf []byte
201+}
202+
203+func (r *chunkReader) Read(p []byte) (int, error) {
204+ if len(p) == 0 {
205+ return 0, nil
206+ }
207+ for len(r.buf) == 0 {
208+ if !r.stream.Receive() {
209+ if err := r.stream.Err(); err != nil {
210+ return 0, err
211+ }
212+ return 0, io.EOF
213+ }
214+ m := r.stream.Msg()
215+ if m.GetRepo() != "" || m.GetReleaseId() != 0 || m.GetName() != "" || m.GetLabel() != "" || m.GetContentType() != "" || m.GetSize() != 0 || m.GetReplace() {
216+ return 0, fmt.Errorf("%w: upload metadata is in the first message only", domain.ErrInvalid)
217+ }
218+ if err := checkBlockData(m.GetData()); err != nil {
219+ return 0, err
220+ }
221+ r.buf = m.GetData()
222+ }
223+ n := copy(p, r.buf)
224+ r.buf = r.buf[n:]
225+ return n, nil
226+}
227+
228+func (s *releaseService) UploadReleaseAsset(ctx context.Context, stream *connect.ClientStream[pb.UploadReleaseAssetRequest]) (*connect.Response[pb.UploadReleaseAssetResponse], error) {
229+ if !stream.Receive() {
230+ err := stream.Err()
231+ if err == nil {
232+ err = fmt.Errorf("%w: the stream has no message", domain.ErrInvalid)
233+ }
234+ return nil, connectError(ctx, err)
235+ }
236+ m := stream.Msg()
237+ repo, err := repoOf(m.GetRepo())
238+ if err != nil {
239+ return nil, connectError(ctx, err)
240+ }
241+ if m.GetSize() < 0 {
242+ return nil, connectError(ctx, fmt.Errorf("%w: size must be nonnegative", domain.ErrInvalid))
243+ }
244+ if err := checkBlockData(m.GetData()); err != nil {
245+ return nil, connectError(ctx, err)
246+ }
247+ a, err := s.rel.UploadAsset(ctx, caller(ctx), repo, ports.UploadAssetParams{ReleaseID: m.GetReleaseId(), Name: m.GetName(), Label: m.GetLabel(), ContentType: m.GetContentType(), Size: m.GetSize(), Replace: m.GetReplace()}, &chunkReader{stream: stream, buf: m.GetData()})
248+ if err != nil {
249+ return nil, connectError(ctx, err)
250+ }
251+ return connect.NewResponse(&pb.UploadReleaseAssetResponse{Asset: assetToPB(a)}), nil
252+}
253+
254+func assetUpdateParams(m *pb.UpdateReleaseAssetRequest) (ports.UpdateAssetParams, error) {
255+ var p ports.UpdateAssetParams
256+ if len(m.GetUpdateMask().GetPaths()) == 0 {
257+ return p, fmt.Errorf("%w: update_mask is empty", domain.ErrInvalid)
258+ }
259+ for _, path := range m.GetUpdateMask().GetPaths() {
260+ switch path {
261+ case "name":
262+ p.Name = new(m.GetName())
263+ case "label":
264+ p.Label = new(m.GetLabel())
265+ default:
266+ return p, fmt.Errorf("%w: unknown update_mask path %q", domain.ErrInvalid, path)
267+ }
268+ }
269+ return p, nil
270+}
271+
272+func (s *releaseService) UpdateReleaseAsset(ctx context.Context, req *connect.Request[pb.UpdateReleaseAssetRequest]) (*connect.Response[pb.UpdateReleaseAssetResponse], error) {
273+ repo, err := repoOf(req.Msg.GetRepo())
274+ if err != nil {
275+ return nil, connectError(ctx, err)
276+ }
277+ p, err := assetUpdateParams(req.Msg)
278+ if err != nil {
279+ return nil, connectError(ctx, err)
280+ }
281+ a, err := s.rel.UpdateAsset(ctx, caller(ctx), repo, req.Msg.GetAssetId(), p)
282+ if err != nil {
283+ return nil, connectError(ctx, err)
284+ }
285+ return connect.NewResponse(&pb.UpdateReleaseAssetResponse{Asset: assetToPB(a)}), nil
286+}
287+
288+func (s *releaseService) DeleteReleaseAsset(ctx context.Context, req *connect.Request[pb.DeleteReleaseAssetRequest]) (*connect.Response[pb.DeleteReleaseAssetResponse], error) {
289+ repo, err := repoOf(req.Msg.GetRepo())
290+ if err != nil {
291+ return nil, connectError(ctx, err)
292+ }
293+ if err := s.rel.DeleteAsset(ctx, caller(ctx), repo, req.Msg.GetAssetId()); err != nil {
294+ return nil, connectError(ctx, err)
295+ }
296+ return connect.NewResponse(&pb.DeleteReleaseAssetResponse{}), nil
297+}
298+
299+func assetRef(m *pb.DownloadReleaseAssetRequest) (ports.AssetRef, error) {
300+ switch v := m.GetSelector().(type) {
301+ case *pb.DownloadReleaseAssetRequest_AssetId:
302+ if v.AssetId > 0 {
303+ return ports.AssetRef{ID: v.AssetId}, nil
304+ }
305+ case *pb.DownloadReleaseAssetRequest_Tag:
306+ if v.Tag.GetTag() != "" && v.Tag.GetName() != "" {
307+ return ports.AssetRef{Tag: v.Tag.GetTag(), Name: v.Tag.GetName()}, nil
308+ }
309+ case *pb.DownloadReleaseAssetRequest_Latest:
310+ if v.Latest.GetLatest() && v.Latest.GetName() != "" {
311+ return ports.AssetRef{Latest: true, Name: v.Latest.GetName()}, nil
312+ }
313+ }
314+ return ports.AssetRef{}, fmt.Errorf("%w: select an asset id, tag/name or latest=true/name", domain.ErrInvalid)
315+}
316+
317+func (s *releaseService) DownloadReleaseAsset(ctx context.Context, req *connect.Request[pb.DownloadReleaseAssetRequest], stream *connect.ServerStream[pb.DownloadReleaseAssetResponse]) error {
318+ repo, err := repoOf(req.Msg.GetRepo())
319+ if err != nil {
320+ return connectError(ctx, err)
321+ }
322+ ref, err := assetRef(req.Msg)
323+ if err != nil {
324+ return connectError(ctx, err)
325+ }
326+ err = s.rel.DownloadAsset(ctx, caller(ctx), repo, ref, req.Msg.GetOffset(), -1, func(c ports.AssetChunk) error {
327+ m := &pb.DownloadReleaseAssetResponse{Chunk: c.Data}
328+ if c.Asset != nil {
329+ m.Asset = assetToPB(*c.Asset)
330+ }
331+ return stream.Send(m)
332+ })
333+ return connectError(ctx, err)
334+}
335+
336+func (s *releaseService) ListReleaseAssets(ctx context.Context, req *connect.Request[pb.ListReleaseAssetsRequest]) (*connect.Response[pb.ListReleaseAssetsResponse], error) {
337+ repo, err := repoOf(req.Msg.GetRepo())
338+ if err != nil {
339+ return nil, connectError(ctx, err)
340+ }
341+ assets, err := s.rel.ListAssets(ctx, caller(ctx), repo, req.Msg.GetReleaseId())
342+ if err != nil {
343+ return nil, connectError(ctx, err)
344+ }
345+ return connect.NewResponse(&pb.ListReleaseAssetsResponse{Assets: assetsToPB(assets)}), nil
346+}
347+
348+func (s *releaseService) GetReleaseUsage(ctx context.Context, req *connect.Request[pb.GetReleaseUsageRequest]) (*connect.Response[pb.GetReleaseUsageResponse], error) {
349+ repo, err := repoOf(req.Msg.GetRepo())
350+ if err != nil {
351+ return nil, connectError(ctx, err)
352+ }
353+ u, err := s.rel.Usage(ctx, caller(ctx), repo)
354+ if err != nil {
355+ return nil, connectError(ctx, err)
356+ }
357+ return connect.NewResponse(&pb.GetReleaseUsageResponse{UsedBytes: u.Used, QuotaBytes: u.Limits.Quota, AssetMaxBytes: u.Limits.AssetMax, AssetsMax: int32(u.Limits.AssetsMax)}), nil
358+}
A internal/adapters/rpc/release_test.go +472 -0
@@ -0,0 +1,472 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "bytes"
7+ "context"
8+ "io"
9+ "net/http/httptest"
10+ "testing"
11+ "time"
12+
13+ "connectrpc.com/connect"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+ "google.golang.org/protobuf/proto"
18+ "google.golang.org/protobuf/types/known/fieldmaskpb"
19+
20+ pb "bnema.dev/ermite-api/gen/ermite"
21+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
22+ "bnema.dev/ermite/internal/domain"
23+ "bnema.dev/ermite/internal/ports"
24+ "bnema.dev/ermite/internal/ports/mocks"
25+)
26+
27+func releaseClient(t *testing.T, rel ports.ReleaseService) (ermiteconnect.ReleaseServiceClient, ermiteconnect.ServerServiceClient) {
28+ t.Helper()
29+ srv := httptest.NewUnstartedServer(New(Config{Releases: rel, Sessions: mocks.NewSessionService(t), SigningKey: mocks.NewSigningKeyService(t)}).Handler())
30+ srv.EnableHTTP2 = true
31+ srv.StartTLS()
32+ t.Cleanup(srv.Close)
33+ return ermiteconnect.NewReleaseServiceClient(srv.Client(), srv.URL), ermiteconnect.NewServerServiceClient(srv.Client(), srv.URL)
34+}
35+
36+func TestReleaseRPCUnary(t *testing.T) {
37+ ctx := context.Background()
38+ repo := mustRepoName(t, "demo")
39+ now := time.Now().UTC().Truncate(time.Second)
40+ r := domain.Release{ID: 7, Repo: repo, Tag: "v1", Title: "title", Notes: "notes", Prerelease: true, TargetCommitish: "abc", Author: "owner", CreatedAt: now, PublishedAt: now, UpdatedAt: now}
41+ a := domain.ReleaseAsset{ID: 9, ReleaseID: 7, Repo: repo, Name: "a.zip", Label: "label", ContentType: "application/zip", Size: 3, Digest: "sha256:123", Uploader: "owner", CreatedAt: now, UpdatedAt: now}
42+ expected := releaseToPB(r)
43+ expected.Latest = true
44+ expected.Assets = []*pb.ReleaseAsset{assetToPB(a)}
45+ for _, method := range []string{"create", "get id", "get tag", "get latest", "list", "update", "delete", "update asset", "delete asset", "list assets", "usage"} {
46+ t.Run(method, func(t *testing.T) {
47+ rel := mocks.NewReleaseService(t)
48+ c, _ := releaseClient(t, rel)
49+ p := domain.Anonymous()
50+ hydrate := func() {
51+ rel.EXPECT().GetRelease(mock.Anything, p, repo, ports.ReleaseRef{Latest: true}).Return(r, nil).Once()
52+ rel.EXPECT().ListAssets(mock.Anything, p, repo, r.ID).Return([]domain.ReleaseAsset{a}, nil).Once()
53+ }
54+ switch method {
55+ case "create":
56+ rel.EXPECT().CreateRelease(mock.Anything, p, repo, ports.CreateReleaseParams{Tag: "v1", TargetCommitish: "abc", Title: "title", Notes: "notes", Draft: true, Prerelease: true, MakeLatest: true}).Return(r, nil).Once()
57+ hydrate()
58+ res, err := c.CreateRelease(ctx, connect.NewRequest(&pb.CreateReleaseRequest{Repo: "demo", Tag: "v1", TargetCommitish: "abc", Title: "title", Notes: "notes", Draft: true, Prerelease: true, MakeLatest: true}))
59+ require.NoError(t, err)
60+ assert.True(t, proto.Equal(expected, res.Msg.GetRelease()))
61+ case "get id", "get tag", "get latest":
62+ req := &pb.GetReleaseRequest{Repo: "demo"}
63+ ref := ports.ReleaseRef{}
64+ switch method {
65+ case "get id":
66+ req.Selector = &pb.GetReleaseRequest_Id{Id: 7}
67+ ref.ID = 7
68+ case "get tag":
69+ req.Selector = &pb.GetReleaseRequest_Tag{Tag: "v1"}
70+ ref.Tag = "v1"
71+ default:
72+ req.Selector = &pb.GetReleaseRequest_Latest{Latest: true}
73+ ref.Latest = true
74+ }
75+ rel.EXPECT().GetRelease(mock.Anything, p, repo, ref).Return(r, nil).Once()
76+ hydrate()
77+ res, err := c.GetRelease(ctx, connect.NewRequest(req))
78+ require.NoError(t, err)
79+ assert.True(t, proto.Equal(expected, res.Msg.GetRelease()))
80+ case "list":
81+ rel.EXPECT().ListReleases(mock.Anything, p, repo, 17, "cursor").Return(ports.ReleasePage{Releases: []domain.Release{r}, NextPageToken: "next"}, nil).Once()
82+ hydrate()
83+ res, err := c.ListReleases(ctx, connect.NewRequest(&pb.ListReleasesRequest{Repo: "demo", PageSize: 17, PageToken: "cursor"}))
84+ require.NoError(t, err)
85+ require.Len(t, res.Msg.Releases, 1)
86+ assert.True(t, proto.Equal(expected, res.Msg.Releases[0]))
87+ assert.Equal(t, "next", res.Msg.NextPageToken)
88+ case "update":
89+ rel.EXPECT().UpdateRelease(mock.Anything, p, repo, int64(7), ports.UpdateReleaseParams{Title: new(""), Draft: new(false)}).Return(r, nil).Once()
90+ hydrate()
91+ res, err := c.UpdateRelease(ctx, connect.NewRequest(&pb.UpdateReleaseRequest{Repo: "demo", Id: 7, Tag: "ignored", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"title", "draft"}}}))
92+ require.NoError(t, err)
93+ assert.True(t, proto.Equal(expected, res.Msg.GetRelease()))
94+ case "delete":
95+ rel.EXPECT().DeleteRelease(mock.Anything, p, repo, int64(7)).Return(nil).Once()
96+ _, err := c.DeleteRelease(ctx, connect.NewRequest(&pb.DeleteReleaseRequest{Repo: "demo", Id: 7}))
97+ require.NoError(t, err)
98+ case "update asset":
99+ rel.EXPECT().UpdateAsset(mock.Anything, p, repo, int64(9), ports.UpdateAssetParams{Name: new("new.zip"), Label: new("")}).Return(a, nil).Once()
100+ res, err := c.UpdateReleaseAsset(ctx, connect.NewRequest(&pb.UpdateReleaseAssetRequest{Repo: "demo", AssetId: 9, Name: "new.zip", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"name", "label"}}}))
101+ require.NoError(t, err)
102+ assert.True(t, proto.Equal(assetToPB(a), res.Msg.GetAsset()))
103+ case "delete asset":
104+ rel.EXPECT().DeleteAsset(mock.Anything, p, repo, int64(9)).Return(nil).Once()
105+ _, err := c.DeleteReleaseAsset(ctx, connect.NewRequest(&pb.DeleteReleaseAssetRequest{Repo: "demo", AssetId: 9}))
106+ require.NoError(t, err)
107+ case "list assets":
108+ rel.EXPECT().ListAssets(mock.Anything, p, repo, int64(7)).Return([]domain.ReleaseAsset{a}, nil).Once()
109+ res, err := c.ListReleaseAssets(ctx, connect.NewRequest(&pb.ListReleaseAssetsRequest{Repo: "demo", ReleaseId: 7}))
110+ require.NoError(t, err)
111+ require.Len(t, res.Msg.Assets, 1)
112+ assert.True(t, proto.Equal(assetToPB(a), res.Msg.Assets[0]))
113+ case "usage":
114+ rel.EXPECT().Usage(mock.Anything, p, repo).Return(ports.ReleaseUsage{Used: 1, Limits: ports.ReleaseLimits{Quota: 2, AssetMax: 3, AssetsMax: 4}}, nil).Once()
115+ res, err := c.GetReleaseUsage(ctx, connect.NewRequest(&pb.GetReleaseUsageRequest{Repo: "demo"}))
116+ require.NoError(t, err)
117+ assert.True(t, proto.Equal(&pb.GetReleaseUsageResponse{UsedBytes: 1, QuotaBytes: 2, AssetMaxBytes: 3, AssetsMax: 4}, res.Msg))
118+ }
119+ })
120+ }
121+}
122+
123+func TestReleaseFeatureAndMount(t *testing.T) {
124+ for _, enabled := range []bool{false, true} {
125+ t.Run(map[bool]string{false: "absent", true: "present"}[enabled], func(t *testing.T) {
126+ var rel ports.ReleaseService
127+ if enabled {
128+ rel = mocks.NewReleaseService(t)
129+ }
130+ c, server := releaseClient(t, rel)
131+ res, err := server.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{}))
132+ require.NoError(t, err)
133+ assert.Equal(t, enabled, containsReleaseFeature(res.Msg.Features))
134+ assert.NotContains(t, Features, FeatureReleases)
135+ if !enabled {
136+ _, err := c.GetReleaseUsage(context.Background(), connect.NewRequest(&pb.GetReleaseUsageRequest{Repo: "demo"}))
137+ requireCode(t, connect.CodeUnimplemented, err)
138+ }
139+ })
140+ }
141+}
142+func containsReleaseFeature(features []string) bool {
143+ for _, f := range features {
144+ if f == FeatureReleases {
145+ return true
146+ }
147+ }
148+ return false
149+}
150+
151+func TestReleaseRPCUpload(t *testing.T) {
152+ for _, firstData := range []bool{false, true} {
153+ t.Run(map[bool]string{false: "header only", true: "header with data"}[firstData], func(t *testing.T) {
154+ rel := mocks.NewReleaseService(t)
155+ c, _ := releaseClient(t, rel)
156+ full := bytes.Repeat([]byte("x"), domain.CIArtifactChunk)
157+ tail := []byte("tail")
158+ want := append(bytes.Clone(full), tail...)
159+ rel.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.UploadAssetParams{ReleaseID: 7, Name: "a", Label: "l", ContentType: "application/octet-stream", Size: int64(len(want)), Replace: true}, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.UploadAssetParams, r io.Reader) (domain.ReleaseAsset, error) {
160+ got, err := io.ReadAll(r)
161+ assert.NoError(t, err)
162+ assert.Equal(t, want, got)
163+ return domain.ReleaseAsset{ID: 9, Size: int64(len(got))}, err
164+ }).Once()
165+ stream := c.UploadReleaseAsset(context.Background())
166+ first := &pb.UploadReleaseAssetRequest{Repo: "demo", ReleaseId: 7, Name: "a", Label: "l", ContentType: "application/octet-stream", Size: int64(len(want)), Replace: true}
167+ if firstData {
168+ first.Data = full
169+ }
170+ require.NoError(t, stream.Send(first))
171+ if !firstData {
172+ require.NoError(t, stream.Send(&pb.UploadReleaseAssetRequest{Data: full}))
173+ }
174+ require.NoError(t, stream.Send(&pb.UploadReleaseAssetRequest{Data: tail}))
175+ res, err := stream.CloseAndReceive()
176+ require.NoError(t, err)
177+ assert.EqualValues(t, 9, res.Msg.Asset.Id)
178+ assert.EqualValues(t, len(want), res.Msg.Asset.Size)
179+ })
180+ }
181+}
182+
183+func TestReleaseRPCUploadInvalid(t *testing.T) {
184+ tests := []struct {
185+ name string
186+ messages []*pb.UploadReleaseAssetRequest
187+ read bool
188+ code connect.Code
189+ }{
190+ {name: "empty", code: connect.CodeInvalidArgument},
191+ {name: "negative size", messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo", Size: -1}}, code: connect.CodeInvalidArgument},
192+ {name: "bad repo", messages: []*pb.UploadReleaseAssetRequest{{Repo: "../bad"}}, code: connect.CodeInvalidArgument},
193+ {name: "first chunk too big", messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo", Data: make([]byte, domain.CIArtifactChunk+1)}}, code: connect.CodeInvalidArgument},
194+ {name: "later chunk too big", messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo"}, {Data: make([]byte, domain.CIArtifactChunk+1)}}, read: true, code: connect.CodeInvalidArgument},
195+ {name: "message exceeds envelope", messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo", Data: make([]byte, RunnerMaxRequestBytes+1)}}, code: connect.CodeResourceExhausted},
196+ }
197+ for _, m := range []*pb.UploadReleaseAssetRequest{{Repo: "demo"}, {ReleaseId: 1}, {Name: "a"}, {Label: "l"}, {ContentType: "text/plain"}, {Size: 1}, {Replace: true}} {
198+ tests = append(tests, struct {
199+ name string
200+ messages []*pb.UploadReleaseAssetRequest
201+ read bool
202+ code connect.Code
203+ }{name: "repeated metadata " + m.String(), messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo"}, m}, read: true, code: connect.CodeInvalidArgument})
204+ }
205+ for _, tt := range tests {
206+ t.Run(tt.name, func(t *testing.T) {
207+ rel := mocks.NewReleaseService(t)
208+ c, _ := releaseClient(t, rel)
209+ if tt.read {
210+ rel.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.UploadAssetParams, r io.Reader) (domain.ReleaseAsset, error) {
211+ _, err := io.ReadAll(r)
212+ return domain.ReleaseAsset{}, err
213+ }).Once()
214+ }
215+ stream := c.UploadReleaseAsset(context.Background())
216+ for _, m := range tt.messages {
217+ if err := stream.Send(m); err != nil {
218+ break
219+ }
220+ }
221+ _, err := stream.CloseAndReceive()
222+ requireCode(t, tt.code, err)
223+ })
224+ }
225+}
226+
227+func TestReleaseRPCDownload(t *testing.T) {
228+ for _, selector := range []string{"id", "tag", "latest"} {
229+ t.Run(selector, func(t *testing.T) {
230+ rel := mocks.NewReleaseService(t)
231+ c, _ := releaseClient(t, rel)
232+ req := &pb.DownloadReleaseAssetRequest{Repo: "demo", Offset: 3}
233+ ref := ports.AssetRef{}
234+ switch selector {
235+ case "id":
236+ req.Selector = &pb.DownloadReleaseAssetRequest_AssetId{AssetId: 9}
237+ ref.ID = 9
238+ case "tag":
239+ req.Selector = &pb.DownloadReleaseAssetRequest_Tag{Tag: &pb.ReleaseAssetTag{Tag: "v1", Name: "a"}}
240+ ref.Tag = "v1"
241+ ref.Name = "a"
242+ default:
243+ req.Selector = &pb.DownloadReleaseAssetRequest_Latest{Latest: &pb.ReleaseAssetLatest{Latest: true, Name: "a"}}
244+ ref.Latest = true
245+ ref.Name = "a"
246+ }
247+ a := domain.ReleaseAsset{ID: 9, ReleaseID: 7, Name: "a", Size: 8}
248+ rel.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ref, int64(3), int64(-1), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error {
249+ if err := send(ports.AssetChunk{Asset: &a}); err != nil {
250+ return err
251+ }
252+ if err := send(ports.AssetChunk{Data: []byte("12")}); err != nil {
253+ return err
254+ }
255+ return send(ports.AssetChunk{Data: []byte("345")})
256+ }).Once()
257+ stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(req))
258+ require.NoError(t, err)
259+ defer func() { _ = stream.Close() }()
260+ require.True(t, stream.Receive())
261+ assert.True(t, proto.Equal(assetToPB(a), stream.Msg().Asset))
262+ assert.Empty(t, stream.Msg().Chunk)
263+ var got []byte
264+ for stream.Receive() {
265+ assert.Nil(t, stream.Msg().Asset)
266+ got = append(got, stream.Msg().Chunk...)
267+ }
268+ require.NoError(t, stream.Err())
269+ assert.Equal(t, "12345", string(got))
270+ })
271+ }
272+}
273+
274+func TestReleaseRPCErrors(t *testing.T) {
275+ for _, tt := range []struct {
276+ err error
277+ code connect.Code
278+ }{{domain.ErrNotFound, connect.CodeNotFound}, {domain.ErrAlreadyExists, connect.CodeAlreadyExists}, {domain.ErrResourceExhausted, connect.CodeResourceExhausted}, {domain.ErrForbidden, connect.CodePermissionDenied}, {domain.ErrOutOfRange, connect.CodeOutOfRange}, {domain.ErrPreconditionFailed, connect.CodeFailedPrecondition}, {domain.ErrInvalid, connect.CodeInvalidArgument}} {
279+ t.Run(tt.code.String(), func(t *testing.T) {
280+ rel := mocks.NewReleaseService(t)
281+ c, _ := releaseClient(t, rel)
282+ rel.EXPECT().GetRelease(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.ReleaseRef{ID: 7}).Return(domain.Release{}, tt.err).Once()
283+ _, err := c.GetRelease(context.Background(), connect.NewRequest(&pb.GetReleaseRequest{Repo: "demo", Selector: &pb.GetReleaseRequest_Id{Id: 7}}))
284+ requireCode(t, tt.code, err)
285+ rel.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.AssetRef{ID: 9}, int64(0), int64(-1), mock.Anything).Return(tt.err).Once()
286+ stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(&pb.DownloadReleaseAssetRequest{Repo: "demo", Selector: &pb.DownloadReleaseAssetRequest_AssetId{AssetId: 9}}))
287+ if err == nil {
288+ defer func() { _ = stream.Close() }()
289+ assert.False(t, stream.Receive())
290+ err = stream.Err()
291+ }
292+ requireCode(t, tt.code, err)
293+ })
294+ }
295+}
296+
297+func TestReleaseMasksAndSelectors(t *testing.T) {
298+ paths := []string{"tag", "target_commitish", "title", "notes", "draft", "prerelease", "make_latest"}
299+ p, err := releaseUpdateParams(&pb.UpdateReleaseRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}})
300+ require.NoError(t, err)
301+ assert.Equal(t, ports.UpdateReleaseParams{Tag: new(""), TargetCommitish: new(""), Title: new(""), Notes: new(""), Draft: new(false), Prerelease: new(false), MakeLatest: new(false)}, p)
302+ for _, paths := range [][]string{nil, {"unknown"}, {"*"}, {"title", "assets"}} {
303+ _, err := releaseUpdateParams(&pb.UpdateReleaseRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}})
304+ assert.ErrorIs(t, err, domain.ErrInvalid)
305+ _, err = assetUpdateParams(&pb.UpdateReleaseAssetRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}})
306+ assert.ErrorIs(t, err, domain.ErrInvalid)
307+ }
308+ for _, req := range []*pb.GetReleaseRequest{{}, {Selector: &pb.GetReleaseRequest_Id{Id: -1}}, {Selector: &pb.GetReleaseRequest_Tag{}}, {Selector: &pb.GetReleaseRequest_Latest{Latest: false}}} {
309+ _, err := releaseRef(req)
310+ assert.ErrorIs(t, err, domain.ErrInvalid)
311+ }
312+ for _, req := range []*pb.DownloadReleaseAssetRequest{{}, {Selector: &pb.DownloadReleaseAssetRequest_AssetId{}}, {Selector: &pb.DownloadReleaseAssetRequest_Tag{}}, {Selector: &pb.DownloadReleaseAssetRequest_Tag{Tag: &pb.ReleaseAssetTag{Tag: "v1"}}}, {Selector: &pb.DownloadReleaseAssetRequest_Latest{Latest: &pb.ReleaseAssetLatest{Name: "a"}}}} {
313+ _, err := assetRef(req)
314+ assert.ErrorIs(t, err, domain.ErrInvalid)
315+ }
316+}
317+
318+func TestReleaseEvents(t *testing.T) {
319+ r := domain.Release{ID: 7, Repo: mustRepoName(t, "demo"), Tag: "v1", Draft: true, Prerelease: true}
320+ at := time.Now().UTC()
321+ events := []domain.Event{domain.ReleasePublished{Release: r, At: at}, domain.ReleaseUpdated{Release: r, At: at}, domain.ReleaseDeleted{Release: r, At: at}, domain.ReleaseUnpublished{Release: r, At: at}}
322+ expected := []*pb.Event{
323+ {Kind: &pb.Event_ReleasePublished{ReleasePublished: &pb.ReleasePublished{Repo: "demo", Id: 7, Tag: "v1", Draft: true, Prerelease: true}}},
324+ {Kind: &pb.Event_ReleaseUpdated{ReleaseUpdated: &pb.ReleaseUpdated{Repo: "demo", Id: 7, Tag: "v1", Draft: true, Prerelease: true}}},
325+ {Kind: &pb.Event_ReleaseDeleted{ReleaseDeleted: &pb.ReleaseDeleted{Repo: "demo", Id: 7, Tag: "v1"}}},
326+ {Kind: &pb.Event_ReleaseUnpublished{ReleaseUnpublished: &pb.ReleaseUnpublished{Repo: "demo", Id: 7, Tag: "v1", Draft: true, Prerelease: true}}},
327+ }
328+ for i, ev := range events {
329+ t.Run(ev.EventKind(), func(t *testing.T) {
330+ expected[i].Id = "event"
331+ expected[i].Time = timestamp(at)
332+ assert.True(t, proto.Equal(expected[i], watchEventToPB(ports.WatchEvent{ID: "event", Event: ev})))
333+ })
334+ }
335+}
336+
337+func TestReleaseResponseEnrichment(t *testing.T) {
338+ repo := mustRepoName(t, "demo")
339+ for _, tt := range []struct {
340+ name string
341+ latest domain.Release
342+ latestErr, assetsErr error
343+ code connect.Code
344+ wantLatest bool
345+ }{
346+ {name: "draft without latest", latestErr: domain.ErrNotFound},
347+ {name: "different latest", latest: domain.Release{ID: 8}},
348+ {name: "current latest", latest: domain.Release{ID: 7}, wantLatest: true},
349+ {name: "latest lookup fails", latestErr: domain.ErrUnavailable, code: connect.CodeUnavailable},
350+ {name: "assets lookup fails", assetsErr: domain.ErrNotFound, code: connect.CodeNotFound},
351+ } {
352+ t.Run(tt.name, func(t *testing.T) {
353+ rel := mocks.NewReleaseService(t)
354+ rel.EXPECT().GetRelease(mock.Anything, domain.Owner(), repo, ports.ReleaseRef{ID: 7}).Return(domain.Release{ID: 7, Repo: repo, Draft: true}, nil).Once()
355+ rel.EXPECT().GetRelease(mock.Anything, domain.Owner(), repo, ports.ReleaseRef{Latest: true}).Return(tt.latest, tt.latestErr).Once()
356+ if tt.code != connect.CodeUnavailable {
357+ rel.EXPECT().ListAssets(mock.Anything, domain.Owner(), repo, int64(7)).Return(nil, tt.assetsErr).Once()
358+ }
359+ res, err := (&releaseService{rel}).GetRelease(ownerCtx(), connect.NewRequest(&pb.GetReleaseRequest{Repo: "demo", Selector: &pb.GetReleaseRequest_Id{Id: 7}}))
360+ if tt.code != 0 {
361+ requireCode(t, tt.code, err)
362+ return
363+ }
364+ require.NoError(t, err)
365+ assert.Equal(t, tt.wantLatest, res.Msg.Release.Latest)
366+ assert.Empty(t, res.Msg.Release.Assets)
367+ assert.Nil(t, res.Msg.Release.PublishedAt)
368+ })
369+ }
370+}
371+
372+func TestReleaseRPCAuthenticatedCaller(t *testing.T) {
373+ rel := mocks.NewReleaseService(t)
374+ auth := mocks.NewAuthService(t)
375+ auth.EXPECT().Authenticate(mock.Anything, "secret").Return(domain.Owner(), nil).Once()
376+ rel.EXPECT().DeleteRelease(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(7)).Return(nil).Once()
377+ srv := httptest.NewServer(New(Config{Releases: rel, Auth: auth, Sessions: mocks.NewSessionService(t)}).Handler())
378+ defer srv.Close()
379+ c := ermiteconnect.NewReleaseServiceClient(srv.Client(), srv.URL)
380+ req := connect.NewRequest(&pb.DeleteReleaseRequest{Repo: "demo", Id: 7})
381+ req.Header().Set("Authorization", "Bearer secret")
382+ _, err := c.DeleteRelease(context.Background(), req)
383+ require.NoError(t, err)
384+}
385+
386+func TestReleaseRPCDownloadFullChunk(t *testing.T) {
387+ rel := mocks.NewReleaseService(t)
388+ c, _ := releaseClient(t, rel)
389+ data := bytes.Repeat([]byte("x"), domain.CIArtifactChunk)
390+ rel.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.AssetRef{ID: 9}, int64(0), int64(-1), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error {
391+ if err := send(ports.AssetChunk{Asset: &domain.ReleaseAsset{ID: 9, Size: int64(len(data))}}); err != nil {
392+ return err
393+ }
394+ return send(ports.AssetChunk{Data: data})
395+ }).Once()
396+ stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(&pb.DownloadReleaseAssetRequest{Repo: "demo", Selector: &pb.DownloadReleaseAssetRequest_AssetId{AssetId: 9}}))
397+ require.NoError(t, err)
398+ defer func() { _ = stream.Close() }()
399+ require.True(t, stream.Receive())
400+ require.NotNil(t, stream.Msg().Asset)
401+ require.True(t, stream.Receive())
402+ assert.Equal(t, data, stream.Msg().Chunk)
403+ assert.False(t, stream.Receive())
404+ require.NoError(t, stream.Err())
405+}
406+
407+func TestReleaseRPCRejectsInvalidMasksAndSelectors(t *testing.T) {
408+ c, _ := releaseClient(t, mocks.NewReleaseService(t))
409+ _, err := c.UpdateRelease(context.Background(), connect.NewRequest(&pb.UpdateReleaseRequest{Repo: "demo", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"unknown"}}}))
410+ requireCode(t, connect.CodeInvalidArgument, err)
411+ _, err = c.UpdateReleaseAsset(context.Background(), connect.NewRequest(&pb.UpdateReleaseAssetRequest{Repo: "demo"}))
412+ requireCode(t, connect.CodeInvalidArgument, err)
413+ _, err = c.GetRelease(context.Background(), connect.NewRequest(&pb.GetReleaseRequest{Repo: "demo", Selector: &pb.GetReleaseRequest_Latest{Latest: false}}))
414+ requireCode(t, connect.CodeInvalidArgument, err)
415+ stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(&pb.DownloadReleaseAssetRequest{Repo: "demo", Selector: &pb.DownloadReleaseAssetRequest_Latest{Latest: &pb.ReleaseAssetLatest{Name: "a"}}}))
416+ if err == nil {
417+ defer func() { _ = stream.Close() }()
418+ assert.False(t, stream.Receive())
419+ err = stream.Err()
420+ }
421+ requireCode(t, connect.CodeInvalidArgument, err)
422+}
423+
424+func TestReleaseRPCListResolvesLatestOnce(t *testing.T) {
425+ repo := mustRepoName(t, "demo")
426+ rel := mocks.NewReleaseService(t)
427+ c, _ := releaseClient(t, rel)
428+ p := domain.Anonymous()
429+ rows := []domain.Release{{ID: 3, Repo: repo, Tag: "c"}, {ID: 2, Repo: repo, Tag: "b"}, {ID: 1, Repo: repo, Tag: "a"}}
430+ rel.EXPECT().ListReleases(mock.Anything, p, repo, 0, "").Return(ports.ReleasePage{Releases: rows}, nil).Once()
431+ rel.EXPECT().GetRelease(mock.Anything, p, repo, ports.ReleaseRef{Latest: true}).Return(rows[1], nil).Once()
432+ for _, r := range rows {
433+ rel.EXPECT().ListAssets(mock.Anything, p, repo, r.ID).Return(nil, nil).Once()
434+ }
435+ res, err := c.ListReleases(context.Background(), connect.NewRequest(&pb.ListReleasesRequest{Repo: "demo"}))
436+ require.NoError(t, err)
437+ require.Len(t, res.Msg.Releases, 3)
438+ for _, m := range res.Msg.Releases {
439+ assert.Equal(t, m.Id == 2, m.Latest, m.Tag)
440+ }
441+}
442+
443+func TestReleaseRPCListWithoutLatest(t *testing.T) {
444+ repo := mustRepoName(t, "demo")
445+ rel := mocks.NewReleaseService(t)
446+ c, _ := releaseClient(t, rel)
447+ p := domain.Anonymous()
448+ rel.EXPECT().ListReleases(mock.Anything, p, repo, 0, "").Return(ports.ReleasePage{Releases: []domain.Release{{ID: 1, Repo: repo}}}, nil).Once()
449+ rel.EXPECT().GetRelease(mock.Anything, p, repo, ports.ReleaseRef{Latest: true}).Return(domain.Release{}, domain.ErrNotFound).Once()
450+ rel.EXPECT().ListAssets(mock.Anything, p, repo, int64(1)).Return(nil, nil).Once()
451+ res, err := c.ListReleases(context.Background(), connect.NewRequest(&pb.ListReleasesRequest{Repo: "demo"}))
452+ require.NoError(t, err)
453+ require.Len(t, res.Msg.Releases, 1)
454+ assert.False(t, res.Msg.Releases[0].Latest)
455+}
456+
457+func TestReleaseRPCDownloadZeroByteAsset(t *testing.T) {
458+ rel := mocks.NewReleaseService(t)
459+ c, _ := releaseClient(t, rel)
460+ a := domain.ReleaseAsset{ID: 9, ReleaseID: 7, Name: "empty", Size: 0}
461+ // The use case sends the metadata and no data for an empty asset.
462+ rel.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.AssetRef{ID: 9}, int64(0), int64(-1), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error {
463+ return send(ports.AssetChunk{Asset: &a})
464+ }).Once()
465+ stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(&pb.DownloadReleaseAssetRequest{Repo: "demo", Selector: &pb.DownloadReleaseAssetRequest_AssetId{AssetId: 9}}))
466+ require.NoError(t, err)
467+ defer func() { _ = stream.Close() }()
468+ require.True(t, stream.Receive())
469+ assert.True(t, proto.Equal(assetToPB(a), stream.Msg().Asset))
470+ assert.False(t, stream.Receive())
471+ require.NoError(t, stream.Err())
472+}
@@ -0,0 +1,472 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "bytes"
7+ "context"
8+ "io"
9+ "net/http/httptest"
10+ "testing"
11+ "time"
12+
13+ "connectrpc.com/connect"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+ "google.golang.org/protobuf/proto"
18+ "google.golang.org/protobuf/types/known/fieldmaskpb"
19+
20+ pb "bnema.dev/ermite-api/gen/ermite"
21+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
22+ "bnema.dev/ermite/internal/domain"
23+ "bnema.dev/ermite/internal/ports"
24+ "bnema.dev/ermite/internal/ports/mocks"
25+)
26+
27+func releaseClient(t *testing.T, rel ports.ReleaseService) (ermiteconnect.ReleaseServiceClient, ermiteconnect.ServerServiceClient) {
28+ t.Helper()
29+ srv := httptest.NewUnstartedServer(New(Config{Releases: rel, Sessions: mocks.NewSessionService(t), SigningKey: mocks.NewSigningKeyService(t)}).Handler())
30+ srv.EnableHTTP2 = true
31+ srv.StartTLS()
32+ t.Cleanup(srv.Close)
33+ return ermiteconnect.NewReleaseServiceClient(srv.Client(), srv.URL), ermiteconnect.NewServerServiceClient(srv.Client(), srv.URL)
34+}
35+
36+func TestReleaseRPCUnary(t *testing.T) {
37+ ctx := context.Background()
38+ repo := mustRepoName(t, "demo")
39+ now := time.Now().UTC().Truncate(time.Second)
40+ r := domain.Release{ID: 7, Repo: repo, Tag: "v1", Title: "title", Notes: "notes", Prerelease: true, TargetCommitish: "abc", Author: "owner", CreatedAt: now, PublishedAt: now, UpdatedAt: now}
41+ a := domain.ReleaseAsset{ID: 9, ReleaseID: 7, Repo: repo, Name: "a.zip", Label: "label", ContentType: "application/zip", Size: 3, Digest: "sha256:123", Uploader: "owner", CreatedAt: now, UpdatedAt: now}
42+ expected := releaseToPB(r)
43+ expected.Latest = true
44+ expected.Assets = []*pb.ReleaseAsset{assetToPB(a)}
45+ for _, method := range []string{"create", "get id", "get tag", "get latest", "list", "update", "delete", "update asset", "delete asset", "list assets", "usage"} {
46+ t.Run(method, func(t *testing.T) {
47+ rel := mocks.NewReleaseService(t)
48+ c, _ := releaseClient(t, rel)
49+ p := domain.Anonymous()
50+ hydrate := func() {
51+ rel.EXPECT().GetRelease(mock.Anything, p, repo, ports.ReleaseRef{Latest: true}).Return(r, nil).Once()
52+ rel.EXPECT().ListAssets(mock.Anything, p, repo, r.ID).Return([]domain.ReleaseAsset{a}, nil).Once()
53+ }
54+ switch method {
55+ case "create":
56+ rel.EXPECT().CreateRelease(mock.Anything, p, repo, ports.CreateReleaseParams{Tag: "v1", TargetCommitish: "abc", Title: "title", Notes: "notes", Draft: true, Prerelease: true, MakeLatest: true}).Return(r, nil).Once()
57+ hydrate()
58+ res, err := c.CreateRelease(ctx, connect.NewRequest(&pb.CreateReleaseRequest{Repo: "demo", Tag: "v1", TargetCommitish: "abc", Title: "title", Notes: "notes", Draft: true, Prerelease: true, MakeLatest: true}))
59+ require.NoError(t, err)
60+ assert.True(t, proto.Equal(expected, res.Msg.GetRelease()))
61+ case "get id", "get tag", "get latest":
62+ req := &pb.GetReleaseRequest{Repo: "demo"}
63+ ref := ports.ReleaseRef{}
64+ switch method {
65+ case "get id":
66+ req.Selector = &pb.GetReleaseRequest_Id{Id: 7}
67+ ref.ID = 7
68+ case "get tag":
69+ req.Selector = &pb.GetReleaseRequest_Tag{Tag: "v1"}
70+ ref.Tag = "v1"
71+ default:
72+ req.Selector = &pb.GetReleaseRequest_Latest{Latest: true}
73+ ref.Latest = true
74+ }
75+ rel.EXPECT().GetRelease(mock.Anything, p, repo, ref).Return(r, nil).Once()
76+ hydrate()
77+ res, err := c.GetRelease(ctx, connect.NewRequest(req))
78+ require.NoError(t, err)
79+ assert.True(t, proto.Equal(expected, res.Msg.GetRelease()))
80+ case "list":
81+ rel.EXPECT().ListReleases(mock.Anything, p, repo, 17, "cursor").Return(ports.ReleasePage{Releases: []domain.Release{r}, NextPageToken: "next"}, nil).Once()
82+ hydrate()
83+ res, err := c.ListReleases(ctx, connect.NewRequest(&pb.ListReleasesRequest{Repo: "demo", PageSize: 17, PageToken: "cursor"}))
84+ require.NoError(t, err)
85+ require.Len(t, res.Msg.Releases, 1)
86+ assert.True(t, proto.Equal(expected, res.Msg.Releases[0]))
87+ assert.Equal(t, "next", res.Msg.NextPageToken)
88+ case "update":
89+ rel.EXPECT().UpdateRelease(mock.Anything, p, repo, int64(7), ports.UpdateReleaseParams{Title: new(""), Draft: new(false)}).Return(r, nil).Once()
90+ hydrate()
91+ res, err := c.UpdateRelease(ctx, connect.NewRequest(&pb.UpdateReleaseRequest{Repo: "demo", Id: 7, Tag: "ignored", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"title", "draft"}}}))
92+ require.NoError(t, err)
93+ assert.True(t, proto.Equal(expected, res.Msg.GetRelease()))
94+ case "delete":
95+ rel.EXPECT().DeleteRelease(mock.Anything, p, repo, int64(7)).Return(nil).Once()
96+ _, err := c.DeleteRelease(ctx, connect.NewRequest(&pb.DeleteReleaseRequest{Repo: "demo", Id: 7}))
97+ require.NoError(t, err)
98+ case "update asset":
99+ rel.EXPECT().UpdateAsset(mock.Anything, p, repo, int64(9), ports.UpdateAssetParams{Name: new("new.zip"), Label: new("")}).Return(a, nil).Once()
100+ res, err := c.UpdateReleaseAsset(ctx, connect.NewRequest(&pb.UpdateReleaseAssetRequest{Repo: "demo", AssetId: 9, Name: "new.zip", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"name", "label"}}}))
101+ require.NoError(t, err)
102+ assert.True(t, proto.Equal(assetToPB(a), res.Msg.GetAsset()))
103+ case "delete asset":
104+ rel.EXPECT().DeleteAsset(mock.Anything, p, repo, int64(9)).Return(nil).Once()
105+ _, err := c.DeleteReleaseAsset(ctx, connect.NewRequest(&pb.DeleteReleaseAssetRequest{Repo: "demo", AssetId: 9}))
106+ require.NoError(t, err)
107+ case "list assets":
108+ rel.EXPECT().ListAssets(mock.Anything, p, repo, int64(7)).Return([]domain.ReleaseAsset{a}, nil).Once()
109+ res, err := c.ListReleaseAssets(ctx, connect.NewRequest(&pb.ListReleaseAssetsRequest{Repo: "demo", ReleaseId: 7}))
110+ require.NoError(t, err)
111+ require.Len(t, res.Msg.Assets, 1)
112+ assert.True(t, proto.Equal(assetToPB(a), res.Msg.Assets[0]))
113+ case "usage":
114+ rel.EXPECT().Usage(mock.Anything, p, repo).Return(ports.ReleaseUsage{Used: 1, Limits: ports.ReleaseLimits{Quota: 2, AssetMax: 3, AssetsMax: 4}}, nil).Once()
115+ res, err := c.GetReleaseUsage(ctx, connect.NewRequest(&pb.GetReleaseUsageRequest{Repo: "demo"}))
116+ require.NoError(t, err)
117+ assert.True(t, proto.Equal(&pb.GetReleaseUsageResponse{UsedBytes: 1, QuotaBytes: 2, AssetMaxBytes: 3, AssetsMax: 4}, res.Msg))
118+ }
119+ })
120+ }
121+}
122+
123+func TestReleaseFeatureAndMount(t *testing.T) {
124+ for _, enabled := range []bool{false, true} {
125+ t.Run(map[bool]string{false: "absent", true: "present"}[enabled], func(t *testing.T) {
126+ var rel ports.ReleaseService
127+ if enabled {
128+ rel = mocks.NewReleaseService(t)
129+ }
130+ c, server := releaseClient(t, rel)
131+ res, err := server.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{}))
132+ require.NoError(t, err)
133+ assert.Equal(t, enabled, containsReleaseFeature(res.Msg.Features))
134+ assert.NotContains(t, Features, FeatureReleases)
135+ if !enabled {
136+ _, err := c.GetReleaseUsage(context.Background(), connect.NewRequest(&pb.GetReleaseUsageRequest{Repo: "demo"}))
137+ requireCode(t, connect.CodeUnimplemented, err)
138+ }
139+ })
140+ }
141+}
142+func containsReleaseFeature(features []string) bool {
143+ for _, f := range features {
144+ if f == FeatureReleases {
145+ return true
146+ }
147+ }
148+ return false
149+}
150+
151+func TestReleaseRPCUpload(t *testing.T) {
152+ for _, firstData := range []bool{false, true} {
153+ t.Run(map[bool]string{false: "header only", true: "header with data"}[firstData], func(t *testing.T) {
154+ rel := mocks.NewReleaseService(t)
155+ c, _ := releaseClient(t, rel)
156+ full := bytes.Repeat([]byte("x"), domain.CIArtifactChunk)
157+ tail := []byte("tail")
158+ want := append(bytes.Clone(full), tail...)
159+ rel.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.UploadAssetParams{ReleaseID: 7, Name: "a", Label: "l", ContentType: "application/octet-stream", Size: int64(len(want)), Replace: true}, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.UploadAssetParams, r io.Reader) (domain.ReleaseAsset, error) {
160+ got, err := io.ReadAll(r)
161+ assert.NoError(t, err)
162+ assert.Equal(t, want, got)
163+ return domain.ReleaseAsset{ID: 9, Size: int64(len(got))}, err
164+ }).Once()
165+ stream := c.UploadReleaseAsset(context.Background())
166+ first := &pb.UploadReleaseAssetRequest{Repo: "demo", ReleaseId: 7, Name: "a", Label: "l", ContentType: "application/octet-stream", Size: int64(len(want)), Replace: true}
167+ if firstData {
168+ first.Data = full
169+ }
170+ require.NoError(t, stream.Send(first))
171+ if !firstData {
172+ require.NoError(t, stream.Send(&pb.UploadReleaseAssetRequest{Data: full}))
173+ }
174+ require.NoError(t, stream.Send(&pb.UploadReleaseAssetRequest{Data: tail}))
175+ res, err := stream.CloseAndReceive()
176+ require.NoError(t, err)
177+ assert.EqualValues(t, 9, res.Msg.Asset.Id)
178+ assert.EqualValues(t, len(want), res.Msg.Asset.Size)
179+ })
180+ }
181+}
182+
183+func TestReleaseRPCUploadInvalid(t *testing.T) {
184+ tests := []struct {
185+ name string
186+ messages []*pb.UploadReleaseAssetRequest
187+ read bool
188+ code connect.Code
189+ }{
190+ {name: "empty", code: connect.CodeInvalidArgument},
191+ {name: "negative size", messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo", Size: -1}}, code: connect.CodeInvalidArgument},
192+ {name: "bad repo", messages: []*pb.UploadReleaseAssetRequest{{Repo: "../bad"}}, code: connect.CodeInvalidArgument},
193+ {name: "first chunk too big", messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo", Data: make([]byte, domain.CIArtifactChunk+1)}}, code: connect.CodeInvalidArgument},
194+ {name: "later chunk too big", messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo"}, {Data: make([]byte, domain.CIArtifactChunk+1)}}, read: true, code: connect.CodeInvalidArgument},
195+ {name: "message exceeds envelope", messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo", Data: make([]byte, RunnerMaxRequestBytes+1)}}, code: connect.CodeResourceExhausted},
196+ }
197+ for _, m := range []*pb.UploadReleaseAssetRequest{{Repo: "demo"}, {ReleaseId: 1}, {Name: "a"}, {Label: "l"}, {ContentType: "text/plain"}, {Size: 1}, {Replace: true}} {
198+ tests = append(tests, struct {
199+ name string
200+ messages []*pb.UploadReleaseAssetRequest
201+ read bool
202+ code connect.Code
203+ }{name: "repeated metadata " + m.String(), messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo"}, m}, read: true, code: connect.CodeInvalidArgument})
204+ }
205+ for _, tt := range tests {
206+ t.Run(tt.name, func(t *testing.T) {
207+ rel := mocks.NewReleaseService(t)
208+ c, _ := releaseClient(t, rel)
209+ if tt.read {
210+ rel.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.UploadAssetParams, r io.Reader) (domain.ReleaseAsset, error) {
211+ _, err := io.ReadAll(r)
212+ return domain.ReleaseAsset{}, err
213+ }).Once()
214+ }
215+ stream := c.UploadReleaseAsset(context.Background())
216+ for _, m := range tt.messages {
217+ if err := stream.Send(m); err != nil {
218+ break
219+ }
220+ }
221+ _, err := stream.CloseAndReceive()
222+ requireCode(t, tt.code, err)
223+ })
224+ }
225+}
226+
227+func TestReleaseRPCDownload(t *testing.T) {
228+ for _, selector := range []string{"id", "tag", "latest"} {
229+ t.Run(selector, func(t *testing.T) {
230+ rel := mocks.NewReleaseService(t)
231+ c, _ := releaseClient(t, rel)
232+ req := &pb.DownloadReleaseAssetRequest{Repo: "demo", Offset: 3}
233+ ref := ports.AssetRef{}
234+ switch selector {
235+ case "id":
236+ req.Selector = &pb.DownloadReleaseAssetRequest_AssetId{AssetId: 9}
237+ ref.ID = 9
238+ case "tag":
239+ req.Selector = &pb.DownloadReleaseAssetRequest_Tag{Tag: &pb.ReleaseAssetTag{Tag: "v1", Name: "a"}}
240+ ref.Tag = "v1"
241+ ref.Name = "a"
242+ default:
243+ req.Selector = &pb.DownloadReleaseAssetRequest_Latest{Latest: &pb.ReleaseAssetLatest{Latest: true, Name: "a"}}
244+ ref.Latest = true
245+ ref.Name = "a"
246+ }
247+ a := domain.ReleaseAsset{ID: 9, ReleaseID: 7, Name: "a", Size: 8}
248+ rel.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ref, int64(3), int64(-1), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error {
249+ if err := send(ports.AssetChunk{Asset: &a}); err != nil {
250+ return err
251+ }
252+ if err := send(ports.AssetChunk{Data: []byte("12")}); err != nil {
253+ return err
254+ }
255+ return send(ports.AssetChunk{Data: []byte("345")})
256+ }).Once()
257+ stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(req))
258+ require.NoError(t, err)
259+ defer func() { _ = stream.Close() }()
260+ require.True(t, stream.Receive())
261+ assert.True(t, proto.Equal(assetToPB(a), stream.Msg().Asset))
262+ assert.Empty(t, stream.Msg().Chunk)
263+ var got []byte
264+ for stream.Receive() {
265+ assert.Nil(t, stream.Msg().Asset)
266+ got = append(got, stream.Msg().Chunk...)
267+ }
268+ require.NoError(t, stream.Err())
269+ assert.Equal(t, "12345", string(got))
270+ })
271+ }
272+}
273+
274+func TestReleaseRPCErrors(t *testing.T) {
275+ for _, tt := range []struct {
276+ err error
277+ code connect.Code
278+ }{{domain.ErrNotFound, connect.CodeNotFound}, {domain.ErrAlreadyExists, connect.CodeAlreadyExists}, {domain.ErrResourceExhausted, connect.CodeResourceExhausted}, {domain.ErrForbidden, connect.CodePermissionDenied}, {domain.ErrOutOfRange, connect.CodeOutOfRange}, {domain.ErrPreconditionFailed, connect.CodeFailedPrecondition}, {domain.ErrInvalid, connect.CodeInvalidArgument}} {
279+ t.Run(tt.code.String(), func(t *testing.T) {
280+ rel := mocks.NewReleaseService(t)
281+ c, _ := releaseClient(t, rel)
282+ rel.EXPECT().GetRelease(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.ReleaseRef{ID: 7}).Return(domain.Release{}, tt.err).Once()
283+ _, err := c.GetRelease(context.Background(), connect.NewRequest(&pb.GetReleaseRequest{Repo: "demo", Selector: &pb.GetReleaseRequest_Id{Id: 7}}))
284+ requireCode(t, tt.code, err)
285+ rel.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.AssetRef{ID: 9}, int64(0), int64(-1), mock.Anything).Return(tt.err).Once()
286+ stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(&pb.DownloadReleaseAssetRequest{Repo: "demo", Selector: &pb.DownloadReleaseAssetRequest_AssetId{AssetId: 9}}))
287+ if err == nil {
288+ defer func() { _ = stream.Close() }()
289+ assert.False(t, stream.Receive())
290+ err = stream.Err()
291+ }
292+ requireCode(t, tt.code, err)
293+ })
294+ }
295+}
296+
297+func TestReleaseMasksAndSelectors(t *testing.T) {
298+ paths := []string{"tag", "target_commitish", "title", "notes", "draft", "prerelease", "make_latest"}
299+ p, err := releaseUpdateParams(&pb.UpdateReleaseRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}})
300+ require.NoError(t, err)
301+ assert.Equal(t, ports.UpdateReleaseParams{Tag: new(""), TargetCommitish: new(""), Title: new(""), Notes: new(""), Draft: new(false), Prerelease: new(false), MakeLatest: new(false)}, p)
302+ for _, paths := range [][]string{nil, {"unknown"}, {"*"}, {"title", "assets"}} {
303+ _, err := releaseUpdateParams(&pb.UpdateReleaseRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}})
304+ assert.ErrorIs(t, err, domain.ErrInvalid)
305+ _, err = assetUpdateParams(&pb.UpdateReleaseAssetRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}})
306+ assert.ErrorIs(t, err, domain.ErrInvalid)
307+ }
308+ for _, req := range []*pb.GetReleaseRequest{{}, {Selector: &pb.GetReleaseRequest_Id{Id: -1}}, {Selector: &pb.GetReleaseRequest_Tag{}}, {Selector: &pb.GetReleaseRequest_Latest{Latest: false}}} {
309+ _, err := releaseRef(req)
310+ assert.ErrorIs(t, err, domain.ErrInvalid)
311+ }
312+ for _, req := range []*pb.DownloadReleaseAssetRequest{{}, {Selector: &pb.DownloadReleaseAssetRequest_AssetId{}}, {Selector: &pb.DownloadReleaseAssetRequest_Tag{}}, {Selector: &pb.DownloadReleaseAssetRequest_Tag{Tag: &pb.ReleaseAssetTag{Tag: "v1"}}}, {Selector: &pb.DownloadReleaseAssetRequest_Latest{Latest: &pb.ReleaseAssetLatest{Name: "a"}}}} {
313+ _, err := assetRef(req)
314+ assert.ErrorIs(t, err, domain.ErrInvalid)
315+ }
316+}
317+
318+func TestReleaseEvents(t *testing.T) {
319+ r := domain.Release{ID: 7, Repo: mustRepoName(t, "demo"), Tag: "v1", Draft: true, Prerelease: true}
320+ at := time.Now().UTC()
321+ events := []domain.Event{domain.ReleasePublished{Release: r, At: at}, domain.ReleaseUpdated{Release: r, At: at}, domain.ReleaseDeleted{Release: r, At: at}, domain.ReleaseUnpublished{Release: r, At: at}}
322+ expected := []*pb.Event{
323+ {Kind: &pb.Event_ReleasePublished{ReleasePublished: &pb.ReleasePublished{Repo: "demo", Id: 7, Tag: "v1", Draft: true, Prerelease: true}}},
324+ {Kind: &pb.Event_ReleaseUpdated{ReleaseUpdated: &pb.ReleaseUpdated{Repo: "demo", Id: 7, Tag: "v1", Draft: true, Prerelease: true}}},
325+ {Kind: &pb.Event_ReleaseDeleted{ReleaseDeleted: &pb.ReleaseDeleted{Repo: "demo", Id: 7, Tag: "v1"}}},
326+ {Kind: &pb.Event_ReleaseUnpublished{ReleaseUnpublished: &pb.ReleaseUnpublished{Repo: "demo", Id: 7, Tag: "v1", Draft: true, Prerelease: true}}},
327+ }
328+ for i, ev := range events {
329+ t.Run(ev.EventKind(), func(t *testing.T) {
330+ expected[i].Id = "event"
331+ expected[i].Time = timestamp(at)
332+ assert.True(t, proto.Equal(expected[i], watchEventToPB(ports.WatchEvent{ID: "event", Event: ev})))
333+ })
334+ }
335+}
336+
337+func TestReleaseResponseEnrichment(t *testing.T) {
338+ repo := mustRepoName(t, "demo")
339+ for _, tt := range []struct {
340+ name string
341+ latest domain.Release
342+ latestErr, assetsErr error
343+ code connect.Code
344+ wantLatest bool
345+ }{
346+ {name: "draft without latest", latestErr: domain.ErrNotFound},
347+ {name: "different latest", latest: domain.Release{ID: 8}},
348+ {name: "current latest", latest: domain.Release{ID: 7}, wantLatest: true},
349+ {name: "latest lookup fails", latestErr: domain.ErrUnavailable, code: connect.CodeUnavailable},
350+ {name: "assets lookup fails", assetsErr: domain.ErrNotFound, code: connect.CodeNotFound},
351+ } {
352+ t.Run(tt.name, func(t *testing.T) {
353+ rel := mocks.NewReleaseService(t)
354+ rel.EXPECT().GetRelease(mock.Anything, domain.Owner(), repo, ports.ReleaseRef{ID: 7}).Return(domain.Release{ID: 7, Repo: repo, Draft: true}, nil).Once()
355+ rel.EXPECT().GetRelease(mock.Anything, domain.Owner(), repo, ports.ReleaseRef{Latest: true}).Return(tt.latest, tt.latestErr).Once()
356+ if tt.code != connect.CodeUnavailable {
357+ rel.EXPECT().ListAssets(mock.Anything, domain.Owner(), repo, int64(7)).Return(nil, tt.assetsErr).Once()
358+ }
359+ res, err := (&releaseService{rel}).GetRelease(ownerCtx(), connect.NewRequest(&pb.GetReleaseRequest{Repo: "demo", Selector: &pb.GetReleaseRequest_Id{Id: 7}}))
360+ if tt.code != 0 {
361+ requireCode(t, tt.code, err)
362+ return
363+ }
364+ require.NoError(t, err)
365+ assert.Equal(t, tt.wantLatest, res.Msg.Release.Latest)
366+ assert.Empty(t, res.Msg.Release.Assets)
367+ assert.Nil(t, res.Msg.Release.PublishedAt)
368+ })
369+ }
370+}
371+
372+func TestReleaseRPCAuthenticatedCaller(t *testing.T) {
373+ rel := mocks.NewReleaseService(t)
374+ auth := mocks.NewAuthService(t)
375+ auth.EXPECT().Authenticate(mock.Anything, "secret").Return(domain.Owner(), nil).Once()
376+ rel.EXPECT().DeleteRelease(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(7)).Return(nil).Once()
377+ srv := httptest.NewServer(New(Config{Releases: rel, Auth: auth, Sessions: mocks.NewSessionService(t)}).Handler())
378+ defer srv.Close()
379+ c := ermiteconnect.NewReleaseServiceClient(srv.Client(), srv.URL)
380+ req := connect.NewRequest(&pb.DeleteReleaseRequest{Repo: "demo", Id: 7})
381+ req.Header().Set("Authorization", "Bearer secret")
382+ _, err := c.DeleteRelease(context.Background(), req)
383+ require.NoError(t, err)
384+}
385+
386+func TestReleaseRPCDownloadFullChunk(t *testing.T) {
387+ rel := mocks.NewReleaseService(t)
388+ c, _ := releaseClient(t, rel)
389+ data := bytes.Repeat([]byte("x"), domain.CIArtifactChunk)
390+ rel.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.AssetRef{ID: 9}, int64(0), int64(-1), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error {
391+ if err := send(ports.AssetChunk{Asset: &domain.ReleaseAsset{ID: 9, Size: int64(len(data))}}); err != nil {
392+ return err
393+ }
394+ return send(ports.AssetChunk{Data: data})
395+ }).Once()
396+ stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(&pb.DownloadReleaseAssetRequest{Repo: "demo", Selector: &pb.DownloadReleaseAssetRequest_AssetId{AssetId: 9}}))
397+ require.NoError(t, err)
398+ defer func() { _ = stream.Close() }()
399+ require.True(t, stream.Receive())
400+ require.NotNil(t, stream.Msg().Asset)
401+ require.True(t, stream.Receive())
402+ assert.Equal(t, data, stream.Msg().Chunk)
403+ assert.False(t, stream.Receive())
404+ require.NoError(t, stream.Err())
405+}
406+
407+func TestReleaseRPCRejectsInvalidMasksAndSelectors(t *testing.T) {
408+ c, _ := releaseClient(t, mocks.NewReleaseService(t))
409+ _, err := c.UpdateRelease(context.Background(), connect.NewRequest(&pb.UpdateReleaseRequest{Repo: "demo", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"unknown"}}}))
410+ requireCode(t, connect.CodeInvalidArgument, err)
411+ _, err = c.UpdateReleaseAsset(context.Background(), connect.NewRequest(&pb.UpdateReleaseAssetRequest{Repo: "demo"}))
412+ requireCode(t, connect.CodeInvalidArgument, err)
413+ _, err = c.GetRelease(context.Background(), connect.NewRequest(&pb.GetReleaseRequest{Repo: "demo", Selector: &pb.GetReleaseRequest_Latest{Latest: false}}))
414+ requireCode(t, connect.CodeInvalidArgument, err)
415+ stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(&pb.DownloadReleaseAssetRequest{Repo: "demo", Selector: &pb.DownloadReleaseAssetRequest_Latest{Latest: &pb.ReleaseAssetLatest{Name: "a"}}}))
416+ if err == nil {
417+ defer func() { _ = stream.Close() }()
418+ assert.False(t, stream.Receive())
419+ err = stream.Err()
420+ }
421+ requireCode(t, connect.CodeInvalidArgument, err)
422+}
423+
424+func TestReleaseRPCListResolvesLatestOnce(t *testing.T) {
425+ repo := mustRepoName(t, "demo")
426+ rel := mocks.NewReleaseService(t)
427+ c, _ := releaseClient(t, rel)
428+ p := domain.Anonymous()
429+ rows := []domain.Release{{ID: 3, Repo: repo, Tag: "c"}, {ID: 2, Repo: repo, Tag: "b"}, {ID: 1, Repo: repo, Tag: "a"}}
430+ rel.EXPECT().ListReleases(mock.Anything, p, repo, 0, "").Return(ports.ReleasePage{Releases: rows}, nil).Once()
431+ rel.EXPECT().GetRelease(mock.Anything, p, repo, ports.ReleaseRef{Latest: true}).Return(rows[1], nil).Once()
432+ for _, r := range rows {
433+ rel.EXPECT().ListAssets(mock.Anything, p, repo, r.ID).Return(nil, nil).Once()
434+ }
435+ res, err := c.ListReleases(context.Background(), connect.NewRequest(&pb.ListReleasesRequest{Repo: "demo"}))
436+ require.NoError(t, err)
437+ require.Len(t, res.Msg.Releases, 3)
438+ for _, m := range res.Msg.Releases {
439+ assert.Equal(t, m.Id == 2, m.Latest, m.Tag)
440+ }
441+}
442+
443+func TestReleaseRPCListWithoutLatest(t *testing.T) {
444+ repo := mustRepoName(t, "demo")
445+ rel := mocks.NewReleaseService(t)
446+ c, _ := releaseClient(t, rel)
447+ p := domain.Anonymous()
448+ rel.EXPECT().ListReleases(mock.Anything, p, repo, 0, "").Return(ports.ReleasePage{Releases: []domain.Release{{ID: 1, Repo: repo}}}, nil).Once()
449+ rel.EXPECT().GetRelease(mock.Anything, p, repo, ports.ReleaseRef{Latest: true}).Return(domain.Release{}, domain.ErrNotFound).Once()
450+ rel.EXPECT().ListAssets(mock.Anything, p, repo, int64(1)).Return(nil, nil).Once()
451+ res, err := c.ListReleases(context.Background(), connect.NewRequest(&pb.ListReleasesRequest{Repo: "demo"}))
452+ require.NoError(t, err)
453+ require.Len(t, res.Msg.Releases, 1)
454+ assert.False(t, res.Msg.Releases[0].Latest)
455+}
456+
457+func TestReleaseRPCDownloadZeroByteAsset(t *testing.T) {
458+ rel := mocks.NewReleaseService(t)
459+ c, _ := releaseClient(t, rel)
460+ a := domain.ReleaseAsset{ID: 9, ReleaseID: 7, Name: "empty", Size: 0}
461+ // The use case sends the metadata and no data for an empty asset.
462+ rel.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.AssetRef{ID: 9}, int64(0), int64(-1), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error {
463+ return send(ports.AssetChunk{Asset: &a})
464+ }).Once()
465+ stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(&pb.DownloadReleaseAssetRequest{Repo: "demo", Selector: &pb.DownloadReleaseAssetRequest_AssetId{AssetId: 9}}))
466+ require.NoError(t, err)
467+ defer func() { _ = stream.Close() }()
468+ require.True(t, stream.Receive())
469+ assert.True(t, proto.Equal(assetToPB(a), stream.Msg().Asset))
470+ assert.False(t, stream.Receive())
471+ require.NoError(t, stream.Err())
472+}
M internal/adapters/web/fragments.go +8 -2
@@ -62,7 +62,8 @@ func liveEvent(we ports.WatchEvent) bool {
6262 return true
6363 }
6464 switch we.Event.(type) {
65- case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
65+ case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished,
66+ domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
6667 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
6768 domain.RunUpdated, domain.JobUpdated,
6869 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:
@@ -164,6 +165,11 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error)
164165 // repoPage handles the events of the topic of one repository.
165166 func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
166167 switch ev := we.Event.(type) {
168+ case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished:
169+ if l.view == "releases" || l.view == "release" {
170+ return gap, nil // notes, assets, latest or visibility changed: reload
171+ }
172+ return nil, nil
167173 case domain.RunUpdated:
168174 return l.runEvent(ctx, ev)
169175 case domain.JobUpdated:
@@ -179,7 +185,7 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
179185 return gap, nil // no longer visible
180186 case err != nil:
181187 return nil, err
182- case l.view == "summary", l.view == "refs":
188+ case l.view == "summary", l.view == "refs", l.view == "releases", l.view == "release":
183189 return gap, nil // the description, the default branch
184190 }
185191 return nil, nil
@@ -62,7 +62,8 @@ func liveEvent(we ports.WatchEvent) bool {
62 return true62 return true
63 }63 }
64 switch we.Event.(type) {64 switch we.Event.(type) {
65- case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,65+ case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished,
66+ domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
66 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,67 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
67 domain.RunUpdated, domain.JobUpdated,68 domain.RunUpdated, domain.JobUpdated,
68 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:69 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:
@@ -164,6 +165,11 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error)
164 // repoPage handles the events of the topic of one repository.165 // repoPage handles the events of the topic of one repository.
165 func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {166 func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
166 switch ev := we.Event.(type) {167 switch ev := we.Event.(type) {
168+ case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished:
169+ if l.view == "releases" || l.view == "release" {
170+ return gap, nil // notes, assets, latest or visibility changed: reload
171+ }
172+ return nil, nil
167 case domain.RunUpdated:173 case domain.RunUpdated:
168 return l.runEvent(ctx, ev)174 return l.runEvent(ctx, ev)
169 case domain.JobUpdated:175 case domain.JobUpdated:
@@ -179,7 +185,7 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
179 return gap, nil // no longer visible185 return gap, nil // no longer visible
180 case err != nil:186 case err != nil:
181 return nil, err187 return nil, err
182- case l.view == "summary", l.view == "refs":188+ case l.view == "summary", l.view == "refs", l.view == "releases", l.view == "release":
183 return gap, nil // the description, the default branch189 return gap, nil // the description, the default branch
184 }190 }
185 return nil, nil191 return nil, nil
M internal/adapters/web/fragments_test.go +20 -0
A internal/adapters/web/releases.go +244 -0
A internal/adapters/web/releases.templ +67 -0
A internal/adapters/web/releases_templ.go +439 -0
A internal/adapters/web/releases_test.go +298 -0
M internal/adapters/web/repo.go +3 -0
M internal/adapters/web/web.go +11 -0
M internal/app/api_test.go +1 -1
M internal/app/ci_artifacts_test.go +110 -12
M internal/app/ci_e2e_test.go +2 -0
M internal/app/cli_e2e_test.go +4 -3
A internal/app/cli_release.go +603 -0
A internal/app/cli_release_e2e_test.go +65 -0
A internal/app/cli_release_test.go +388 -0
M internal/app/cli_token.go +9 -0
M internal/app/cli_token_test.go +40 -1
M internal/app/cli_version.go +1 -0
M internal/app/cli_version_test.go +5 -5
M internal/app/forge.go +49 -4
A internal/app/release_e2e_test.go +160 -0
M internal/app/root.go +1 -1
M internal/app/testdata/web/blob-binary.html +15 -5
M internal/app/testdata/web/blob-code.html +15 -5
M internal/app/testdata/web/blob-empty.html +15 -5
M internal/app/testdata/web/blob-image.html +15 -5
M internal/app/testdata/web/blob-markdown.html +15 -5
M internal/app/testdata/web/blob-svg.html +15 -5
M internal/app/testdata/web/blob-text.html +15 -5
M internal/app/testdata/web/blob-too-large.html +15 -5
M internal/app/testdata/web/blob-trojan.html +15 -5
M internal/app/testdata/web/commit-big.html +15 -5
M internal/app/testdata/web/commit-binary.html +15 -5
M internal/app/testdata/web/commit-merge.html +15 -5
M internal/app/testdata/web/commit-modify-split.html +15 -5
M internal/app/testdata/web/commit-modify.html +15 -5
M internal/app/testdata/web/commit-rename.html +15 -5
M internal/app/testdata/web/commit-root.html +15 -5
M internal/app/testdata/web/commit-trojan.html +15 -5
M internal/app/testdata/web/commits-page2.html +14 -4
M internal/app/testdata/web/commits-path.html +14 -4
M internal/app/testdata/web/commits-root.html +14 -4
M internal/app/testdata/web/commits-small.html +16 -6
M internal/app/testdata/web/refs.html +17 -7
M internal/app/testdata/web/summary.html +17 -7
M internal/app/testdata/web/tree-nested.html +15 -5
M internal/app/testdata/web/tree-root.html +15 -5
M internal/app/testdata/web/tree-slash-ref.html +15 -5
M internal/app/testdata/web/tree-subdir.html +15 -5
M internal/app/web.go +1 -0
A internal/app/web_release_test.go +111 -0
M internal/boundary/boundary_test.go +14 -2
M internal/config/config.go +117 -0
A internal/config/storage_test.go +111 -0
M internal/domain/ci_artifact.go +21 -0
M internal/domain/ci_artifact_test.go +7 -0
M internal/domain/event.go +44 -0
A internal/domain/release.go +165 -0
A internal/domain/release_test.go +59 -0
M internal/domain/token.go +4 -2
M internal/domain/token_test.go +5 -1
A internal/httprange/httprange.go +77 -0
A internal/httprange/httprange_test.go +72 -0
A internal/ports/blob.go +72 -0
A internal/ports/blob_test.go +19 -0
M internal/ports/ci_artifact.go +3 -13
A internal/ports/mocks/BlobStore.go +644 -0
M internal/ports/mocks/CIArtifactFiles.go +0 -276
A internal/ports/mocks/GitHubReleaseAPI.go +1044 -0
A internal/ports/mocks/MultipartUpload.go +273 -0
A internal/ports/mocks/ReleaseRepoCleaner.go +168 -0
A internal/ports/mocks/ReleaseService.go +997 -0
A internal/ports/mocks/ReleaseStore.go +796 -0
A internal/ports/mocks/ReleaseSweeper.go +87 -0
A internal/ports/mocks/ReleaseTagTrigger.go +100 -0
A internal/ports/mocks/ReleaseTagWriter.go +117 -0
A internal/ports/release.go +119 -0
M internal/ports/services.go +17 -0
M internal/usecase/ci.go +15 -7
M internal/usecase/ci_artifacts.go +325 -12
A internal/usecase/ci_artifacts_blob_test.go +301 -0
A internal/usecase/ci_artifacts_slots_test.go +179 -0
M internal/usecase/ci_watch.go +4 -3
M internal/usecase/events.go +25 -0
M internal/usecase/events_test.go +95 -0
M internal/usecase/pushes.go +21 -14
A internal/usecase/releases.go +494 -0
A internal/usecase/releases_assets.go +441 -0
A internal/usecase/releases_assets_test.go +308 -0
A internal/usecase/releases_github.go +387 -0
A internal/usecase/releases_github_test.go +360 -0
A internal/usecase/releases_integration_test.go +336 -0
A internal/usecase/releases_sweep.go +106 -0
A internal/usecase/releases_sweep_test.go +147 -0
A internal/usecase/releases_test.go +57 -0
M internal/usecase/repos.go +9 -1
M internal/usecase/repos_write.go +11 -0
M proto/ermite/storage/ci_artifact.proto +4 -2
A proto/ermite/storage/release.proto +46 -0
M skills/ermite/CLI.md +20 -0

Comments0 open / 0

no comments yet.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close