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
A internal/adapters/client/release_test.go +43 -0
M internal/adapters/client/scopes_test.go +1 -0
A internal/adapters/gitcli/tags.go +42 -0
A internal/adapters/gitcli/tags_test.go +31 -0
M internal/adapters/githubapi/handler.go +23 -2
A internal/adapters/githubapi/releases.go +413 -0
A internal/adapters/githubapi/releases_test.go +466 -0
A internal/adapters/githubapi/testdata/releases/draft.json +40 -0
A internal/adapters/githubapi/testdata/releases/release.json +113 -0
M internal/adapters/kvstore/ci_artifact.go +2 -1
M internal/adapters/kvstore/ci_artifact_test.go +5 -0
M internal/adapters/kvstore/issues_test.go +16 -8
A internal/adapters/kvstore/releases.go +408 -0
A internal/adapters/kvstore/releases_test.go +131 -0
M internal/adapters/kvstore/repos.go +1 -0
M internal/adapters/kvstore/store.go +25 -4
M internal/adapters/kvstore/store_test.go +57 -0
M internal/adapters/rpc/api.go +9 -0
M internal/adapters/rpc/convert.go +28 -3
M internal/adapters/rpc/errors.go +2 -0
M internal/adapters/rpc/event.go +26 -0
A internal/adapters/rpc/release.go +358 -0
A internal/adapters/rpc/release_test.go +472 -0
M internal/adapters/web/fragments.go +8 -2
M internal/adapters/web/fragments_test.go +20 -0
@@ -8,6 +8,7 @@ import (
88 "testing"
99
1010 "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
1112
1213 "github.com/a-h/templ"
1314 "github.com/stretchr/testify/assert"
@@ -99,3 +100,22 @@ func TestMRPushedHeadSignal(t *testing.T) {
99100 }
100101 }
101102 }
103+
104+// Release lifecycle events invalidate the release pages without putting notes
105+// or asset metadata in SSE payloads. Other views need no release fragments.
106+func TestReleaseLiveUpdates(t *testing.T) {
107+ for _, ev := range []domain.Event{domain.ReleasePublished{}, domain.ReleaseUpdated{}, domain.ReleaseDeleted{}, domain.ReleaseUnpublished{}} {
108+ we := ports.WatchEvent{Event: ev}
109+ require.True(t, liveEvent(we), ev.EventKind())
110+ for _, view := range []string{"releases", "release", "summary", "refs", "mr"} {
111+ st := &liveStream{repo: "proj", view: view}
112+ got, err := st.render(t.Context(), we)
113+ require.NoError(t, err)
114+ if view == "release" || view == "releases" {
115+ assert.Equal(t, gap, got)
116+ } else {
117+ assert.Empty(t, got)
118+ }
119+ }
120+ }
121+}
@@ -8,6 +8,7 @@ import (
8 "testing"8 "testing"
9 9
10 "bnema.dev/ermite/internal/domain"10 "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
11 12
12 "github.com/a-h/templ"13 "github.com/a-h/templ"
13 "github.com/stretchr/testify/assert"14 "github.com/stretchr/testify/assert"
@@ -99,3 +100,22 @@ func TestMRPushedHeadSignal(t *testing.T) {
99 }100 }
100 }101 }
101 }102 }
103+
104+// Release lifecycle events invalidate the release pages without putting notes
105+// or asset metadata in SSE payloads. Other views need no release fragments.
106+func TestReleaseLiveUpdates(t *testing.T) {
107+ for _, ev := range []domain.Event{domain.ReleasePublished{}, domain.ReleaseUpdated{}, domain.ReleaseDeleted{}, domain.ReleaseUnpublished{}} {
108+ we := ports.WatchEvent{Event: ev}
109+ require.True(t, liveEvent(we), ev.EventKind())
110+ for _, view := range []string{"releases", "release", "summary", "refs", "mr"} {
111+ st := &liveStream{repo: "proj", view: view}
112+ got, err := st.render(t.Context(), we)
113+ require.NoError(t, err)
114+ if view == "release" || view == "releases" {
115+ assert.Equal(t, gap, got)
116+ } else {
117+ assert.Empty(t, got)
118+ }
119+ }
120+ }
121+}
A internal/adapters/web/releases.go +244 -0
@@ -0,0 +1,244 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "html/template"
9+ "net/http"
10+ "net/url"
11+ "strconv"
12+ "strings"
13+ "time"
14+
15+ "github.com/a-h/templ"
16+ "github.com/bnema/zerowrap"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/httprange"
20+ "bnema.dev/ermite/internal/ports"
21+)
22+
23+func releasesBase(repo string) string { return repoHref(repo, "releases") }
24+func releaseHref(repo, tag string) string { return repoHref(repo, "releases", "tag", tag) }
25+func releaseAssetHref(repo string, id int64) string {
26+ return releasesBase(repo) + "/assets/" + strconv.FormatInt(id, 10)
27+}
28+
29+// ReleaseRow describes a release without mistaking MakeLatest (a pin) for
30+// the latest release selected by the service.
31+type ReleaseRow struct {
32+ Tag, Title, Href, Published string
33+ Draft, Prerelease, Latest bool
34+ AssetCount int
35+}
36+type ReleaseListPage struct {
37+ Rows []ReleaseRow
38+ Older string
39+}
40+type ReleaseAssetRow struct{ Name, Href, Size, ContentType string }
41+type ReleaseDetailPage struct {
42+ ReleaseRow
43+ Notes template.HTML
44+ SHA, CommitHref, SourceHref string
45+ Assets []ReleaseAssetRow
46+}
47+
48+func (s *Server) latestReleaseID(ctx context.Context, repo domain.RepoName) (int64, error) {
49+ rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), repo, ports.ReleaseRef{Latest: true})
50+ if errors.Is(err, domain.ErrNotFound) {
51+ return 0, nil
52+ }
53+ return rel.ID, err
54+}
55+func releaseRow(repo string, rel domain.Release, latest int64) ReleaseRow {
56+ published := "draft"
57+ if !rel.Draft && !rel.PublishedAt.IsZero() {
58+ published = rel.PublishedAt.UTC().Format("2006-01-02")
59+ }
60+ title := rel.Title
61+ if title == "" {
62+ title = rel.Tag
63+ }
64+ return ReleaseRow{Tag: rel.Tag, Title: title, Href: releaseHref(repo, rel.Tag), Published: published, Draft: rel.Draft, Prerelease: rel.Prerelease, Latest: rel.ID == latest}
65+}
66+func (s *Server) releaseList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
67+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
68+ if err != nil {
69+ return nil, 0, err
70+ }
71+ page, err := s.cfg.Releases.ListReleases(ctx, principal(ctx), d.repo.Name, 30, r.URL.Query().Get("page"))
72+ if err != nil {
73+ return nil, 0, err
74+ }
75+ latest, err := s.latestReleaseID(ctx, d.repo.Name)
76+ if err != nil {
77+ return nil, 0, err
78+ }
79+ p := ReleaseListPage{}
80+ for _, rel := range page.Releases {
81+ row := releaseRow(d.name(), rel, latest)
82+ assets, err := s.cfg.Releases.ListAssets(ctx, principal(ctx), d.repo.Name, rel.ID)
83+ if err != nil {
84+ return nil, 0, err
85+ }
86+ row.AssetCount = len(assets)
87+ p.Rows = append(p.Rows, row)
88+ }
89+ if page.NextPageToken != "" {
90+ p.Older = releasesBase(d.name()) + "?page=" + url.QueryEscape(page.NextPageToken)
91+ }
92+ l := s.repoLayout(ctx, d, "releases")
93+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=releases")
94+ l.Title = "Releases · " + d.name()
95+ l.Crumbs = append(l.Crumbs, Crumb{Label: "releases", Current: true})
96+ return Releases(l, p), http.StatusOK, nil
97+}
98+func (s *Server) releaseShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
99+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
100+ if err != nil {
101+ return nil, 0, err
102+ }
103+ rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), d.repo.Name, ports.ReleaseRef{Tag: r.PathValue("tag")})
104+ if err != nil {
105+ return nil, 0, err
106+ }
107+ latest, err := s.latestReleaseID(ctx, d.repo.Name)
108+ if err != nil {
109+ return nil, 0, err
110+ }
111+ assets, err := s.cfg.Releases.ListAssets(ctx, principal(ctx), d.repo.Name, rel.ID)
112+ if err != nil {
113+ return nil, 0, err
114+ }
115+ notes, _, err := s.md.render([]byte(rel.Notes), d.name(), rel.Tag, "")
116+ if err != nil {
117+ return nil, 0, err
118+ }
119+ p := ReleaseDetailPage{ReleaseRow: releaseRow(d.name(), rel, latest), Notes: notes, SourceHref: repoHref(d.name(), "tree", rel.Tag)}
120+ if rel.TargetCommitish != "" {
121+ p.SHA = short(rel.TargetCommitish)
122+ p.CommitHref = repoHref(d.name(), "commit", rel.TargetCommitish)
123+ }
124+ for _, a := range assets {
125+ p.Assets = append(p.Assets, ReleaseAssetRow{Name: a.Name, Href: releaseAssetHref(d.name(), a.ID), Size: humanSize(a.Size), ContentType: a.ContentType})
126+ }
127+ l := s.repoLayout(ctx, d, "releases")
128+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=release")
129+ l.Title = rel.Tag + " · " + d.name()
130+ l.Crumbs = append(l.Crumbs, Crumb{Label: "releases", Href: releasesBase(d.name())}, Crumb{Label: rel.Tag, Current: true})
131+ return ReleaseDetail(l, p), http.StatusOK, nil
132+}
133+func (s *Server) releaseLatest(w http.ResponseWriter, r *http.Request) {
134+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
135+ defer cancel()
136+ repo, err := domain.ParseRepoName(r.PathValue("repo"))
137+ if err != nil {
138+ s.fail(ctx, w, r, err)
139+ return
140+ }
141+ rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), repo, ports.ReleaseRef{Latest: true})
142+ if err != nil {
143+ s.fail(ctx, w, r, err)
144+ return
145+ }
146+ cacheHeaders(ctx, w.Header(), "no-cache", sessionCache)
147+ http.Redirect(w, r, releaseHref(repo.String(), rel.Tag), http.StatusSeeOther)
148+}
149+
150+func releaseHeaders(h http.Header, a domain.ReleaseAsset) {
151+ // Reuse the artifact sandbox and attachment policy; never trust an
152+ // uploader's media type to execute content in the forge's origin.
153+ typ := "application/octet-stream"
154+ if a.ContentType == domain.CIArtifactZip {
155+ typ = domain.CIArtifactZip
156+ }
157+ h.Set("Content-Type", typ)
158+ h.Set("Content-Disposition", contentDisposition(attachmentName(a.Name)))
159+ h.Set("X-Content-Type-Options", "nosniff")
160+ h.Set("Content-Security-Policy", rawCSP)
161+ h.Set("Content-Length", strconv.FormatInt(a.Size, 10))
162+ h.Set("ETag", `"`+strings.Replace(a.Digest, "sha256:", "sha256-", 1)+`"`)
163+ h.Set("Accept-Ranges", "bytes")
164+}
165+func (s *Server) releaseDownload(w http.ResponseWriter, r *http.Request) {
166+ ctx, cancel := context.WithTimeout(r.Context(), 60*time.Minute)
167+ defer cancel()
168+ repo, err := domain.ParseRepoName(r.PathValue("repo"))
169+ if err != nil {
170+ s.fail(ctx, w, r, errNotFound)
171+ return
172+ }
173+ ref := ports.AssetRef{Tag: r.PathValue("tag"), Name: r.PathValue("asset")}
174+ if id := r.PathValue("id"); id != "" {
175+ ref.ID, err = strconv.ParseInt(id, 10, 64)
176+ if err != nil || ref.ID < 1 {
177+ s.fail(ctx, w, r, errNotFound)
178+ return
179+ }
180+ } else if ref.Tag == "" {
181+ ref.Latest = true
182+ }
183+ a, err := s.cfg.Releases.GetAsset(ctx, principal(ctx), repo, ref)
184+ if err != nil {
185+ s.fail(ctx, w, r, errNotFound)
186+ return
187+ }
188+ // Resolve latest/tag only once: a moving latest cannot select another
189+ // asset between reading the headers and streaming the bytes.
190+ ref = ports.AssetRef{ID: a.ID}
191+ h := w.Header()
192+ releaseHeaders(h, a)
193+ cacheHeaders(ctx, h, "no-cache", sessionCache)
194+ offset, length, partial, err := httprange.Select(r, a.Size, h.Get("ETag"))
195+ if err != nil {
196+ h.Set("Content-Range", httprange.Unsatisfied(a.Size))
197+ h.Set("Content-Length", "0")
198+ w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
199+ return
200+ }
201+ status := http.StatusOK
202+ if partial {
203+ status = http.StatusPartialContent
204+ h.Set("Content-Range", httprange.ContentRange(offset, length, a.Size))
205+ }
206+ h.Set("Content-Length", strconv.FormatInt(length, 10))
207+ if r.Method == http.MethodHead {
208+ w.WriteHeader(status)
209+ return
210+ }
211+ started := false
212+ rc := http.NewResponseController(w)
213+ defer func() { _ = rc.SetWriteDeadline(time.Time{}) }()
214+ err = s.cfg.Releases.DownloadAsset(ctx, principal(ctx), repo, ref, offset, length, func(c ports.AssetChunk) error {
215+ if !started {
216+ if c.Asset == nil || c.Asset.ID != a.ID || c.Asset.Digest != a.Digest || c.Asset.Size != a.Size {
217+ return errors.New("web: release asset changed before download")
218+ }
219+ _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
220+ w.WriteHeader(status)
221+ started = true
222+ }
223+ if len(c.Data) == 0 {
224+ return nil
225+ }
226+ _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
227+ _, err := w.Write(c.Data)
228+ return err
229+ })
230+ if err == nil {
231+ return
232+ }
233+ if !started {
234+ // Do not leave binary headers or range metadata on the HTML error page.
235+ for _, key := range []string{"Content-Length", "Content-Disposition", "Content-Range", "Content-Security-Policy", "ETag", "Accept-Ranges"} {
236+ h.Del(key)
237+ }
238+ s.fail(ctx, w, r, errNotFound)
239+ return
240+ }
241+ log := zerowrap.FromCtx(ctx)
242+ log.Error().Err(err).Str("repo", repo.String()).Int64("asset", a.ID).Msg("release download failed")
243+ panic(http.ErrAbortHandler)
244+}
@@ -0,0 +1,244 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "html/template"
9+ "net/http"
10+ "net/url"
11+ "strconv"
12+ "strings"
13+ "time"
14+
15+ "github.com/a-h/templ"
16+ "github.com/bnema/zerowrap"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/httprange"
20+ "bnema.dev/ermite/internal/ports"
21+)
22+
23+func releasesBase(repo string) string { return repoHref(repo, "releases") }
24+func releaseHref(repo, tag string) string { return repoHref(repo, "releases", "tag", tag) }
25+func releaseAssetHref(repo string, id int64) string {
26+ return releasesBase(repo) + "/assets/" + strconv.FormatInt(id, 10)
27+}
28+
29+// ReleaseRow describes a release without mistaking MakeLatest (a pin) for
30+// the latest release selected by the service.
31+type ReleaseRow struct {
32+ Tag, Title, Href, Published string
33+ Draft, Prerelease, Latest bool
34+ AssetCount int
35+}
36+type ReleaseListPage struct {
37+ Rows []ReleaseRow
38+ Older string
39+}
40+type ReleaseAssetRow struct{ Name, Href, Size, ContentType string }
41+type ReleaseDetailPage struct {
42+ ReleaseRow
43+ Notes template.HTML
44+ SHA, CommitHref, SourceHref string
45+ Assets []ReleaseAssetRow
46+}
47+
48+func (s *Server) latestReleaseID(ctx context.Context, repo domain.RepoName) (int64, error) {
49+ rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), repo, ports.ReleaseRef{Latest: true})
50+ if errors.Is(err, domain.ErrNotFound) {
51+ return 0, nil
52+ }
53+ return rel.ID, err
54+}
55+func releaseRow(repo string, rel domain.Release, latest int64) ReleaseRow {
56+ published := "draft"
57+ if !rel.Draft && !rel.PublishedAt.IsZero() {
58+ published = rel.PublishedAt.UTC().Format("2006-01-02")
59+ }
60+ title := rel.Title
61+ if title == "" {
62+ title = rel.Tag
63+ }
64+ return ReleaseRow{Tag: rel.Tag, Title: title, Href: releaseHref(repo, rel.Tag), Published: published, Draft: rel.Draft, Prerelease: rel.Prerelease, Latest: rel.ID == latest}
65+}
66+func (s *Server) releaseList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
67+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
68+ if err != nil {
69+ return nil, 0, err
70+ }
71+ page, err := s.cfg.Releases.ListReleases(ctx, principal(ctx), d.repo.Name, 30, r.URL.Query().Get("page"))
72+ if err != nil {
73+ return nil, 0, err
74+ }
75+ latest, err := s.latestReleaseID(ctx, d.repo.Name)
76+ if err != nil {
77+ return nil, 0, err
78+ }
79+ p := ReleaseListPage{}
80+ for _, rel := range page.Releases {
81+ row := releaseRow(d.name(), rel, latest)
82+ assets, err := s.cfg.Releases.ListAssets(ctx, principal(ctx), d.repo.Name, rel.ID)
83+ if err != nil {
84+ return nil, 0, err
85+ }
86+ row.AssetCount = len(assets)
87+ p.Rows = append(p.Rows, row)
88+ }
89+ if page.NextPageToken != "" {
90+ p.Older = releasesBase(d.name()) + "?page=" + url.QueryEscape(page.NextPageToken)
91+ }
92+ l := s.repoLayout(ctx, d, "releases")
93+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=releases")
94+ l.Title = "Releases · " + d.name()
95+ l.Crumbs = append(l.Crumbs, Crumb{Label: "releases", Current: true})
96+ return Releases(l, p), http.StatusOK, nil
97+}
98+func (s *Server) releaseShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
99+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
100+ if err != nil {
101+ return nil, 0, err
102+ }
103+ rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), d.repo.Name, ports.ReleaseRef{Tag: r.PathValue("tag")})
104+ if err != nil {
105+ return nil, 0, err
106+ }
107+ latest, err := s.latestReleaseID(ctx, d.repo.Name)
108+ if err != nil {
109+ return nil, 0, err
110+ }
111+ assets, err := s.cfg.Releases.ListAssets(ctx, principal(ctx), d.repo.Name, rel.ID)
112+ if err != nil {
113+ return nil, 0, err
114+ }
115+ notes, _, err := s.md.render([]byte(rel.Notes), d.name(), rel.Tag, "")
116+ if err != nil {
117+ return nil, 0, err
118+ }
119+ p := ReleaseDetailPage{ReleaseRow: releaseRow(d.name(), rel, latest), Notes: notes, SourceHref: repoHref(d.name(), "tree", rel.Tag)}
120+ if rel.TargetCommitish != "" {
121+ p.SHA = short(rel.TargetCommitish)
122+ p.CommitHref = repoHref(d.name(), "commit", rel.TargetCommitish)
123+ }
124+ for _, a := range assets {
125+ p.Assets = append(p.Assets, ReleaseAssetRow{Name: a.Name, Href: releaseAssetHref(d.name(), a.ID), Size: humanSize(a.Size), ContentType: a.ContentType})
126+ }
127+ l := s.repoLayout(ctx, d, "releases")
128+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=release")
129+ l.Title = rel.Tag + " · " + d.name()
130+ l.Crumbs = append(l.Crumbs, Crumb{Label: "releases", Href: releasesBase(d.name())}, Crumb{Label: rel.Tag, Current: true})
131+ return ReleaseDetail(l, p), http.StatusOK, nil
132+}
133+func (s *Server) releaseLatest(w http.ResponseWriter, r *http.Request) {
134+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
135+ defer cancel()
136+ repo, err := domain.ParseRepoName(r.PathValue("repo"))
137+ if err != nil {
138+ s.fail(ctx, w, r, err)
139+ return
140+ }
141+ rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), repo, ports.ReleaseRef{Latest: true})
142+ if err != nil {
143+ s.fail(ctx, w, r, err)
144+ return
145+ }
146+ cacheHeaders(ctx, w.Header(), "no-cache", sessionCache)
147+ http.Redirect(w, r, releaseHref(repo.String(), rel.Tag), http.StatusSeeOther)
148+}
149+
150+func releaseHeaders(h http.Header, a domain.ReleaseAsset) {
151+ // Reuse the artifact sandbox and attachment policy; never trust an
152+ // uploader's media type to execute content in the forge's origin.
153+ typ := "application/octet-stream"
154+ if a.ContentType == domain.CIArtifactZip {
155+ typ = domain.CIArtifactZip
156+ }
157+ h.Set("Content-Type", typ)
158+ h.Set("Content-Disposition", contentDisposition(attachmentName(a.Name)))
159+ h.Set("X-Content-Type-Options", "nosniff")
160+ h.Set("Content-Security-Policy", rawCSP)
161+ h.Set("Content-Length", strconv.FormatInt(a.Size, 10))
162+ h.Set("ETag", `"`+strings.Replace(a.Digest, "sha256:", "sha256-", 1)+`"`)
163+ h.Set("Accept-Ranges", "bytes")
164+}
165+func (s *Server) releaseDownload(w http.ResponseWriter, r *http.Request) {
166+ ctx, cancel := context.WithTimeout(r.Context(), 60*time.Minute)
167+ defer cancel()
168+ repo, err := domain.ParseRepoName(r.PathValue("repo"))
169+ if err != nil {
170+ s.fail(ctx, w, r, errNotFound)
171+ return
172+ }
173+ ref := ports.AssetRef{Tag: r.PathValue("tag"), Name: r.PathValue("asset")}
174+ if id := r.PathValue("id"); id != "" {
175+ ref.ID, err = strconv.ParseInt(id, 10, 64)
176+ if err != nil || ref.ID < 1 {
177+ s.fail(ctx, w, r, errNotFound)
178+ return
179+ }
180+ } else if ref.Tag == "" {
181+ ref.Latest = true
182+ }
183+ a, err := s.cfg.Releases.GetAsset(ctx, principal(ctx), repo, ref)
184+ if err != nil {
185+ s.fail(ctx, w, r, errNotFound)
186+ return
187+ }
188+ // Resolve latest/tag only once: a moving latest cannot select another
189+ // asset between reading the headers and streaming the bytes.
190+ ref = ports.AssetRef{ID: a.ID}
191+ h := w.Header()
192+ releaseHeaders(h, a)
193+ cacheHeaders(ctx, h, "no-cache", sessionCache)
194+ offset, length, partial, err := httprange.Select(r, a.Size, h.Get("ETag"))
195+ if err != nil {
196+ h.Set("Content-Range", httprange.Unsatisfied(a.Size))
197+ h.Set("Content-Length", "0")
198+ w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
199+ return
200+ }
201+ status := http.StatusOK
202+ if partial {
203+ status = http.StatusPartialContent
204+ h.Set("Content-Range", httprange.ContentRange(offset, length, a.Size))
205+ }
206+ h.Set("Content-Length", strconv.FormatInt(length, 10))
207+ if r.Method == http.MethodHead {
208+ w.WriteHeader(status)
209+ return
210+ }
211+ started := false
212+ rc := http.NewResponseController(w)
213+ defer func() { _ = rc.SetWriteDeadline(time.Time{}) }()
214+ err = s.cfg.Releases.DownloadAsset(ctx, principal(ctx), repo, ref, offset, length, func(c ports.AssetChunk) error {
215+ if !started {
216+ if c.Asset == nil || c.Asset.ID != a.ID || c.Asset.Digest != a.Digest || c.Asset.Size != a.Size {
217+ return errors.New("web: release asset changed before download")
218+ }
219+ _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
220+ w.WriteHeader(status)
221+ started = true
222+ }
223+ if len(c.Data) == 0 {
224+ return nil
225+ }
226+ _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
227+ _, err := w.Write(c.Data)
228+ return err
229+ })
230+ if err == nil {
231+ return
232+ }
233+ if !started {
234+ // Do not leave binary headers or range metadata on the HTML error page.
235+ for _, key := range []string{"Content-Length", "Content-Disposition", "Content-Range", "Content-Security-Policy", "ETag", "Accept-Ranges"} {
236+ h.Del(key)
237+ }
238+ s.fail(ctx, w, r, errNotFound)
239+ return
240+ }
241+ log := zerowrap.FromCtx(ctx)
242+ log.Error().Err(err).Str("repo", repo.String()).Int64("asset", a.ID).Msg("release download failed")
243+ panic(http.ErrAbortHandler)
244+}
A internal/adapters/web/releases.templ +67 -0
@@ -0,0 +1,67 @@
1+package web
2+
3+// ReleaseBadges are textual as well as visual, including the computed latest.
4+templ ReleaseBadges(r ReleaseRow) {
5+ if r.Draft {
6+ <span class="pill">Draft</span>
7+ }
8+ if r.Prerelease {
9+ <span class="pill">Pre-release</span>
10+ }
11+ if r.Latest {
12+ <span class="pill">Latest</span>
13+ }
14+}
15+
16+templ Releases(l Layout, p ReleaseListPage) {
17+ @Shell(l, nil) {
18+ <h1>Releases</h1>
19+ if len(p.Rows) == 0 {
20+ <p class="dim">no releases yet.</p>
21+ } else {
22+ <table class="list" aria-label="Releases">
23+ <thead><tr><th>tag</th><th>title</th><th>published</th><th>assets</th></tr></thead>
24+ <tbody>
25+ for _, r := range p.Rows {
26+ <tr>
27+ <td><a href={ templ.SafeURL(r.Href) }>{ r.Tag }</a></td>
28+ <td>{ r.Title } @ReleaseBadges(r)</td>
29+ <td>{ r.Published }</td><td>{ itoa(r.AssetCount) }</td>
30+ </tr>
31+ }
32+ </tbody>
33+ </table>
34+ }
35+ if p.Older != "" {
36+ <p class="more"><a href={ templ.SafeURL(p.Older) } rel="next">older releases</a></p>
37+ }
38+ }
39+}
40+
41+templ ReleaseDetail(l Layout, p ReleaseDetailPage) {
42+ @Shell(l, nil) {
43+ <h1>{ p.Title }</h1>
44+ <p>
45+ <span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow)
46+ if p.SHA != "" {
47+ <a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a>
48+ }
49+ <span class="dim">{ p.Published }</span>
50+ </p>
51+ <div class="md">@templ.Raw(string(p.Notes))</div>
52+ <h2>Assets</h2>
53+ if len(p.Assets) == 0 {
54+ <p class="dim">no assets.</p>
55+ } else {
56+ <table class="list" aria-label="Release assets">
57+ <thead><tr><th>name</th><th>size</th><th>content type</th></tr></thead>
58+ <tbody>
59+ for _, a := range p.Assets {
60+ <tr><td><a href={ templ.SafeURL(a.Href) } download>{ a.Name }</a></td><td>{ a.Size }</td><td>{ a.ContentType }</td></tr>
61+ }
62+ </tbody>
63+ </table>
64+ }
65+ <p><a href={ templ.SafeURL(p.SourceHref) }>Source</a></p>
66+ }
67+}
@@ -0,0 +1,67 @@
1+package web
2+
3+// ReleaseBadges are textual as well as visual, including the computed latest.
4+templ ReleaseBadges(r ReleaseRow) {
5+ if r.Draft {
6+ <span class="pill">Draft</span>
7+ }
8+ if r.Prerelease {
9+ <span class="pill">Pre-release</span>
10+ }
11+ if r.Latest {
12+ <span class="pill">Latest</span>
13+ }
14+}
15+
16+templ Releases(l Layout, p ReleaseListPage) {
17+ @Shell(l, nil) {
18+ <h1>Releases</h1>
19+ if len(p.Rows) == 0 {
20+ <p class="dim">no releases yet.</p>
21+ } else {
22+ <table class="list" aria-label="Releases">
23+ <thead><tr><th>tag</th><th>title</th><th>published</th><th>assets</th></tr></thead>
24+ <tbody>
25+ for _, r := range p.Rows {
26+ <tr>
27+ <td><a href={ templ.SafeURL(r.Href) }>{ r.Tag }</a></td>
28+ <td>{ r.Title } @ReleaseBadges(r)</td>
29+ <td>{ r.Published }</td><td>{ itoa(r.AssetCount) }</td>
30+ </tr>
31+ }
32+ </tbody>
33+ </table>
34+ }
35+ if p.Older != "" {
36+ <p class="more"><a href={ templ.SafeURL(p.Older) } rel="next">older releases</a></p>
37+ }
38+ }
39+}
40+
41+templ ReleaseDetail(l Layout, p ReleaseDetailPage) {
42+ @Shell(l, nil) {
43+ <h1>{ p.Title }</h1>
44+ <p>
45+ <span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow)
46+ if p.SHA != "" {
47+ <a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a>
48+ }
49+ <span class="dim">{ p.Published }</span>
50+ </p>
51+ <div class="md">@templ.Raw(string(p.Notes))</div>
52+ <h2>Assets</h2>
53+ if len(p.Assets) == 0 {
54+ <p class="dim">no assets.</p>
55+ } else {
56+ <table class="list" aria-label="Release assets">
57+ <thead><tr><th>name</th><th>size</th><th>content type</th></tr></thead>
58+ <tbody>
59+ for _, a := range p.Assets {
60+ <tr><td><a href={ templ.SafeURL(a.Href) } download>{ a.Name }</a></td><td>{ a.Size }</td><td>{ a.ContentType }</td></tr>
61+ }
62+ </tbody>
63+ </table>
64+ }
65+ <p><a href={ templ.SafeURL(p.SourceHref) }>Source</a></p>
66+ }
67+}
A internal/adapters/web/releases_templ.go +439 -0 generated file: collapsed
A internal/adapters/web/releases_test.go +298 -0
@@ -0,0 +1,298 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "mime"
9+ "net/http"
10+ "net/http/httptest"
11+ "net/url"
12+ "strings"
13+ "testing"
14+ "time"
15+
16+ "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/mock"
18+ "github.com/stretchr/testify/require"
19+
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
22+ "bnema.dev/ermite/internal/ports/mocks"
23+)
24+
25+type releaseFixture struct {
26+ srv *Server
27+ svc *mocks.ReleaseService
28+ published, draft domain.Release
29+ asset domain.ReleaseAsset
30+ data string
31+ downloadErr error
32+ failAfterMetadata bool
33+ downloads int
34+ tokens []string
35+}
36+
37+func newReleaseFixture(t *testing.T) *releaseFixture {
38+ t.Helper()
39+ repo := repoName(t, "proj")
40+ f := &releaseFixture{svc: mocks.NewReleaseService(t), data: "0123456789"}
41+ f.published = domain.Release{ID: 1, Repo: repo, Tag: "stable/v1", Title: "First release", Notes: "# Notes\n\n**Ready** [bad](javascript:alert(1)) <script>alert(1)</script>\n\n[guide](guide.md)", TargetCommitish: strings.Repeat("a", 40), PublishedAt: fixedNow, Prerelease: true}
42+ f.draft = domain.Release{ID: 2, Repo: repo, Tag: "unpublished", Title: "Secret draft", Draft: true, MakeLatest: true}
43+ f.asset = domain.ReleaseAsset{ID: 7, ReleaseID: 1, Repo: repo, Name: "build.zip", Size: 10, ContentType: "application/zip", Digest: "sha256:" + strings.Repeat("b", 64)}
44+ f.svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) {
45+ if ref.Latest || ref.Tag == f.published.Tag {
46+ return f.published, nil
47+ }
48+ if ref.Tag == f.draft.Tag && p.IsOwner() {
49+ return f.draft, nil
50+ }
51+ return domain.Release{}, domain.ErrNotFound
52+ }).Maybe()
53+ f.svc.EXPECT().ListReleases(mock.Anything, mock.Anything, repo, 30, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, _ int, token string) (ports.ReleasePage, error) {
54+ f.tokens = append(f.tokens, token)
55+ rows := []domain.Release{f.published}
56+ if p.IsOwner() {
57+ rows = append([]domain.Release{f.draft}, rows...)
58+ }
59+ return ports.ReleasePage{Releases: rows, NextPageToken: "next+/="}, nil
60+ }).Maybe()
61+ f.svc.EXPECT().ListAssets(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, id int64) ([]domain.ReleaseAsset, error) {
62+ if id == f.published.ID {
63+ return []domain.ReleaseAsset{f.asset}, nil
64+ }
65+ if id == f.draft.ID && p.IsOwner() {
66+ return nil, nil
67+ }
68+ return nil, domain.ErrNotFound
69+ }).Maybe()
70+ f.svc.EXPECT().GetAsset(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, ref ports.AssetRef) (domain.ReleaseAsset, error) {
71+ if ref.ID == f.asset.ID || (ref.Name == f.asset.Name && (ref.Latest || ref.Tag == f.published.Tag)) {
72+ return f.asset, nil
73+ }
74+ return domain.ReleaseAsset{}, domain.ErrNotFound
75+ }).Maybe()
76+ f.svc.EXPECT().DownloadAsset(mock.Anything, mock.Anything, repo, ports.AssetRef{ID: 7}, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error {
77+ f.downloads++
78+ deadline, ok := ctx.Deadline()
79+ require.True(t, ok)
80+ assert.InDelta(t, 3600, time.Until(deadline).Seconds(), 2)
81+ if f.downloadErr != nil && !f.failAfterMetadata {
82+ return f.downloadErr
83+ }
84+ if err := send(ports.AssetChunk{Asset: &f.asset}); err != nil {
85+ return err
86+ }
87+ if f.downloadErr != nil {
88+ return f.downloadErr
89+ }
90+ return send(ports.AssetChunk{Data: []byte(f.data[offset : offset+length])})
91+ }).Maybe()
92+ browse := mocks.NewBrowseService(t)
93+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
94+ f.srv = newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Releases: f.svc, Sessions: newSessions(t, "tok"), Limiter: &blockingLimiter{}, Now: func() time.Time { return fixedNow }})
95+ return f
96+}
97+func (f *releaseFixture) request(method, path, cookie, byteRange string) *httptest.ResponseRecorder {
98+ r := httptest.NewRequest(method, path, nil)
99+ if cookie != "" {
100+ r.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
101+ }
102+ if byteRange != "" {
103+ r.Header.Set("Range", byteRange)
104+ }
105+ w := httptest.NewRecorder()
106+ f.srv.ServeHTTP(w, r)
107+ return w
108+}
109+func TestReleasePages(t *testing.T) {
110+ f := newReleaseFixture(t)
111+ for _, path := range []string{"/proj/releases", "/proj/releases/"} {
112+ w := f.request("GET", path, "", "")
113+ require.Equal(t, 200, w.Code)
114+ body := w.Body.String()
115+ assert.Contains(t, body, "First release")
116+ assert.NotContains(t, body, "Secret draft")
117+ assert.Contains(t, body, ">Latest</span>")
118+ assert.Contains(t, body, ">Pre-release</span>")
119+ assert.Contains(t, body, `href="/proj/releases/tag/stable/v1"`)
120+ assert.Contains(t, body, "2026-10-12")
121+ assert.Contains(t, body, `<td>1</td>`)
122+ assert.Contains(t, body, `?page=next%2B%2F%3D`)
123+ assert.Contains(t, body, `aria-keyshortcuts="l"`)
124+ assert.Contains(t, body, "view=releases")
125+ }
126+ owner := f.request("GET", "/proj/releases?page=cursor%2B%2F%3D", "tok", "")
127+ require.Equal(t, 200, owner.Code)
128+ assert.Contains(t, owner.Body.String(), "Secret draft")
129+ assert.Contains(t, owner.Body.String(), ">Draft</span>")
130+ assert.Contains(t, owner.Header().Get("Cache-Control"), "no-store")
131+ assert.Equal(t, []string{"", "", "cursor+/="}, f.tokens)
132+ // The pin on the draft must not become a computed latest badge.
133+ assert.Equal(t, 1, strings.Count(owner.Body.String(), ">Latest</span>"))
134+ w := f.request("GET", "/proj/releases/tag/stable/v1", "", "")
135+ require.Equal(t, 200, w.Code)
136+ body := w.Body.String()
137+ for _, text := range []string{"<strong>Ready</strong>", "build.zip", humanSize(10), "application/zip", `href="/proj/releases/assets/7"`, `href="/proj/tree/stable/v1"`, `href="/proj/commit/` + strings.Repeat("a", 40) + `"`, "view=release"} {
138+ assert.Contains(t, body, text)
139+ }
140+ assert.NotContains(t, body, "<script>alert")
141+ assert.NotContains(t, body, `href="javascript:`)
142+ assert.Contains(t, body, `href="/proj/blob/stable/v1/guide.md"`)
143+ for _, path := range []string{"/proj/releases/tag/unknown", "/proj/releases/tag/unpublished"} {
144+ assert.Equal(t, 404, f.request("GET", path, "", "").Code)
145+ }
146+ assert.Equal(t, 200, f.request("GET", "/proj/releases/tag/unpublished", "tok", "").Code)
147+ latest := f.request("GET", "/proj/releases/latest", "", "")
148+ assert.Equal(t, 303, latest.Code)
149+ assert.Equal(t, "/proj/releases/tag/stable/v1", latest.Header().Get("Location"))
150+}
151+func TestReleaseDownloads(t *testing.T) {
152+ f := newReleaseFixture(t)
153+ // A simple tag uses the stable URL; slash tags use the page's asset-id link.
154+ f.published.Tag = "v1"
155+ for _, path := range []string{"/proj/releases/download/v1/build.zip", "/proj/releases/latest/download/build.zip", "/proj/releases/assets/7"} {
156+ for _, tc := range []struct {
157+ rangeValue, body, cr string
158+ status int
159+ }{
160+ {"", "0123456789", "", 200}, {"bytes=2-5", "2345", "bytes 2-5/10", 206}, {"bytes=7-", "789", "bytes 7-9/10", 206}, {"bytes=-3", "789", "bytes 7-9/10", 206}, {"bytes=8-99", "89", "bytes 8-9/10", 206}, {"bytes=0-0,2-3", "0123456789", "", 200}, {"bytes=10-", "", "bytes */10", 416},
161+ } {
162+ w := f.request("GET", path, "", tc.rangeValue)
163+ require.Equal(t, tc.status, w.Code, path+" "+tc.rangeValue)
164+ assert.Equal(t, tc.body, w.Body.String())
165+ assert.Equal(t, tc.cr, w.Header().Get("Content-Range"))
166+ assert.Equal(t, "bytes", w.Header().Get("Accept-Ranges"))
167+ assert.Equal(t, `"sha256-`+strings.Repeat("b", 64)+`"`, w.Header().Get("ETag"))
168+ assert.Equal(t, "application/zip", w.Header().Get("Content-Type"))
169+ assert.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options"))
170+ assert.Equal(t, rawCSP, w.Header().Get("Content-Security-Policy"))
171+ _, params, err := mime.ParseMediaType(w.Header().Get("Content-Disposition"))
172+ require.NoError(t, err)
173+ assert.Equal(t, "build.zip", params["filename"])
174+ assert.Equal(t, itoa(len(tc.body)), w.Header().Get("Content-Length"))
175+ }
176+ before := f.downloads
177+ w := f.request("HEAD", path, "", "bytes=1-2")
178+ assert.Equal(t, 200, w.Code)
179+ assert.Empty(t, w.Header().Get("Content-Range"))
180+ assert.Empty(t, w.Body.String())
181+ assert.Equal(t, "10", w.Header().Get("Content-Length"))
182+ assert.Equal(t, before, f.downloads)
183+ }
184+ for _, path := range []string{"/proj/releases/assets/0", "/proj/releases/assets/nope", "/proj/releases/assets/99", "/proj/releases/download/missing/build.zip", "/proj/releases/latest/download/nope"} {
185+ assert.Equal(t, 404, f.request("GET", path, "", "").Code)
186+ }
187+ r := httptest.NewRequest("GET", "/proj/releases/assets/7", nil)
188+ r.Header.Set("Range", "bytes=1-2")
189+ r.Header.Set("If-Range", `"old"`)
190+ w := httptest.NewRecorder()
191+ f.srv.ServeHTTP(w, r)
192+ assert.Equal(t, 200, w.Code)
193+ assert.Equal(t, f.data, w.Body.String())
194+}
195+func TestReleaseDownloadIfRange(t *testing.T) {
196+ f := newReleaseFixture(t)
197+ etag := `"sha256-` + strings.Repeat("b", 64) + `"`
198+ for _, tc := range []struct {
199+ name, ifRange, body, cr string
200+ status int
201+ }{
202+ {"matching", etag, "2345", "bytes 2-5/10", 206},
203+ {"stale", `"old"`, "0123456789", "", 200},
204+ {"date", "Wed, 21 Oct 2026 12:00:00 GMT", "0123456789", "", 200},
205+ } {
206+ r := httptest.NewRequest("GET", "/proj/releases/assets/7", nil)
207+ r.Header.Set("Range", "bytes=2-5")
208+ r.Header.Set("If-Range", tc.ifRange)
209+ w := httptest.NewRecorder()
210+ f.srv.ServeHTTP(w, r)
211+ assert.Equal(t, tc.status, w.Code, tc.name)
212+ assert.Equal(t, tc.body, w.Body.String(), tc.name)
213+ assert.Equal(t, tc.cr, w.Header().Get("Content-Range"), tc.name)
214+ }
215+}
216+
217+// A tag with a slash is one path segment once escaped: the download route must
218+// match it and resolve the tag with the slash.
219+func TestReleaseDownloadSlashTag(t *testing.T) {
220+ f := newReleaseFixture(t) // the published tag is "stable/v1"
221+ w := f.request("GET", "/proj/releases/download/"+url.PathEscape(f.published.Tag)+"/build.zip", "", "")
222+ require.Equal(t, 200, w.Code)
223+ assert.Equal(t, f.data, w.Body.String())
224+}
225+
226+func TestReleaseDownloadFailures(t *testing.T) {
227+ f := newReleaseFixture(t)
228+ f.downloadErr = errors.New("blob unavailable")
229+ w := f.request("GET", "/proj/releases/assets/7", "", "bytes=1-2")
230+ assert.Equal(t, 404, w.Code)
231+ assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
232+ for _, key := range []string{"Content-Disposition", "Content-Range", "Accept-Ranges"} {
233+ assert.Empty(t, w.Header().Get(key))
234+ }
235+ f.failAfterMetadata = true
236+ assert.PanicsWithValue(t, http.ErrAbortHandler, func() { f.request("GET", "/proj/releases/assets/7", "", "") })
237+}
238+func TestReleaseAttachmentHeaders(t *testing.T) {
239+ for _, name := range []string{"résumé.zip", "build.zip", "evil\r\nX-Injected: true.html", `<img src=x onerror=alert(1)>.html`} {
240+ h := http.Header{}
241+ releaseHeaders(h, domain.ReleaseAsset{Name: name, ContentType: "text/html", Digest: "sha256:abcd", Size: 12})
242+ assert.Equal(t, "application/octet-stream", h.Get("Content-Type"))
243+ assert.NotContains(t, h.Get("Content-Disposition"), "\r")
244+ assert.NotContains(t, h.Get("Content-Disposition"), "\n")
245+ media, params, err := mime.ParseMediaType(h.Get("Content-Disposition"))
246+ require.NoError(t, err)
247+ assert.Equal(t, "attachment", media)
248+ assert.Equal(t, attachmentName(name), params["filename"])
249+ }
250+}
251+func TestReleasesDisabled(t *testing.T) {
252+ s := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t)})
253+ for _, path := range []string{"/proj/releases", "/proj/releases/latest", "/proj/releases/assets/7"} {
254+ w := httptest.NewRecorder()
255+ s.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
256+ assert.Equal(t, 404, w.Code)
257+ }
258+}
259+
260+func TestReleaseEmptyDownloads(t *testing.T) {
261+ f := newReleaseFixture(t)
262+ f.asset.Size = 0
263+ f.data = ""
264+ w := f.request("GET", "/proj/releases/assets/7", "", "")
265+ assert.Equal(t, 200, w.Code)
266+ assert.Equal(t, "0", w.Header().Get("Content-Length"))
267+ assert.Empty(t, w.Body.String())
268+ w = f.request("GET", "/proj/releases/assets/7", "", "bytes=0-")
269+ assert.Equal(t, 416, w.Code)
270+ assert.Equal(t, "bytes */0", w.Header().Get("Content-Range"))
271+}
272+
273+func TestReleaseLatestMissing(t *testing.T) {
274+ svc := mocks.NewReleaseService(t)
275+ repo := repoName(t, "proj")
276+ svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, ports.ReleaseRef{Latest: true}).Return(domain.Release{}, domain.ErrNotFound)
277+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Releases: svc})
278+ w := httptest.NewRecorder()
279+ srv.ServeHTTP(w, httptest.NewRequest("GET", "/proj/releases/latest", nil))
280+ assert.Equal(t, 404, w.Code)
281+ assert.Empty(t, w.Header().Get("Location"))
282+}
283+
284+func TestReleaseAssetNameEscapedInPage(t *testing.T) {
285+ f := newReleaseFixture(t)
286+ f.asset.Name = `<img src=x onerror=alert(1)>`
287+ w := f.request("GET", "/proj/releases/tag/stable/v1", "", "")
288+ require.Equal(t, 200, w.Code)
289+ assert.NotContains(t, w.Body.String(), f.asset.Name)
290+ assert.Contains(t, w.Body.String(), "&lt;img src=x onerror=alert(1)&gt;")
291+}
292+
293+func TestReleaseRowFallbacks(t *testing.T) {
294+ row := releaseRow("proj", domain.Release{ID: 1, Tag: "v1", Draft: true, PublishedAt: fixedNow}, 0)
295+ assert.Equal(t, "v1", row.Title)
296+ assert.Equal(t, "draft", row.Published)
297+ assert.False(t, row.Latest)
298+}
@@ -0,0 +1,298 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "mime"
9+ "net/http"
10+ "net/http/httptest"
11+ "net/url"
12+ "strings"
13+ "testing"
14+ "time"
15+
16+ "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/mock"
18+ "github.com/stretchr/testify/require"
19+
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
22+ "bnema.dev/ermite/internal/ports/mocks"
23+)
24+
25+type releaseFixture struct {
26+ srv *Server
27+ svc *mocks.ReleaseService
28+ published, draft domain.Release
29+ asset domain.ReleaseAsset
30+ data string
31+ downloadErr error
32+ failAfterMetadata bool
33+ downloads int
34+ tokens []string
35+}
36+
37+func newReleaseFixture(t *testing.T) *releaseFixture {
38+ t.Helper()
39+ repo := repoName(t, "proj")
40+ f := &releaseFixture{svc: mocks.NewReleaseService(t), data: "0123456789"}
41+ f.published = domain.Release{ID: 1, Repo: repo, Tag: "stable/v1", Title: "First release", Notes: "# Notes\n\n**Ready** [bad](javascript:alert(1)) <script>alert(1)</script>\n\n[guide](guide.md)", TargetCommitish: strings.Repeat("a", 40), PublishedAt: fixedNow, Prerelease: true}
42+ f.draft = domain.Release{ID: 2, Repo: repo, Tag: "unpublished", Title: "Secret draft", Draft: true, MakeLatest: true}
43+ f.asset = domain.ReleaseAsset{ID: 7, ReleaseID: 1, Repo: repo, Name: "build.zip", Size: 10, ContentType: "application/zip", Digest: "sha256:" + strings.Repeat("b", 64)}
44+ f.svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) {
45+ if ref.Latest || ref.Tag == f.published.Tag {
46+ return f.published, nil
47+ }
48+ if ref.Tag == f.draft.Tag && p.IsOwner() {
49+ return f.draft, nil
50+ }
51+ return domain.Release{}, domain.ErrNotFound
52+ }).Maybe()
53+ f.svc.EXPECT().ListReleases(mock.Anything, mock.Anything, repo, 30, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, _ int, token string) (ports.ReleasePage, error) {
54+ f.tokens = append(f.tokens, token)
55+ rows := []domain.Release{f.published}
56+ if p.IsOwner() {
57+ rows = append([]domain.Release{f.draft}, rows...)
58+ }
59+ return ports.ReleasePage{Releases: rows, NextPageToken: "next+/="}, nil
60+ }).Maybe()
61+ f.svc.EXPECT().ListAssets(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, id int64) ([]domain.ReleaseAsset, error) {
62+ if id == f.published.ID {
63+ return []domain.ReleaseAsset{f.asset}, nil
64+ }
65+ if id == f.draft.ID && p.IsOwner() {
66+ return nil, nil
67+ }
68+ return nil, domain.ErrNotFound
69+ }).Maybe()
70+ f.svc.EXPECT().GetAsset(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, ref ports.AssetRef) (domain.ReleaseAsset, error) {
71+ if ref.ID == f.asset.ID || (ref.Name == f.asset.Name && (ref.Latest || ref.Tag == f.published.Tag)) {
72+ return f.asset, nil
73+ }
74+ return domain.ReleaseAsset{}, domain.ErrNotFound
75+ }).Maybe()
76+ f.svc.EXPECT().DownloadAsset(mock.Anything, mock.Anything, repo, ports.AssetRef{ID: 7}, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error {
77+ f.downloads++
78+ deadline, ok := ctx.Deadline()
79+ require.True(t, ok)
80+ assert.InDelta(t, 3600, time.Until(deadline).Seconds(), 2)
81+ if f.downloadErr != nil && !f.failAfterMetadata {
82+ return f.downloadErr
83+ }
84+ if err := send(ports.AssetChunk{Asset: &f.asset}); err != nil {
85+ return err
86+ }
87+ if f.downloadErr != nil {
88+ return f.downloadErr
89+ }
90+ return send(ports.AssetChunk{Data: []byte(f.data[offset : offset+length])})
91+ }).Maybe()
92+ browse := mocks.NewBrowseService(t)
93+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
94+ f.srv = newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Releases: f.svc, Sessions: newSessions(t, "tok"), Limiter: &blockingLimiter{}, Now: func() time.Time { return fixedNow }})
95+ return f
96+}
97+func (f *releaseFixture) request(method, path, cookie, byteRange string) *httptest.ResponseRecorder {
98+ r := httptest.NewRequest(method, path, nil)
99+ if cookie != "" {
100+ r.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
101+ }
102+ if byteRange != "" {
103+ r.Header.Set("Range", byteRange)
104+ }
105+ w := httptest.NewRecorder()
106+ f.srv.ServeHTTP(w, r)
107+ return w
108+}
109+func TestReleasePages(t *testing.T) {
110+ f := newReleaseFixture(t)
111+ for _, path := range []string{"/proj/releases", "/proj/releases/"} {
112+ w := f.request("GET", path, "", "")
113+ require.Equal(t, 200, w.Code)
114+ body := w.Body.String()
115+ assert.Contains(t, body, "First release")
116+ assert.NotContains(t, body, "Secret draft")
117+ assert.Contains(t, body, ">Latest</span>")
118+ assert.Contains(t, body, ">Pre-release</span>")
119+ assert.Contains(t, body, `href="/proj/releases/tag/stable/v1"`)
120+ assert.Contains(t, body, "2026-10-12")
121+ assert.Contains(t, body, `<td>1</td>`)
122+ assert.Contains(t, body, `?page=next%2B%2F%3D`)
123+ assert.Contains(t, body, `aria-keyshortcuts="l"`)
124+ assert.Contains(t, body, "view=releases")
125+ }
126+ owner := f.request("GET", "/proj/releases?page=cursor%2B%2F%3D", "tok", "")
127+ require.Equal(t, 200, owner.Code)
128+ assert.Contains(t, owner.Body.String(), "Secret draft")
129+ assert.Contains(t, owner.Body.String(), ">Draft</span>")
130+ assert.Contains(t, owner.Header().Get("Cache-Control"), "no-store")
131+ assert.Equal(t, []string{"", "", "cursor+/="}, f.tokens)
132+ // The pin on the draft must not become a computed latest badge.
133+ assert.Equal(t, 1, strings.Count(owner.Body.String(), ">Latest</span>"))
134+ w := f.request("GET", "/proj/releases/tag/stable/v1", "", "")
135+ require.Equal(t, 200, w.Code)
136+ body := w.Body.String()
137+ for _, text := range []string{"<strong>Ready</strong>", "build.zip", humanSize(10), "application/zip", `href="/proj/releases/assets/7"`, `href="/proj/tree/stable/v1"`, `href="/proj/commit/` + strings.Repeat("a", 40) + `"`, "view=release"} {
138+ assert.Contains(t, body, text)
139+ }
140+ assert.NotContains(t, body, "<script>alert")
141+ assert.NotContains(t, body, `href="javascript:`)
142+ assert.Contains(t, body, `href="/proj/blob/stable/v1/guide.md"`)
143+ for _, path := range []string{"/proj/releases/tag/unknown", "/proj/releases/tag/unpublished"} {
144+ assert.Equal(t, 404, f.request("GET", path, "", "").Code)
145+ }
146+ assert.Equal(t, 200, f.request("GET", "/proj/releases/tag/unpublished", "tok", "").Code)
147+ latest := f.request("GET", "/proj/releases/latest", "", "")
148+ assert.Equal(t, 303, latest.Code)
149+ assert.Equal(t, "/proj/releases/tag/stable/v1", latest.Header().Get("Location"))
150+}
151+func TestReleaseDownloads(t *testing.T) {
152+ f := newReleaseFixture(t)
153+ // A simple tag uses the stable URL; slash tags use the page's asset-id link.
154+ f.published.Tag = "v1"
155+ for _, path := range []string{"/proj/releases/download/v1/build.zip", "/proj/releases/latest/download/build.zip", "/proj/releases/assets/7"} {
156+ for _, tc := range []struct {
157+ rangeValue, body, cr string
158+ status int
159+ }{
160+ {"", "0123456789", "", 200}, {"bytes=2-5", "2345", "bytes 2-5/10", 206}, {"bytes=7-", "789", "bytes 7-9/10", 206}, {"bytes=-3", "789", "bytes 7-9/10", 206}, {"bytes=8-99", "89", "bytes 8-9/10", 206}, {"bytes=0-0,2-3", "0123456789", "", 200}, {"bytes=10-", "", "bytes */10", 416},
161+ } {
162+ w := f.request("GET", path, "", tc.rangeValue)
163+ require.Equal(t, tc.status, w.Code, path+" "+tc.rangeValue)
164+ assert.Equal(t, tc.body, w.Body.String())
165+ assert.Equal(t, tc.cr, w.Header().Get("Content-Range"))
166+ assert.Equal(t, "bytes", w.Header().Get("Accept-Ranges"))
167+ assert.Equal(t, `"sha256-`+strings.Repeat("b", 64)+`"`, w.Header().Get("ETag"))
168+ assert.Equal(t, "application/zip", w.Header().Get("Content-Type"))
169+ assert.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options"))
170+ assert.Equal(t, rawCSP, w.Header().Get("Content-Security-Policy"))
171+ _, params, err := mime.ParseMediaType(w.Header().Get("Content-Disposition"))
172+ require.NoError(t, err)
173+ assert.Equal(t, "build.zip", params["filename"])
174+ assert.Equal(t, itoa(len(tc.body)), w.Header().Get("Content-Length"))
175+ }
176+ before := f.downloads
177+ w := f.request("HEAD", path, "", "bytes=1-2")
178+ assert.Equal(t, 200, w.Code)
179+ assert.Empty(t, w.Header().Get("Content-Range"))
180+ assert.Empty(t, w.Body.String())
181+ assert.Equal(t, "10", w.Header().Get("Content-Length"))
182+ assert.Equal(t, before, f.downloads)
183+ }
184+ for _, path := range []string{"/proj/releases/assets/0", "/proj/releases/assets/nope", "/proj/releases/assets/99", "/proj/releases/download/missing/build.zip", "/proj/releases/latest/download/nope"} {
185+ assert.Equal(t, 404, f.request("GET", path, "", "").Code)
186+ }
187+ r := httptest.NewRequest("GET", "/proj/releases/assets/7", nil)
188+ r.Header.Set("Range", "bytes=1-2")
189+ r.Header.Set("If-Range", `"old"`)
190+ w := httptest.NewRecorder()
191+ f.srv.ServeHTTP(w, r)
192+ assert.Equal(t, 200, w.Code)
193+ assert.Equal(t, f.data, w.Body.String())
194+}
195+func TestReleaseDownloadIfRange(t *testing.T) {
196+ f := newReleaseFixture(t)
197+ etag := `"sha256-` + strings.Repeat("b", 64) + `"`
198+ for _, tc := range []struct {
199+ name, ifRange, body, cr string
200+ status int
201+ }{
202+ {"matching", etag, "2345", "bytes 2-5/10", 206},
203+ {"stale", `"old"`, "0123456789", "", 200},
204+ {"date", "Wed, 21 Oct 2026 12:00:00 GMT", "0123456789", "", 200},
205+ } {
206+ r := httptest.NewRequest("GET", "/proj/releases/assets/7", nil)
207+ r.Header.Set("Range", "bytes=2-5")
208+ r.Header.Set("If-Range", tc.ifRange)
209+ w := httptest.NewRecorder()
210+ f.srv.ServeHTTP(w, r)
211+ assert.Equal(t, tc.status, w.Code, tc.name)
212+ assert.Equal(t, tc.body, w.Body.String(), tc.name)
213+ assert.Equal(t, tc.cr, w.Header().Get("Content-Range"), tc.name)
214+ }
215+}
216+
217+// A tag with a slash is one path segment once escaped: the download route must
218+// match it and resolve the tag with the slash.
219+func TestReleaseDownloadSlashTag(t *testing.T) {
220+ f := newReleaseFixture(t) // the published tag is "stable/v1"
221+ w := f.request("GET", "/proj/releases/download/"+url.PathEscape(f.published.Tag)+"/build.zip", "", "")
222+ require.Equal(t, 200, w.Code)
223+ assert.Equal(t, f.data, w.Body.String())
224+}
225+
226+func TestReleaseDownloadFailures(t *testing.T) {
227+ f := newReleaseFixture(t)
228+ f.downloadErr = errors.New("blob unavailable")
229+ w := f.request("GET", "/proj/releases/assets/7", "", "bytes=1-2")
230+ assert.Equal(t, 404, w.Code)
231+ assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
232+ for _, key := range []string{"Content-Disposition", "Content-Range", "Accept-Ranges"} {
233+ assert.Empty(t, w.Header().Get(key))
234+ }
235+ f.failAfterMetadata = true
236+ assert.PanicsWithValue(t, http.ErrAbortHandler, func() { f.request("GET", "/proj/releases/assets/7", "", "") })
237+}
238+func TestReleaseAttachmentHeaders(t *testing.T) {
239+ for _, name := range []string{"résumé.zip", "build.zip", "evil\r\nX-Injected: true.html", `<img src=x onerror=alert(1)>.html`} {
240+ h := http.Header{}
241+ releaseHeaders(h, domain.ReleaseAsset{Name: name, ContentType: "text/html", Digest: "sha256:abcd", Size: 12})
242+ assert.Equal(t, "application/octet-stream", h.Get("Content-Type"))
243+ assert.NotContains(t, h.Get("Content-Disposition"), "\r")
244+ assert.NotContains(t, h.Get("Content-Disposition"), "\n")
245+ media, params, err := mime.ParseMediaType(h.Get("Content-Disposition"))
246+ require.NoError(t, err)
247+ assert.Equal(t, "attachment", media)
248+ assert.Equal(t, attachmentName(name), params["filename"])
249+ }
250+}
251+func TestReleasesDisabled(t *testing.T) {
252+ s := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t)})
253+ for _, path := range []string{"/proj/releases", "/proj/releases/latest", "/proj/releases/assets/7"} {
254+ w := httptest.NewRecorder()
255+ s.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
256+ assert.Equal(t, 404, w.Code)
257+ }
258+}
259+
260+func TestReleaseEmptyDownloads(t *testing.T) {
261+ f := newReleaseFixture(t)
262+ f.asset.Size = 0
263+ f.data = ""
264+ w := f.request("GET", "/proj/releases/assets/7", "", "")
265+ assert.Equal(t, 200, w.Code)
266+ assert.Equal(t, "0", w.Header().Get("Content-Length"))
267+ assert.Empty(t, w.Body.String())
268+ w = f.request("GET", "/proj/releases/assets/7", "", "bytes=0-")
269+ assert.Equal(t, 416, w.Code)
270+ assert.Equal(t, "bytes */0", w.Header().Get("Content-Range"))
271+}
272+
273+func TestReleaseLatestMissing(t *testing.T) {
274+ svc := mocks.NewReleaseService(t)
275+ repo := repoName(t, "proj")
276+ svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, ports.ReleaseRef{Latest: true}).Return(domain.Release{}, domain.ErrNotFound)
277+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Releases: svc})
278+ w := httptest.NewRecorder()
279+ srv.ServeHTTP(w, httptest.NewRequest("GET", "/proj/releases/latest", nil))
280+ assert.Equal(t, 404, w.Code)
281+ assert.Empty(t, w.Header().Get("Location"))
282+}
283+
284+func TestReleaseAssetNameEscapedInPage(t *testing.T) {
285+ f := newReleaseFixture(t)
286+ f.asset.Name = `<img src=x onerror=alert(1)>`
287+ w := f.request("GET", "/proj/releases/tag/stable/v1", "", "")
288+ require.Equal(t, 200, w.Code)
289+ assert.NotContains(t, w.Body.String(), f.asset.Name)
290+ assert.Contains(t, w.Body.String(), "&lt;img src=x onerror=alert(1)&gt;")
291+}
292+
293+func TestReleaseRowFallbacks(t *testing.T) {
294+ row := releaseRow("proj", domain.Release{ID: 1, Tag: "v1", Draft: true, PublishedAt: fixedNow}, 0)
295+ assert.Equal(t, "v1", row.Title)
296+ assert.Equal(t, "draft", row.Published)
297+ assert.False(t, row.Latest)
298+}
M internal/adapters/web/repo.go +3 -0
@@ -156,6 +156,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
156156 if s.cfg.Issues != nil && d.repo.EffectiveSettings().IssuesEnabled() {
157157 l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))
158158 }
159+ if s.cfg.Releases != nil {
160+ l.Sections = append(l.Sections, mk("l", "Releases", releasesBase(d.name()), "releases"))
161+ }
159162 l.Sections = s.withActions(l.Sections, d.name(), section)
160163 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
161164
@@ -156,6 +156,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
156 if s.cfg.Issues != nil && d.repo.EffectiveSettings().IssuesEnabled() {156 if s.cfg.Issues != nil && d.repo.EffectiveSettings().IssuesEnabled() {
157 l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))157 l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))
158 }158 }
159+ if s.cfg.Releases != nil {
160+ l.Sections = append(l.Sections, mk("l", "Releases", releasesBase(d.name()), "releases"))
161+ }
159 l.Sections = s.withActions(l.Sections, d.name(), section)162 l.Sections = s.withActions(l.Sections, d.name(), section)
160 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}163 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
161 164
M internal/adapters/web/web.go +11 -0
@@ -91,6 +91,8 @@ type Config struct {
9191 // their download and their deletion. It needs Runs. Without it there is
9292 // neither. Leave it unset rather than assigning a typed nil pointer.
9393 Artifacts ports.ArtifactService
94+ // Releases serves the release pages and streamed assets. Nil disables them.
95+ Releases ports.ReleaseService
9496 // Events is the source of the event stream behind /-/events. Without
9597 // it the pages are not live and the route answers 404. Leave it unset
9698 // rather than assigning a typed nil pointer.
@@ -252,6 +254,15 @@ func (s *Server) routes() *http.ServeMux {
252254 mux.HandleFunc("GET /{repo}/issues/submitted", s.issueSubmittedPage)
253255 mux.HandleFunc("POST /{repo}/issues/new", s.issueFormPost)
254256 }
257+ if s.cfg.Releases != nil {
258+ mux.HandleFunc("GET /{repo}/releases", s.page(s.releaseList))
259+ mux.HandleFunc("GET /{repo}/releases/{$}", s.page(s.releaseList))
260+ mux.HandleFunc("GET /{repo}/releases/tag/{tag...}", s.page(s.releaseShow))
261+ mux.HandleFunc("GET /{repo}/releases/latest", s.releaseLatest)
262+ mux.HandleFunc("GET /{repo}/releases/download/{tag}/{asset}", s.releaseDownload)
263+ mux.HandleFunc("GET /{repo}/releases/latest/download/{asset}", s.releaseDownload)
264+ mux.HandleFunc("GET /{repo}/releases/assets/{id}", s.releaseDownload)
265+ }
255266 if s.cfg.Runs != nil {
256267 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
257268 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))
@@ -91,6 +91,8 @@ type Config struct {
91 // their download and their deletion. It needs Runs. Without it there is91 // their download and their deletion. It needs Runs. Without it there is
92 // neither. Leave it unset rather than assigning a typed nil pointer.92 // neither. Leave it unset rather than assigning a typed nil pointer.
93 Artifacts ports.ArtifactService93 Artifacts ports.ArtifactService
94+ // Releases serves the release pages and streamed assets. Nil disables them.
95+ Releases ports.ReleaseService
94 // Events is the source of the event stream behind /-/events. Without96 // Events is the source of the event stream behind /-/events. Without
95 // it the pages are not live and the route answers 404. Leave it unset97 // it the pages are not live and the route answers 404. Leave it unset
96 // rather than assigning a typed nil pointer.98 // rather than assigning a typed nil pointer.
@@ -252,6 +254,15 @@ func (s *Server) routes() *http.ServeMux {
252 mux.HandleFunc("GET /{repo}/issues/submitted", s.issueSubmittedPage)254 mux.HandleFunc("GET /{repo}/issues/submitted", s.issueSubmittedPage)
253 mux.HandleFunc("POST /{repo}/issues/new", s.issueFormPost)255 mux.HandleFunc("POST /{repo}/issues/new", s.issueFormPost)
254 }256 }
257+ if s.cfg.Releases != nil {
258+ mux.HandleFunc("GET /{repo}/releases", s.page(s.releaseList))
259+ mux.HandleFunc("GET /{repo}/releases/{$}", s.page(s.releaseList))
260+ mux.HandleFunc("GET /{repo}/releases/tag/{tag...}", s.page(s.releaseShow))
261+ mux.HandleFunc("GET /{repo}/releases/latest", s.releaseLatest)
262+ mux.HandleFunc("GET /{repo}/releases/download/{tag}/{asset}", s.releaseDownload)
263+ mux.HandleFunc("GET /{repo}/releases/latest/download/{asset}", s.releaseDownload)
264+ mux.HandleFunc("GET /{repo}/releases/assets/{id}", s.releaseDownload)
265+ }
255 if s.cfg.Runs != nil {266 if s.cfg.Runs != nil {
256 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))267 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
257 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))268 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready", "releases"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198 require.NoError(t, err, who)198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}, resp.Msg.GetFeatures(), who)199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready", "releases"}, resp.Msg.GetFeatures(), who)
200 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
201 }201 }
202 })202 })
M internal/app/ci_artifacts_test.go +110 -12
@@ -25,6 +25,7 @@ import (
2525
2626 pb "bnema.dev/ermite-api/gen/ermite"
2727 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
28+ "bnema.dev/ermite/internal/adapters/blobdisk"
2829 "bnema.dev/ermite/internal/adapters/ciartifacts"
2930 "bnema.dev/ermite/internal/domain"
3031 "bnema.dev/ermite/internal/ports"
@@ -48,6 +49,14 @@ jobs:
4849 - run: echo down
4950 `
5051
52+// newArtifactHarness uses the production blob wiring.
53+func newArtifactHarness(t *testing.T, tweak func(*usecase.CIConfig)) *ciHarness {
54+ t.Helper()
55+ c := newCIHarness(t, tweak)
56+ c.artifactRoot = blobdisk.Dir(c.data)
57+ return c
58+}
59+
5160 // artifactAPI are the artifact clients of one caller.
5261 type artifactAPI struct {
5362 job ermiteconnect.JobArtifactServiceClient
@@ -202,11 +211,11 @@ func (c *ciHarness) usage() int64 {
202211 return used
203212 }
204213
205-// artifactFiles lists every file under ci/artifacts.
214+// artifactFiles lists the new CI objects under the test blob root.
206215 func (c *ciHarness) artifactFiles() []string {
207216 c.t.Helper()
208217 var out []string
209- root := ciartifacts.Dir(c.data)
218+ root := filepath.Join(c.artifactRoot, "objects", "ci")
210219 _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
211220 if err == nil && !d.IsDir() {
212221 rel, _ := filepath.Rel(root, path)
@@ -249,7 +258,7 @@ func (c *ciHarness) newArtifactRun(r *fakeRunner, files map[string]string) (runI
249258 }
250259
251260 func TestCIArtifactsPassBetweenJobs(t *testing.T) {
252- c := newCIHarness(t, nil)
261+ c := newArtifactHarness(t, nil)
253262 runner := c.registerRunner("alpha", "ubuntu-latest")
254263 _, upJob, downJob := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
255264 up, down := c.jobArtifacts(upJob), c.jobArtifacts(downJob)
@@ -417,7 +426,7 @@ func TestCIArtifactsPassBetweenJobs(t *testing.T) {
417426 // AC3: a job token of another run reaches nothing of this run, and the calls
418427 // are refused once the job ended.
419428 func TestCIArtifactsAreIsolatedPerRunAndJob(t *testing.T) {
420- c := newCIHarness(t, nil)
429+ c := newArtifactHarness(t, nil)
421430 runner := c.registerRunner("alpha", "ubuntu-latest")
422431 runA, upA, downA := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
423432 _, upB, _ := c.newArtifactRun(runner, map[string]string{"README": "two"})
@@ -505,7 +514,7 @@ func (s *shiftClock) since(t time.Time) time.Duration { return s.Now().Sub(t) }
505514
506515 func TestCIArtifactLimits(t *testing.T) {
507516 const mib = 1 << 20
508- c := newCIHarness(t, func(cfg *usecase.CIConfig) {
517+ c := newArtifactHarness(t, func(cfg *usecase.CIConfig) {
509518 cfg.ArtifactLimits = ports.ArtifactLimits{Retention: 10 * 24 * time.Hour, Quota: 3 * mib, MaxSize: 2 * mib}
510519 })
511520 runner := c.registerRunner("alpha", "ubuntu-latest")
@@ -567,7 +576,9 @@ func TestCIArtifactLimits(t *testing.T) {
567576
568577 func TestCIArtifactsSweep(t *testing.T) {
569578 clk := &shiftClock{}
570- c := newCIHarness(t, func(cfg *usecase.CIConfig) {
579+ legacyRoot := ciartifacts.Dir(t.TempDir())
580+ c := newArtifactHarness(t, func(cfg *usecase.CIConfig) {
581+ cfg.ArtifactFiles = ciartifacts.New(legacyRoot)
571582 cfg.Clock = clk
572583 cfg.Tick = 20 * time.Millisecond
573584 cfg.ArtifactLimits.Retention = 2 * 24 * time.Hour
@@ -583,9 +594,10 @@ func TestCIArtifactsSweep(t *testing.T) {
583594 crashed := up.mustCreate("crashed")
584595 _, err := up.block(crashed, "x", []byte("stale"))
585596 require.NoError(t, err)
586- stray := filepath.Join(ciartifacts.Dir(c.data), fmt.Sprintf("%020d", runID), fmt.Sprintf("%020d", int64(999999)))
597+ stray := filepath.Join(legacyRoot, fmt.Sprintf("%020d", runID), fmt.Sprintf("%020d", int64(999999)))
598+ require.NoError(t, os.MkdirAll(filepath.Dir(stray), 0o700))
587599 require.NoError(t, os.WriteFile(stray, []byte("orphan"), 0o600))
588- strayRun := filepath.Join(ciartifacts.Dir(c.data), fmt.Sprintf("%020d", int64(888888)))
600+ strayRun := filepath.Join(legacyRoot, fmt.Sprintf("%020d", int64(888888)))
589601 require.NoError(t, os.MkdirAll(strayRun, 0o700))
590602 start := clk.Now()
591603
@@ -619,7 +631,7 @@ func TestCIArtifactsSweep(t *testing.T) {
619631
620632 // Deleting the repository takes the records and the files of its artifacts.
621633 func TestCIArtifactsGoWithTheRepository(t *testing.T) {
622- c := newCIHarness(t, nil)
634+ c := newArtifactHarness(t, nil)
623635 runner := c.registerRunner("alpha", "ubuntu-latest")
624636 _, upJob, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
625637 a := c.jobArtifacts(upJob).upload("dist", []byte("x"))
@@ -648,7 +660,7 @@ func TestCIArtifactsOff(t *testing.T) {
648660 // before they are written, against the charges and the other writes.
649661 func TestCIArtifactLimitsHoldUnderConcurrency(t *testing.T) {
650662 const kib, mib = 1 << 10, 1 << 20
651- c := newCIHarness(t, func(cfg *usecase.CIConfig) {
663+ c := newArtifactHarness(t, func(cfg *usecase.CIConfig) {
652664 cfg.ArtifactLimits = ports.ArtifactLimits{Quota: 3 * mib, MaxSize: 2 * mib}
653665 })
654666 runner := c.registerRunner("alpha", "ubuntu-latest")
@@ -657,7 +669,7 @@ func TestCIArtifactLimitsHoldUnderConcurrency(t *testing.T) {
657669
658670 blockFiles := func() (total int64) {
659671 for _, f := range c.artifactFiles() {
660- if info, err := os.Stat(filepath.Join(ciartifacts.Dir(c.data), f)); err == nil {
672+ if info, err := os.Stat(filepath.Join(c.artifactRoot, "objects", "ci", f)); err == nil {
661673 total += info.Size()
662674 }
663675 }
@@ -733,7 +745,7 @@ func TestCIArtifactLimitsHoldUnderConcurrency(t *testing.T) {
733745 }
734746
735747 func TestCIArtifactCaps(t *testing.T) {
736- c := newCIHarness(t, nil)
748+ c := newArtifactHarness(t, nil)
737749 runner := c.registerRunner("alpha", "ubuntu-latest")
738750 _, upJob, downJob := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
739751 up, down := c.jobArtifacts(upJob), c.jobArtifacts(downJob)
@@ -787,3 +799,89 @@ func TestCIArtifactCaps(t *testing.T) {
787799 assert.True(t, bytes.Equal(content, body))
788800 })
789801 }
802+
803+// Both storage generations remain downloadable through the real RPC handlers.
804+func TestCIArtifactBlobAndLegacyDownload(t *testing.T) {
805+ // This test inserts legacy data after startup; supply its reader explicitly.
806+ legacyRoot := ciartifacts.Dir(t.TempDir())
807+ c := newArtifactHarness(t, func(cfg *usecase.CIConfig) { cfg.ArtifactFiles = ciartifacts.New(legacyRoot) })
808+ runner := c.registerRunner("alpha", "ubuntu-latest")
809+ _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
810+ up := c.jobArtifacts(job)
811+ current := up.upload("new", []byte("new blob"))
812+ store := c.forge.store.Artifacts()
813+ a, err := store.GetArtifact(c.ctx, current.GetId())
814+ require.NoError(t, err)
815+ assert.Equal(t, domain.CIArtifactStoreBlob, a.Store)
816+ path := filepath.Join(c.artifactRoot, "objects", filepath.FromSlash(a.BlobKey()))
817+ data, err := os.ReadFile(path)
818+ require.NoError(t, err)
819+ assert.Equal(t, "new blob", string(data))
820+ _, got, err := up.download(a.ID, 0)
821+ require.NoError(t, err)
822+ assert.Equal(t, data, got)
823+
824+ // Simulate an on-disk record written by the pre-upgrade server: no store
825+ // field and contents at the old id-only path, absent from the blob store.
826+ legacy := up.upload("old", []byte("old disk"))
827+ a, err = store.GetArtifact(c.ctx, legacy.GetId())
828+ require.NoError(t, err)
829+ oldPath := filepath.Join(legacyRoot, fmt.Sprintf("%020d", a.RunID), fmt.Sprintf("%020d", a.ID))
830+ require.NoError(t, os.MkdirAll(filepath.Dir(oldPath), 0o700))
831+ require.NoError(t, os.WriteFile(oldPath, []byte("old disk"), 0o600))
832+ require.NoError(t, os.Remove(filepath.Join(c.artifactRoot, "objects", filepath.FromSlash(a.BlobKey()))))
833+ used, version, err := store.ArtifactUsage(c.ctx, a.Repo)
834+ require.NoError(t, err)
835+ a.Store = domain.CIArtifactStoreLegacy
836+ require.NoError(t, store.ApplyArtifacts(c.ctx, ports.CIArtifactWrite{Repo: a.Repo, Used: used, UsageVersion: version, Put: []domain.CIArtifact{a}}))
837+ _, got, err = up.download(a.ID, 0)
838+ require.NoError(t, err)
839+ assert.Equal(t, "old disk", string(got))
840+ require.NoError(t, up.remove("old"))
841+ _, err = os.Stat(oldPath)
842+ assert.True(t, os.IsNotExist(err))
843+}
844+
845+func TestCIArtifactWithoutLegacyReader(t *testing.T) {
846+ c := newArtifactHarness(t, func(cfg *usecase.CIConfig) { cfg.ArtifactFiles = nil })
847+ runner := c.registerRunner("alpha", "ubuntu-latest")
848+ _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
849+ up := c.jobArtifacts(job)
850+ a := up.upload("blob only", []byte("contents"))
851+ _, got, err := up.download(a.GetId(), 0)
852+ require.NoError(t, err)
853+ assert.Equal(t, "contents", string(got))
854+ require.NoError(t, c.forge.Repos.Delete(c.ctx, domain.Root(), c.name(c.repo)))
855+ assert.Empty(t, c.artifactFiles())
856+}
857+
858+func TestCIArtifactMultipartAndOversizedBlock(t *testing.T) {
859+ c := newArtifactHarness(t, nil)
860+ runner := c.registerRunner("alpha", "ubuntu-latest")
861+ _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
862+ up := c.jobArtifacts(job)
863+ id := up.mustCreate("multipart")
864+ first, second := pattern(6<<20, 1), pattern(3<<20, 2)
865+ _, err := up.block(id, "a", first)
866+ require.NoError(t, err)
867+ _, err = up.block(id, "b", second)
868+ require.NoError(t, err)
869+ committed, err := up.commit(id, "a", "b")
870+ require.NoError(t, err)
871+ _, err = up.finalize("multipart", committed.GetDigest())
872+ require.NoError(t, err)
873+ _, got, err := up.download(id, 0)
874+ require.NoError(t, err)
875+ assert.Equal(t, append(first, second...), got)
876+ before := c.usage()
877+ refused := up.mustCreate("too big")
878+ _, err = up.block(refused, "a", make([]byte, domain.CIArtifactMaxBlockSize+1))
879+ require.Error(t, err)
880+ assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
881+ assert.Contains(t, err.Error(), "a block has at most 8 MiB")
882+ assert.Equal(t, before, c.usage())
883+ a, err := c.forge.store.Artifacts().GetArtifact(c.ctx, refused)
884+ require.NoError(t, err)
885+ assert.Zero(t, a.Charged)
886+ require.NoError(t, blobdisk.New(c.artifactRoot).List(c.ctx, a.BlobKey()+".blocks/", func(ports.BlobInfo) error { t.Error("refused block survived"); return nil }))
887+}
@@ -25,6 +25,7 @@ import (
25 25
26 pb "bnema.dev/ermite-api/gen/ermite"26 pb "bnema.dev/ermite-api/gen/ermite"
27 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"27 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
28+ "bnema.dev/ermite/internal/adapters/blobdisk"
28 "bnema.dev/ermite/internal/adapters/ciartifacts"29 "bnema.dev/ermite/internal/adapters/ciartifacts"
29 "bnema.dev/ermite/internal/domain"30 "bnema.dev/ermite/internal/domain"
30 "bnema.dev/ermite/internal/ports"31 "bnema.dev/ermite/internal/ports"
@@ -48,6 +49,14 @@ jobs:
48 - run: echo down49 - run: echo down
49 `50 `
50 51
52+// newArtifactHarness uses the production blob wiring.
53+func newArtifactHarness(t *testing.T, tweak func(*usecase.CIConfig)) *ciHarness {
54+ t.Helper()
55+ c := newCIHarness(t, tweak)
56+ c.artifactRoot = blobdisk.Dir(c.data)
57+ return c
58+}
59+
51 // artifactAPI are the artifact clients of one caller.60 // artifactAPI are the artifact clients of one caller.
52 type artifactAPI struct {61 type artifactAPI struct {
53 job ermiteconnect.JobArtifactServiceClient62 job ermiteconnect.JobArtifactServiceClient
@@ -202,11 +211,11 @@ func (c *ciHarness) usage() int64 {
202 return used211 return used
203 }212 }
204 213
205-// artifactFiles lists every file under ci/artifacts.214+// artifactFiles lists the new CI objects under the test blob root.
206 func (c *ciHarness) artifactFiles() []string {215 func (c *ciHarness) artifactFiles() []string {
207 c.t.Helper()216 c.t.Helper()
208 var out []string217 var out []string
209- root := ciartifacts.Dir(c.data)218+ root := filepath.Join(c.artifactRoot, "objects", "ci")
210 _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {219 _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
211 if err == nil && !d.IsDir() {220 if err == nil && !d.IsDir() {
212 rel, _ := filepath.Rel(root, path)221 rel, _ := filepath.Rel(root, path)
@@ -249,7 +258,7 @@ func (c *ciHarness) newArtifactRun(r *fakeRunner, files map[string]string) (runI
249 }258 }
250 259
251 func TestCIArtifactsPassBetweenJobs(t *testing.T) {260 func TestCIArtifactsPassBetweenJobs(t *testing.T) {
252- c := newCIHarness(t, nil)261+ c := newArtifactHarness(t, nil)
253 runner := c.registerRunner("alpha", "ubuntu-latest")262 runner := c.registerRunner("alpha", "ubuntu-latest")
254 _, upJob, downJob := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})263 _, upJob, downJob := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
255 up, down := c.jobArtifacts(upJob), c.jobArtifacts(downJob)264 up, down := c.jobArtifacts(upJob), c.jobArtifacts(downJob)
@@ -417,7 +426,7 @@ func TestCIArtifactsPassBetweenJobs(t *testing.T) {
417 // AC3: a job token of another run reaches nothing of this run, and the calls426 // AC3: a job token of another run reaches nothing of this run, and the calls
418 // are refused once the job ended.427 // are refused once the job ended.
419 func TestCIArtifactsAreIsolatedPerRunAndJob(t *testing.T) {428 func TestCIArtifactsAreIsolatedPerRunAndJob(t *testing.T) {
420- c := newCIHarness(t, nil)429+ c := newArtifactHarness(t, nil)
421 runner := c.registerRunner("alpha", "ubuntu-latest")430 runner := c.registerRunner("alpha", "ubuntu-latest")
422 runA, upA, downA := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})431 runA, upA, downA := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
423 _, upB, _ := c.newArtifactRun(runner, map[string]string{"README": "two"})432 _, upB, _ := c.newArtifactRun(runner, map[string]string{"README": "two"})
@@ -505,7 +514,7 @@ func (s *shiftClock) since(t time.Time) time.Duration { return s.Now().Sub(t) }
505 514
506 func TestCIArtifactLimits(t *testing.T) {515 func TestCIArtifactLimits(t *testing.T) {
507 const mib = 1 << 20516 const mib = 1 << 20
508- c := newCIHarness(t, func(cfg *usecase.CIConfig) {517+ c := newArtifactHarness(t, func(cfg *usecase.CIConfig) {
509 cfg.ArtifactLimits = ports.ArtifactLimits{Retention: 10 * 24 * time.Hour, Quota: 3 * mib, MaxSize: 2 * mib}518 cfg.ArtifactLimits = ports.ArtifactLimits{Retention: 10 * 24 * time.Hour, Quota: 3 * mib, MaxSize: 2 * mib}
510 })519 })
511 runner := c.registerRunner("alpha", "ubuntu-latest")520 runner := c.registerRunner("alpha", "ubuntu-latest")
@@ -567,7 +576,9 @@ func TestCIArtifactLimits(t *testing.T) {
567 576
568 func TestCIArtifactsSweep(t *testing.T) {577 func TestCIArtifactsSweep(t *testing.T) {
569 clk := &shiftClock{}578 clk := &shiftClock{}
570- c := newCIHarness(t, func(cfg *usecase.CIConfig) {579+ legacyRoot := ciartifacts.Dir(t.TempDir())
580+ c := newArtifactHarness(t, func(cfg *usecase.CIConfig) {
581+ cfg.ArtifactFiles = ciartifacts.New(legacyRoot)
571 cfg.Clock = clk582 cfg.Clock = clk
572 cfg.Tick = 20 * time.Millisecond583 cfg.Tick = 20 * time.Millisecond
573 cfg.ArtifactLimits.Retention = 2 * 24 * time.Hour584 cfg.ArtifactLimits.Retention = 2 * 24 * time.Hour
@@ -583,9 +594,10 @@ func TestCIArtifactsSweep(t *testing.T) {
583 crashed := up.mustCreate("crashed")594 crashed := up.mustCreate("crashed")
584 _, err := up.block(crashed, "x", []byte("stale"))595 _, err := up.block(crashed, "x", []byte("stale"))
585 require.NoError(t, err)596 require.NoError(t, err)
586- stray := filepath.Join(ciartifacts.Dir(c.data), fmt.Sprintf("%020d", runID), fmt.Sprintf("%020d", int64(999999)))597+ stray := filepath.Join(legacyRoot, fmt.Sprintf("%020d", runID), fmt.Sprintf("%020d", int64(999999)))
598+ require.NoError(t, os.MkdirAll(filepath.Dir(stray), 0o700))
587 require.NoError(t, os.WriteFile(stray, []byte("orphan"), 0o600))599 require.NoError(t, os.WriteFile(stray, []byte("orphan"), 0o600))
588- strayRun := filepath.Join(ciartifacts.Dir(c.data), fmt.Sprintf("%020d", int64(888888)))600+ strayRun := filepath.Join(legacyRoot, fmt.Sprintf("%020d", int64(888888)))
589 require.NoError(t, os.MkdirAll(strayRun, 0o700))601 require.NoError(t, os.MkdirAll(strayRun, 0o700))
590 start := clk.Now()602 start := clk.Now()
591 603
@@ -619,7 +631,7 @@ func TestCIArtifactsSweep(t *testing.T) {
619 631
620 // Deleting the repository takes the records and the files of its artifacts.632 // Deleting the repository takes the records and the files of its artifacts.
621 func TestCIArtifactsGoWithTheRepository(t *testing.T) {633 func TestCIArtifactsGoWithTheRepository(t *testing.T) {
622- c := newCIHarness(t, nil)634+ c := newArtifactHarness(t, nil)
623 runner := c.registerRunner("alpha", "ubuntu-latest")635 runner := c.registerRunner("alpha", "ubuntu-latest")
624 _, upJob, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})636 _, upJob, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
625 a := c.jobArtifacts(upJob).upload("dist", []byte("x"))637 a := c.jobArtifacts(upJob).upload("dist", []byte("x"))
@@ -648,7 +660,7 @@ func TestCIArtifactsOff(t *testing.T) {
648 // before they are written, against the charges and the other writes.660 // before they are written, against the charges and the other writes.
649 func TestCIArtifactLimitsHoldUnderConcurrency(t *testing.T) {661 func TestCIArtifactLimitsHoldUnderConcurrency(t *testing.T) {
650 const kib, mib = 1 << 10, 1 << 20662 const kib, mib = 1 << 10, 1 << 20
651- c := newCIHarness(t, func(cfg *usecase.CIConfig) {663+ c := newArtifactHarness(t, func(cfg *usecase.CIConfig) {
652 cfg.ArtifactLimits = ports.ArtifactLimits{Quota: 3 * mib, MaxSize: 2 * mib}664 cfg.ArtifactLimits = ports.ArtifactLimits{Quota: 3 * mib, MaxSize: 2 * mib}
653 })665 })
654 runner := c.registerRunner("alpha", "ubuntu-latest")666 runner := c.registerRunner("alpha", "ubuntu-latest")
@@ -657,7 +669,7 @@ func TestCIArtifactLimitsHoldUnderConcurrency(t *testing.T) {
657 669
658 blockFiles := func() (total int64) {670 blockFiles := func() (total int64) {
659 for _, f := range c.artifactFiles() {671 for _, f := range c.artifactFiles() {
660- if info, err := os.Stat(filepath.Join(ciartifacts.Dir(c.data), f)); err == nil {672+ if info, err := os.Stat(filepath.Join(c.artifactRoot, "objects", "ci", f)); err == nil {
661 total += info.Size()673 total += info.Size()
662 }674 }
663 }675 }
@@ -733,7 +745,7 @@ func TestCIArtifactLimitsHoldUnderConcurrency(t *testing.T) {
733 }745 }
734 746
735 func TestCIArtifactCaps(t *testing.T) {747 func TestCIArtifactCaps(t *testing.T) {
736- c := newCIHarness(t, nil)748+ c := newArtifactHarness(t, nil)
737 runner := c.registerRunner("alpha", "ubuntu-latest")749 runner := c.registerRunner("alpha", "ubuntu-latest")
738 _, upJob, downJob := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})750 _, upJob, downJob := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
739 up, down := c.jobArtifacts(upJob), c.jobArtifacts(downJob)751 up, down := c.jobArtifacts(upJob), c.jobArtifacts(downJob)
@@ -787,3 +799,89 @@ func TestCIArtifactCaps(t *testing.T) {
787 assert.True(t, bytes.Equal(content, body))799 assert.True(t, bytes.Equal(content, body))
788 })800 })
789 }801 }
802+
803+// Both storage generations remain downloadable through the real RPC handlers.
804+func TestCIArtifactBlobAndLegacyDownload(t *testing.T) {
805+ // This test inserts legacy data after startup; supply its reader explicitly.
806+ legacyRoot := ciartifacts.Dir(t.TempDir())
807+ c := newArtifactHarness(t, func(cfg *usecase.CIConfig) { cfg.ArtifactFiles = ciartifacts.New(legacyRoot) })
808+ runner := c.registerRunner("alpha", "ubuntu-latest")
809+ _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
810+ up := c.jobArtifacts(job)
811+ current := up.upload("new", []byte("new blob"))
812+ store := c.forge.store.Artifacts()
813+ a, err := store.GetArtifact(c.ctx, current.GetId())
814+ require.NoError(t, err)
815+ assert.Equal(t, domain.CIArtifactStoreBlob, a.Store)
816+ path := filepath.Join(c.artifactRoot, "objects", filepath.FromSlash(a.BlobKey()))
817+ data, err := os.ReadFile(path)
818+ require.NoError(t, err)
819+ assert.Equal(t, "new blob", string(data))
820+ _, got, err := up.download(a.ID, 0)
821+ require.NoError(t, err)
822+ assert.Equal(t, data, got)
823+
824+ // Simulate an on-disk record written by the pre-upgrade server: no store
825+ // field and contents at the old id-only path, absent from the blob store.
826+ legacy := up.upload("old", []byte("old disk"))
827+ a, err = store.GetArtifact(c.ctx, legacy.GetId())
828+ require.NoError(t, err)
829+ oldPath := filepath.Join(legacyRoot, fmt.Sprintf("%020d", a.RunID), fmt.Sprintf("%020d", a.ID))
830+ require.NoError(t, os.MkdirAll(filepath.Dir(oldPath), 0o700))
831+ require.NoError(t, os.WriteFile(oldPath, []byte("old disk"), 0o600))
832+ require.NoError(t, os.Remove(filepath.Join(c.artifactRoot, "objects", filepath.FromSlash(a.BlobKey()))))
833+ used, version, err := store.ArtifactUsage(c.ctx, a.Repo)
834+ require.NoError(t, err)
835+ a.Store = domain.CIArtifactStoreLegacy
836+ require.NoError(t, store.ApplyArtifacts(c.ctx, ports.CIArtifactWrite{Repo: a.Repo, Used: used, UsageVersion: version, Put: []domain.CIArtifact{a}}))
837+ _, got, err = up.download(a.ID, 0)
838+ require.NoError(t, err)
839+ assert.Equal(t, "old disk", string(got))
840+ require.NoError(t, up.remove("old"))
841+ _, err = os.Stat(oldPath)
842+ assert.True(t, os.IsNotExist(err))
843+}
844+
845+func TestCIArtifactWithoutLegacyReader(t *testing.T) {
846+ c := newArtifactHarness(t, func(cfg *usecase.CIConfig) { cfg.ArtifactFiles = nil })
847+ runner := c.registerRunner("alpha", "ubuntu-latest")
848+ _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
849+ up := c.jobArtifacts(job)
850+ a := up.upload("blob only", []byte("contents"))
851+ _, got, err := up.download(a.GetId(), 0)
852+ require.NoError(t, err)
853+ assert.Equal(t, "contents", string(got))
854+ require.NoError(t, c.forge.Repos.Delete(c.ctx, domain.Root(), c.name(c.repo)))
855+ assert.Empty(t, c.artifactFiles())
856+}
857+
858+func TestCIArtifactMultipartAndOversizedBlock(t *testing.T) {
859+ c := newArtifactHarness(t, nil)
860+ runner := c.registerRunner("alpha", "ubuntu-latest")
861+ _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
862+ up := c.jobArtifacts(job)
863+ id := up.mustCreate("multipart")
864+ first, second := pattern(6<<20, 1), pattern(3<<20, 2)
865+ _, err := up.block(id, "a", first)
866+ require.NoError(t, err)
867+ _, err = up.block(id, "b", second)
868+ require.NoError(t, err)
869+ committed, err := up.commit(id, "a", "b")
870+ require.NoError(t, err)
871+ _, err = up.finalize("multipart", committed.GetDigest())
872+ require.NoError(t, err)
873+ _, got, err := up.download(id, 0)
874+ require.NoError(t, err)
875+ assert.Equal(t, append(first, second...), got)
876+ before := c.usage()
877+ refused := up.mustCreate("too big")
878+ _, err = up.block(refused, "a", make([]byte, domain.CIArtifactMaxBlockSize+1))
879+ require.Error(t, err)
880+ assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
881+ assert.Contains(t, err.Error(), "a block has at most 8 MiB")
882+ assert.Equal(t, before, c.usage())
883+ a, err := c.forge.store.Artifacts().GetArtifact(c.ctx, refused)
884+ require.NoError(t, err)
885+ assert.Zero(t, a.Charged)
886+ require.NoError(t, blobdisk.New(c.artifactRoot).List(c.ctx, a.BlobKey()+".blocks/", func(ports.BlobInfo) error { t.Error("refused block survived"); return nil }))
887+}
M internal/app/ci_e2e_test.go +2 -0
@@ -28,6 +28,8 @@ type ciHarness struct {
2828 root *clients
2929 wt string
3030 repo string
31+ // artifactRoot is supplied by the artifact test rig until P8 wires blobs.
32+ artifactRoot string
3133 }
3234
3335 const ciWorkflow = `name: ci
@@ -28,6 +28,8 @@ type ciHarness struct {
28 root *clients28 root *clients
29 wt string29 wt string
30 repo string30 repo string
31+ // artifactRoot is supplied by the artifact test rig until P8 wires blobs.
32+ artifactRoot string
31 }33 }
32 34
33 const ciWorkflow = `name: ci35 const ciWorkflow = `name: ci
M internal/app/cli_e2e_test.go +4 -3
@@ -334,7 +334,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
334334 assert.JSONEq(t, `[]`, res.stdout)
335335
336336 // version: no token needed. This server runs on http://127.0.0.1, where
337- // passkeys are not available: they are the only feature missing.
337+ // passkeys are not available.
338338 res = c.cli(t, "", "version", "--json").ok(t)
339339 assert.Equal(t, 1, strings.Count(res.stderr, "warning:"), res.stderr)
340340 assert.Contains(t, res.stderr, "the server does not support passkeys")
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350350 ver := decode[versionOut](t, res.stdout)
351351 assert.Equal(t, c.url, ver.Server.URL)
352352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}, ver.Server.Features)
353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready", "releases"}, ver.Server.Features)
354354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355355 stdout, stderr, code = c.bin(t, secret, "version")
356356 require.Equal(t, 0, code, stderr)
@@ -389,7 +389,8 @@ func TestAC6CLIEndToEnd(t *testing.T) {
389389 assert.Empty(t, tk.Secret, "list never shows secrets")
390390 byLabel[tk.Label] = tk
391391 }
392- assert.Len(t, byLabel["daily"].Scopes, len(client.AllScopes()), "the cli preset grants every scope")
392+ assert.Len(t, byLabel["daily"].Scopes, len(client.AllScopes()))
393+ assert.Contains(t, byLabel["daily"].Scopes, "release:write")
393394 assert.Equal(t, tmp.ID, byLabel["tmp"].ID)
394395 assert.NotContains(t, res.stdout, `"secret"`)
395396 res = c.cli(t, secret, "token", "list").ok(t)
@@ -334,7 +334,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
334 assert.JSONEq(t, `[]`, res.stdout)334 assert.JSONEq(t, `[]`, res.stdout)
335 335
336 // version: no token needed. This server runs on http://127.0.0.1, where336 // version: no token needed. This server runs on http://127.0.0.1, where
337- // passkeys are not available: they are the only feature missing.337+ // passkeys are not available.
338 res = c.cli(t, "", "version", "--json").ok(t)338 res = c.cli(t, "", "version", "--json").ok(t)
339 assert.Equal(t, 1, strings.Count(res.stderr, "warning:"), res.stderr)339 assert.Equal(t, 1, strings.Count(res.stderr, "warning:"), res.stderr)
340 assert.Contains(t, res.stderr, "the server does not support passkeys")340 assert.Contains(t, res.stderr, "the server does not support passkeys")
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350 ver := decode[versionOut](t, res.stdout)350 ver := decode[versionOut](t, res.stdout)
351 assert.Equal(t, c.url, ver.Server.URL)351 assert.Equal(t, c.url, ver.Server.URL)
352 assert.NotEmpty(t, ver.Server.Version)352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}, ver.Server.Features)353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready", "releases"}, ver.Server.Features)
354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355 stdout, stderr, code = c.bin(t, secret, "version")355 stdout, stderr, code = c.bin(t, secret, "version")
356 require.Equal(t, 0, code, stderr)356 require.Equal(t, 0, code, stderr)
@@ -389,7 +389,8 @@ func TestAC6CLIEndToEnd(t *testing.T) {
389 assert.Empty(t, tk.Secret, "list never shows secrets")389 assert.Empty(t, tk.Secret, "list never shows secrets")
390 byLabel[tk.Label] = tk390 byLabel[tk.Label] = tk
391 }391 }
392- assert.Len(t, byLabel["daily"].Scopes, len(client.AllScopes()), "the cli preset grants every scope")392+ assert.Len(t, byLabel["daily"].Scopes, len(client.AllScopes()))
393+ assert.Contains(t, byLabel["daily"].Scopes, "release:write")
393 assert.Equal(t, tmp.ID, byLabel["tmp"].ID)394 assert.Equal(t, tmp.ID, byLabel["tmp"].ID)
394 assert.NotContains(t, res.stdout, `"secret"`)395 assert.NotContains(t, res.stdout, `"secret"`)
395 res = c.cli(t, secret, "token", "list").ok(t)396 res = c.cli(t, secret, "token", "list").ok(t)
A internal/app/cli_release.go +603 -0
@@ -0,0 +1,603 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "bufio"
7+ "context"
8+ "crypto/sha256"
9+ "encoding/hex"
10+ "errors"
11+ "fmt"
12+ "io"
13+ "mime"
14+ "net/http"
15+ "net/url"
16+ "os"
17+ "path/filepath"
18+ "slices"
19+ "strconv"
20+ "strings"
21+
22+ "connectrpc.com/connect"
23+ "github.com/spf13/cobra"
24+ "golang.org/x/term"
25+ "google.golang.org/protobuf/types/known/fieldmaskpb"
26+
27+ "bnema.dev/ermite-api/gen/ermite"
28+ "bnema.dev/ermite/internal/adapters/client"
29+)
30+
31+var errNoReleases = errors.New("the server has no releases (it lacks the releases feature)")
32+
33+func requireReleases(ctx context.Context, c *client.Client) error {
34+ res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
35+ if err != nil {
36+ return c.Describe(err)
37+ }
38+ if !slices.Contains(res.Msg.GetFeatures(), client.FeatureReleases) {
39+ return errNoReleases
40+ }
41+ return nil
42+}
43+
44+func releaseClient(env *cliEnv, cmd *cobra.Command) (*client.Client, error) {
45+ c, err := env.client(cmd)
46+ if err != nil {
47+ return nil, err
48+ }
49+ if err := requireReleases(env.ctx(cmd), c); err != nil {
50+ return nil, err
51+ }
52+ return c, nil
53+}
54+
55+// jsonRelease and jsonReleaseAsset keep the CLI shape independent of protobuf JSON.
56+type jsonReleaseAsset struct {
57+ ID int64 `json:"id"`
58+ ReleaseID int64 `json:"release_id"`
59+ Name string `json:"name"`
60+ Label string `json:"label"`
61+ ContentType string `json:"content_type"`
62+ Size int64 `json:"size"`
63+ Digest string `json:"digest"`
64+ Uploader string `json:"uploader"`
65+ CreatedAt *string `json:"created_at"`
66+ UpdatedAt *string `json:"updated_at"`
67+}
68+type jsonRelease struct {
69+ ID int64 `json:"id"`
70+ Repo string `json:"repo"`
71+ Tag string `json:"tag"`
72+ Title string `json:"title"`
73+ Notes string `json:"notes"`
74+ Draft bool `json:"draft"`
75+ Prerelease bool `json:"prerelease"`
76+ Latest bool `json:"latest"`
77+ TargetCommitish string `json:"target_commitish"`
78+ Author string `json:"author"`
79+ CreatedAt *string `json:"created_at"`
80+ PublishedAt *string `json:"published_at"`
81+ UpdatedAt *string `json:"updated_at"`
82+ Assets []jsonReleaseAsset `json:"assets"`
83+}
84+
85+func toJSONRelease(r *ermite.Release) jsonRelease {
86+ out := jsonRelease{ID: r.GetId(), Repo: r.GetRepo(), Tag: r.GetTag(), Title: r.GetTitle(), Notes: r.GetNotes(), Draft: r.GetDraft(), Prerelease: r.GetPrerelease(), Latest: r.GetLatest(), TargetCommitish: r.GetTargetCommitish(), Author: r.GetAuthor(), CreatedAt: jsonTime(r.GetCreatedAt()), PublishedAt: jsonTime(r.GetPublishedAt()), UpdatedAt: jsonTime(r.GetUpdatedAt()), Assets: []jsonReleaseAsset{}}
87+ for _, a := range r.GetAssets() {
88+ out.Assets = append(out.Assets, jsonReleaseAsset{a.GetId(), a.GetReleaseId(), a.GetName(), a.GetLabel(), a.GetContentType(), a.GetSize(), a.GetDigest(), a.GetUploader(), jsonTime(a.GetCreatedAt()), jsonTime(a.GetUpdatedAt())})
89+ }
90+ return out
91+}
92+
93+const releaseJSONHelp = "\nJSON fields: id, repo, tag, title, notes, draft, prerelease, latest, target_commitish, author,\ncreated_at, published_at, updated_at (RFC 3339 UTC or null), assets (id, release_id, name, label,\ncontent_type, size, digest, uploader, created_at, updated_at). list prints an array."
94+
95+func newReleaseCmd(env *cliEnv) *cobra.Command {
96+ cmd := &cobra.Command{Use: "release", Short: "Manage releases and their assets", Long: "Create and manage tagged releases. Reads follow repository visibility; writes need release:write.\nNeeds a server with the releases feature. Select releases by tag, numeric id, or latest (view/download)."}
97+ cmd.AddCommand(newReleaseCreateCmd(env), newReleaseListCmd(env), newReleaseViewCmd(env), newReleaseEditCmd(env), newReleaseDeleteCmd(env), newReleaseUploadCmd(env), newReleaseDownloadCmd(env))
98+ return cmd
99+}
100+func releaseCommand(env *cliEnv, use, short string, n int) *cobra.Command {
101+ return &cobra.Command{Use: use, Short: short, Args: cobra.ExactArgs(n), ValidArgsFunction: completeRepoNames(env)}
102+}
103+
104+func getRelease(ctx context.Context, c *client.Client, repo, selector string, latest bool) (*ermite.Release, error) {
105+ req := &ermite.GetReleaseRequest{Repo: repo}
106+ if selector == "latest" && latest {
107+ req.Selector = &ermite.GetReleaseRequest_Latest{Latest: true}
108+ } else if selector != "" && strings.Trim(selector, "0123456789") == "" {
109+ id, err := strconv.ParseInt(selector, 10, 64)
110+ if err != nil || id <= 0 {
111+ return nil, fmt.Errorf("invalid release id %q", selector)
112+ }
113+ req.Selector = &ermite.GetReleaseRequest_Id{Id: id}
114+ } else {
115+ req.Selector = &ermite.GetReleaseRequest_Tag{Tag: selector}
116+ }
117+ res, err := c.Releases().GetRelease(ctx, connect.NewRequest(req))
118+ if err != nil {
119+ return nil, c.Describe(err)
120+ }
121+ return res.Msg.GetRelease(), nil
122+}
123+func releaseState(r *ermite.Release) string {
124+ if r.GetDraft() {
125+ return "draft"
126+ }
127+ if r.GetPrerelease() {
128+ return "prerelease"
129+ }
130+ return "published"
131+}
132+func releaseURL(base, repo, tag string) string {
133+ return strings.TrimRight(base, "/") + "/" + url.PathEscape(repo) + "/releases/tag/" + url.PathEscape(tag)
134+}
135+func outputRelease(cmd *cobra.Command, c *client.Client, r *ermite.Release, asJSON bool) error {
136+ if asJSON {
137+ return writeJSON(cmd.OutOrStdout(), toJSONRelease(r))
138+ }
139+ _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\nid: %d\n", releaseURL(c.URL(), r.GetRepo(), r.GetTag()), r.GetId())
140+ return err
141+}
142+
143+type releaseFields struct {
144+ title, notes, notesFile, target, tag string
145+ draft, publish, prerelease, noPrerelease, latest bool
146+}
147+
148+func (f *releaseFields) flags(cmd *cobra.Command, edit bool) {
149+ cmd.Flags().StringVar(&f.title, "title", "", "release title")
150+ cmd.Flags().StringVar(&f.notes, "notes", "", "Markdown release notes")
151+ cmd.Flags().StringVar(&f.notesFile, "notes-file", "", "read Markdown release notes from a file (- for stdin)")
152+ cmd.MarkFlagsMutuallyExclusive("notes", "notes-file")
153+ cmd.Flags().StringVar(&f.target, "target", "", "target commit or branch for a missing tag")
154+ cmd.Flags().BoolVar(&f.draft, "draft", false, "keep as a draft")
155+ cmd.Flags().BoolVar(&f.prerelease, "prerelease", false, "mark as a prerelease")
156+ cmd.Flags().BoolVar(&f.latest, "latest", false, "pin as latest")
157+ if edit {
158+ cmd.Flags().StringVar(&f.tag, "tag", "", "new tag")
159+ cmd.Flags().BoolVar(&f.publish, "publish", false, "publish a draft")
160+ cmd.Flags().BoolVar(&f.noPrerelease, "no-prerelease", false, "clear the prerelease flag")
161+ cmd.MarkFlagsMutuallyExclusive("draft", "publish")
162+ cmd.MarkFlagsMutuallyExclusive("prerelease", "no-prerelease")
163+ }
164+}
165+func (f *releaseFields) readNotes(cmd *cobra.Command) error {
166+ if !cmd.Flags().Changed("notes-file") {
167+ return nil
168+ }
169+ var data []byte
170+ var err error
171+ if f.notesFile == "-" {
172+ data, err = io.ReadAll(cmd.InOrStdin())
173+ } else {
174+ data, err = os.ReadFile(f.notesFile)
175+ }
176+ f.notes = string(data)
177+ return err
178+}
179+func newReleaseCreateCmd(env *cliEnv) *cobra.Command {
180+ var f releaseFields
181+ var assets []string
182+ cmd := releaseCommand(env, "create <repo> <tag>", "Create a release", 2)
183+ cmd.Long = "Create a release, then upload each --asset FILE[#LABEL] (repeatable; shell expands globs)." + releaseJSONHelp
184+ f.flags(cmd, false)
185+ cmd.Flags().StringArrayVar(&assets, "asset", nil, "upload FILE[#LABEL] (repeatable)")
186+ asJSON := addJSONFlag(cmd)
187+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
188+ if err := f.readNotes(cmd); err != nil {
189+ return err
190+ }
191+ c, err := releaseClient(env, cmd)
192+ if err != nil {
193+ return err
194+ }
195+ ctx := env.ctx(cmd)
196+ res, err := c.Releases().CreateRelease(ctx, connect.NewRequest(&ermite.CreateReleaseRequest{Repo: args[0], Tag: args[1], Title: f.title, Notes: f.notes, TargetCommitish: f.target, Draft: f.draft, Prerelease: f.prerelease, MakeLatest: f.latest}))
197+ if err != nil {
198+ return c.Describe(err)
199+ }
200+ r := res.Msg.GetRelease()
201+ for _, spec := range assets {
202+ a, err := uploadReleaseFile(ctx, c, args[0], r.GetId(), spec, "", false)
203+ if err != nil {
204+ return fmt.Errorf("release %d was created, but asset upload failed: %w", r.GetId(), err)
205+ }
206+ r.Assets = append(r.Assets, a)
207+ }
208+ return outputRelease(cmd, c, r, *asJSON)
209+ }
210+ return cmd
211+}
212+func newReleaseListCmd(env *cliEnv) *cobra.Command {
213+ var limit int32
214+ cmd := releaseCommand(env, "list <repo>", "List releases, newest first", 1)
215+ cmd.Long = "List releases, including drafts visible to writers." + releaseJSONHelp
216+ cmd.Flags().Int32Var(&limit, "limit", 30, "maximum number of releases")
217+ asJSON := addJSONFlag(cmd)
218+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
219+ if limit <= 0 {
220+ return errors.New("--limit must be positive")
221+ }
222+ c, err := releaseClient(env, cmd)
223+ if err != nil {
224+ return err
225+ }
226+ all := []*ermite.Release{}
227+ page := ""
228+ for int32(len(all)) < limit {
229+ res, err := c.Releases().ListReleases(env.ctx(cmd), connect.NewRequest(&ermite.ListReleasesRequest{Repo: args[0], PageSize: limit - int32(len(all)), PageToken: page}))
230+ if err != nil {
231+ return c.Describe(err)
232+ }
233+ all = append(all, res.Msg.GetReleases()...)
234+ next := res.Msg.GetNextPageToken()
235+ if next == "" {
236+ break
237+ }
238+ if next == page {
239+ return errors.New("the server repeated a release page token")
240+ }
241+ page = next
242+ }
243+ if int32(len(all)) > limit {
244+ all = all[:limit]
245+ }
246+ if *asJSON {
247+ out := make([]jsonRelease, 0, len(all))
248+ for _, r := range all {
249+ out = append(out, toJSONRelease(r))
250+ }
251+ return writeJSON(cmd.OutOrStdout(), out)
252+ }
253+ rows := [][]string{}
254+ for _, r := range all {
255+ rows = append(rows, []string{strconv.FormatInt(r.GetId(), 10), r.GetTag(), r.GetTitle(), releaseState(r), shortTime(r.GetPublishedAt(), "-"), strconv.Itoa(len(r.GetAssets()))})
256+ }
257+ return table(cmd.OutOrStdout(), []string{"ID", "TAG", "TITLE", "STATE", "PUBLISHED", "ASSETS"}, rows)
258+ }
259+ return cmd
260+}
261+func newReleaseViewCmd(env *cliEnv) *cobra.Command {
262+ var notes bool
263+ cmd := releaseCommand(env, "view <repo> <tag|id|latest>", "View a release and its assets", 2)
264+ cmd.Long = "View release fields, Markdown notes and assets. --notes prints only Markdown." + releaseJSONHelp
265+ asJSON := addJSONFlag(cmd)
266+ cmd.Flags().BoolVar(&notes, "notes", false, "print only Markdown notes")
267+ cmd.MarkFlagsMutuallyExclusive("json", "notes")
268+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
269+ c, err := releaseClient(env, cmd)
270+ if err != nil {
271+ return err
272+ }
273+ r, err := getRelease(env.ctx(cmd), c, args[0], args[1], true)
274+ if err != nil {
275+ return err
276+ }
277+ if *asJSON {
278+ return writeJSON(cmd.OutOrStdout(), toJSONRelease(r))
279+ }
280+ if notes {
281+ _, err := fmt.Fprint(cmd.OutOrStdout(), r.GetNotes())
282+ return err
283+ }
284+ if err := fields(cmd.OutOrStdout(), [][2]string{{"id", strconv.FormatInt(r.GetId(), 10)}, {"tag", r.GetTag()}, {"title", r.GetTitle()}, {"state", releaseState(r)}, {"latest", strconv.FormatBool(r.GetLatest())}, {"target", r.GetTargetCommitish()}, {"author", r.GetAuthor()}, {"created", shortTime(r.GetCreatedAt(), "-")}, {"published", shortTime(r.GetPublishedAt(), "-")}, {"updated", shortTime(r.GetUpdatedAt(), "-")}}); err != nil {
285+ return err
286+ }
287+ if _, err := fmt.Fprintln(cmd.OutOrStdout(), clean(r.GetNotes())); err != nil {
288+ return err
289+ }
290+ rows := [][]string{}
291+ for _, a := range r.GetAssets() {
292+ rows = append(rows, []string{a.GetName(), humanBytes(a.GetSize()), a.GetContentType()})
293+ }
294+ return table(cmd.OutOrStdout(), []string{"NAME", "SIZE", "TYPE"}, rows)
295+ }
296+ return cmd
297+}
298+func newReleaseEditCmd(env *cliEnv) *cobra.Command {
299+ var f releaseFields
300+ cmd := releaseCommand(env, "edit <repo> <tag|id>", "Edit a release", 2)
301+ f.flags(cmd, true)
302+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
303+ if err := f.readNotes(cmd); err != nil {
304+ return err
305+ }
306+ paths := []string{}
307+ for _, p := range [][2]string{{"title", "title"}, {"notes", "notes"}, {"notes-file", "notes"}, {"tag", "tag"}, {"target", "target_commitish"}, {"draft", "draft"}, {"publish", "draft"}, {"prerelease", "prerelease"}, {"no-prerelease", "prerelease"}, {"latest", "make_latest"}} {
308+ if cmd.Flags().Changed(p[0]) {
309+ paths = append(paths, p[1])
310+ }
311+ }
312+ if len(paths) == 0 {
313+ return errors.New("give at least one field to edit")
314+ }
315+ c, err := releaseClient(env, cmd)
316+ if err != nil {
317+ return err
318+ }
319+ ctx := env.ctx(cmd)
320+ r, err := getRelease(ctx, c, args[0], args[1], false)
321+ if err != nil {
322+ return err
323+ }
324+ res, err := c.Releases().UpdateRelease(ctx, connect.NewRequest(&ermite.UpdateReleaseRequest{Repo: args[0], Id: r.GetId(), Tag: f.tag, Title: f.title, Notes: f.notes, TargetCommitish: f.target, Draft: f.draft && !f.publish, Prerelease: f.prerelease && !f.noPrerelease, MakeLatest: f.latest, UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}}))
325+ if err != nil {
326+ return c.Describe(err)
327+ }
328+ return outputRelease(cmd, c, res.Msg.GetRelease(), false)
329+ }
330+ return cmd
331+}
332+func newReleaseDeleteCmd(env *cliEnv) *cobra.Command {
333+ var yes bool
334+ cmd := releaseCommand(env, "delete <repo> <tag|id>", "Delete a release and its assets (keeps the tag)", 2)
335+ cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip confirmation")
336+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
337+ c, err := releaseClient(env, cmd)
338+ if err != nil {
339+ return err
340+ }
341+ ctx := env.ctx(cmd)
342+ r, err := getRelease(ctx, c, args[0], args[1], false)
343+ if err != nil {
344+ return err
345+ }
346+ if in, ok := cmd.InOrStdin().(*os.File); !yes && ok && term.IsTerminal(int(in.Fd())) {
347+ _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Delete release %q and all its assets? [y/N] ", clean(r.GetTag()))
348+ answer, err := bufio.NewReader(in).ReadString('\n')
349+ if err != nil || !strings.EqualFold(strings.TrimSpace(answer), "y") {
350+ return errors.New("aborted")
351+ }
352+ }
353+ _, err = c.Releases().DeleteRelease(ctx, connect.NewRequest(&ermite.DeleteReleaseRequest{Repo: args[0], Id: r.GetId()}))
354+ if err != nil {
355+ return c.Describe(err)
356+ }
357+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted release %d\n", r.GetId())
358+ return err
359+ }
360+ return cmd
361+}
362+func newReleaseUploadCmd(env *cliEnv) *cobra.Command {
363+ var clobber bool
364+ var contentType string
365+ cmd := releaseCommand(env, "upload <repo> <tag|id> FILE[#LABEL]...", "Upload release assets", 3)
366+ cmd.Args = cobra.MinimumNArgs(3)
367+ cmd.Flags().BoolVar(&clobber, "clobber", false, "replace assets with the same name")
368+ cmd.Flags().StringVar(&contentType, "content-type", "", "override detected media type")
369+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
370+ c, err := releaseClient(env, cmd)
371+ if err != nil {
372+ return err
373+ }
374+ ctx := env.ctx(cmd)
375+ r, err := getRelease(ctx, c, args[0], args[1], false)
376+ if err != nil {
377+ return err
378+ }
379+ for _, spec := range args[2:] {
380+ a, err := uploadReleaseFile(ctx, c, args[0], r.GetId(), spec, contentType, clobber)
381+ if err != nil {
382+ return err
383+ }
384+ noticef(cmd, "uploaded %s (%s)", clean(a.GetName()), humanBytes(a.GetSize()))
385+ }
386+ return nil
387+ }
388+ return cmd
389+}
390+func uploadReleaseFile(ctx context.Context, c *client.Client, repo string, id int64, spec, contentType string, replace bool) (*ermite.ReleaseAsset, error) {
391+ filename, label, _ := strings.Cut(spec, "#")
392+ f, err := os.Open(filename)
393+ if err != nil {
394+ return nil, err
395+ }
396+ defer func() { _ = f.Close() }()
397+ info, err := f.Stat()
398+ if err != nil {
399+ return nil, err
400+ }
401+ if !info.Mode().IsRegular() {
402+ return nil, fmt.Errorf("%q is not a regular file", filename)
403+ }
404+ if contentType == "" {
405+ contentType = mime.TypeByExtension(filepath.Ext(filename))
406+ if contentType == "" {
407+ buf := make([]byte, 512)
408+ n, err := f.Read(buf)
409+ if err != nil && !errors.Is(err, io.EOF) {
410+ return nil, err
411+ }
412+ contentType = http.DetectContentType(buf[:n])
413+ if _, err := f.Seek(0, io.SeekStart); err != nil {
414+ return nil, err
415+ }
416+ }
417+ }
418+ if _, _, err := mime.ParseMediaType(contentType); err != nil {
419+ return nil, fmt.Errorf("invalid --content-type: %w", err)
420+ }
421+ name := filepath.Base(filename)
422+ stream := c.Releases().UploadReleaseAsset(ctx)
423+ // Always close the stream, including a local read or Send failure.
424+ defer func() {
425+ if conn, err := stream.Conn(); err == nil {
426+ _ = conn.CloseRequest()
427+ _ = conn.CloseResponse()
428+ }
429+ }()
430+ err = stream.Send(&ermite.UploadReleaseAssetRequest{Repo: repo, ReleaseId: id, Name: name, Label: label, ContentType: contentType, Size: info.Size(), Replace: replace})
431+ buf := make([]byte, 1<<20)
432+ for err == nil {
433+ n, rerr := f.Read(buf)
434+ if n > 0 {
435+ err = stream.Send(&ermite.UploadReleaseAssetRequest{Data: buf[:n]})
436+ }
437+ if rerr != nil {
438+ if !errors.Is(rerr, io.EOF) {
439+ return nil, rerr
440+ }
441+ break
442+ }
443+ }
444+ // CloseAndReceive exposes a server error even when Send returned EOF.
445+ res, closeErr := stream.CloseAndReceive()
446+ if closeErr != nil {
447+ err = closeErr
448+ }
449+ if err != nil {
450+ if connect.CodeOf(err) == connect.CodeAlreadyExists && !replace {
451+ return nil, fmt.Errorf("asset %q already exists; use --clobber to replace it", clean(name))
452+ }
453+ return nil, c.Describe(err)
454+ }
455+ return res.Msg.GetAsset(), nil
456+}
457+func newReleaseDownloadCmd(env *cliEnv) *cobra.Command {
458+ var names, patterns []string
459+ var dir string
460+ cmd := releaseCommand(env, "download <repo> <tag|id|latest>", "Download assets without overwriting existing files", 2)
461+ cmd.Flags().StringArrayVar(&names, "name", nil, "asset name (repeatable)")
462+ cmd.Flags().StringArrayVar(&patterns, "pattern", nil, "asset name glob (repeatable)")
463+ cmd.Flags().StringVarP(&dir, "dir", "D", ".", "download directory")
464+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
465+ for _, p := range patterns {
466+ if _, err := filepath.Match(p, ""); err != nil {
467+ return fmt.Errorf("--pattern: %w", err)
468+ }
469+ }
470+ c, err := releaseClient(env, cmd)
471+ if err != nil {
472+ return err
473+ }
474+ ctx := env.ctx(cmd)
475+ r, err := getRelease(ctx, c, args[0], args[1], true)
476+ if err != nil {
477+ return err
478+ }
479+ picked, err := pickReleaseAssets(r.GetAssets(), names, patterns)
480+ if err != nil {
481+ return err
482+ }
483+ if len(picked) == 0 {
484+ return errors.New("no matching release assets")
485+ }
486+ for _, a := range picked {
487+ if err := checkArtifactName(a.GetName()); err != nil {
488+ return err
489+ }
490+ }
491+ if err := os.MkdirAll(dir, 0o755); err != nil {
492+ return err
493+ }
494+ root, err := os.OpenRoot(dir)
495+ if err != nil {
496+ return err
497+ }
498+ defer func() { _ = root.Close() }()
499+ for _, a := range picked {
500+ if err := downloadReleaseAsset(ctx, c, args[0], a, root); err != nil {
501+ return fmt.Errorf("asset %q: %w", clean(a.GetName()), err)
502+ }
503+ noticef(cmd, "downloaded %s to %s", clean(a.GetName()), clean(filepath.Join(dir, a.GetName())))
504+ }
505+ return nil
506+ }
507+ return cmd
508+}
509+func pickReleaseAssets(all []*ermite.ReleaseAsset, names, patterns []string) ([]*ermite.ReleaseAsset, error) {
510+ for _, name := range names {
511+ if !slices.ContainsFunc(all, func(a *ermite.ReleaseAsset) bool { return a.GetName() == name }) {
512+ return nil, fmt.Errorf("the release has no asset %q", clean(name))
513+ }
514+ }
515+ out := []*ermite.ReleaseAsset{}
516+ for _, a := range all {
517+ match := len(names) == 0 && len(patterns) == 0 || slices.Contains(names, a.GetName())
518+ for _, p := range patterns {
519+ ok, err := filepath.Match(p, a.GetName())
520+ if err != nil {
521+ return nil, err
522+ }
523+ match = match || ok
524+ }
525+ if match {
526+ out = append(out, a)
527+ }
528+ }
529+ return out, nil
530+}
531+
532+// downloadReleaseAsset owns the destination exclusively; errors remove it. Only
533+// transport failures resume, once, using the immutable asset id and kept offset.
534+func downloadReleaseAsset(ctx context.Context, c *client.Client, repo string, a *ermite.ReleaseAsset, root *os.Root) (err error) {
535+ f, err := root.OpenFile(a.GetName(), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
536+ if err != nil {
537+ return err
538+ }
539+ defer func() {
540+ cerr := f.Close()
541+ if err == nil {
542+ err = cerr
543+ }
544+ if err != nil {
545+ _ = root.Remove(a.GetName())
546+ }
547+ }()
548+ hash := sha256.New()
549+ w := io.MultiWriter(f, hash)
550+ var kept int64
551+ for attempt := 0; attempt < 2; attempt++ {
552+ stream, openErr := c.Releases().DownloadReleaseAsset(ctx, connect.NewRequest(&ermite.DownloadReleaseAssetRequest{Repo: repo, Selector: &ermite.DownloadReleaseAssetRequest_AssetId{AssetId: a.GetId()}, Offset: kept}))
553+ if openErr != nil {
554+ err = openErr
555+ } else {
556+ described := false
557+ for stream.Receive() {
558+ msg := stream.Msg()
559+ if !described {
560+ meta := msg.GetAsset()
561+ if meta == nil || meta.GetId() != a.GetId() || meta.GetSize() != a.GetSize() || meta.GetDigest() != a.GetDigest() {
562+ _ = stream.Close()
563+ return errors.New("the server did not describe the expected asset")
564+ }
565+ described = true
566+ }
567+ chunk := msg.GetChunk()
568+ if kept+int64(len(chunk)) > a.GetSize() {
569+ _ = stream.Close()
570+ return errors.New("the server sent more than the asset size")
571+ }
572+ n, werr := w.Write(chunk)
573+ kept += int64(n)
574+ if werr != nil {
575+ _ = stream.Close()
576+ return werr
577+ }
578+ }
579+ err = stream.Err()
580+ _ = stream.Close()
581+ if err == nil && (!described || kept != a.GetSize()) {
582+ err = io.ErrUnexpectedEOF
583+ }
584+ }
585+ if err == nil {
586+ break
587+ }
588+ if ctx.Err() != nil {
589+ return ctx.Err()
590+ }
591+ code := connect.CodeOf(err)
592+ if attempt == 1 || (code != connect.CodeUnavailable && code != connect.CodeInternal && !errors.Is(err, io.ErrUnexpectedEOF)) {
593+ return c.Describe(err)
594+ }
595+ }
596+ if err != nil {
597+ return c.Describe(err)
598+ }
599+ if a.GetDigest() != "sha256:"+hex.EncodeToString(hash.Sum(nil)) {
600+ return errors.New("asset digest mismatch")
601+ }
602+ return f.Sync()
603+}
@@ -0,0 +1,603 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "bufio"
7+ "context"
8+ "crypto/sha256"
9+ "encoding/hex"
10+ "errors"
11+ "fmt"
12+ "io"
13+ "mime"
14+ "net/http"
15+ "net/url"
16+ "os"
17+ "path/filepath"
18+ "slices"
19+ "strconv"
20+ "strings"
21+
22+ "connectrpc.com/connect"
23+ "github.com/spf13/cobra"
24+ "golang.org/x/term"
25+ "google.golang.org/protobuf/types/known/fieldmaskpb"
26+
27+ "bnema.dev/ermite-api/gen/ermite"
28+ "bnema.dev/ermite/internal/adapters/client"
29+)
30+
31+var errNoReleases = errors.New("the server has no releases (it lacks the releases feature)")
32+
33+func requireReleases(ctx context.Context, c *client.Client) error {
34+ res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
35+ if err != nil {
36+ return c.Describe(err)
37+ }
38+ if !slices.Contains(res.Msg.GetFeatures(), client.FeatureReleases) {
39+ return errNoReleases
40+ }
41+ return nil
42+}
43+
44+func releaseClient(env *cliEnv, cmd *cobra.Command) (*client.Client, error) {
45+ c, err := env.client(cmd)
46+ if err != nil {
47+ return nil, err
48+ }
49+ if err := requireReleases(env.ctx(cmd), c); err != nil {
50+ return nil, err
51+ }
52+ return c, nil
53+}
54+
55+// jsonRelease and jsonReleaseAsset keep the CLI shape independent of protobuf JSON.
56+type jsonReleaseAsset struct {
57+ ID int64 `json:"id"`
58+ ReleaseID int64 `json:"release_id"`
59+ Name string `json:"name"`
60+ Label string `json:"label"`
61+ ContentType string `json:"content_type"`
62+ Size int64 `json:"size"`
63+ Digest string `json:"digest"`
64+ Uploader string `json:"uploader"`
65+ CreatedAt *string `json:"created_at"`
66+ UpdatedAt *string `json:"updated_at"`
67+}
68+type jsonRelease struct {
69+ ID int64 `json:"id"`
70+ Repo string `json:"repo"`
71+ Tag string `json:"tag"`
72+ Title string `json:"title"`
73+ Notes string `json:"notes"`
74+ Draft bool `json:"draft"`
75+ Prerelease bool `json:"prerelease"`
76+ Latest bool `json:"latest"`
77+ TargetCommitish string `json:"target_commitish"`
78+ Author string `json:"author"`
79+ CreatedAt *string `json:"created_at"`
80+ PublishedAt *string `json:"published_at"`
81+ UpdatedAt *string `json:"updated_at"`
82+ Assets []jsonReleaseAsset `json:"assets"`
83+}
84+
85+func toJSONRelease(r *ermite.Release) jsonRelease {
86+ out := jsonRelease{ID: r.GetId(), Repo: r.GetRepo(), Tag: r.GetTag(), Title: r.GetTitle(), Notes: r.GetNotes(), Draft: r.GetDraft(), Prerelease: r.GetPrerelease(), Latest: r.GetLatest(), TargetCommitish: r.GetTargetCommitish(), Author: r.GetAuthor(), CreatedAt: jsonTime(r.GetCreatedAt()), PublishedAt: jsonTime(r.GetPublishedAt()), UpdatedAt: jsonTime(r.GetUpdatedAt()), Assets: []jsonReleaseAsset{}}
87+ for _, a := range r.GetAssets() {
88+ out.Assets = append(out.Assets, jsonReleaseAsset{a.GetId(), a.GetReleaseId(), a.GetName(), a.GetLabel(), a.GetContentType(), a.GetSize(), a.GetDigest(), a.GetUploader(), jsonTime(a.GetCreatedAt()), jsonTime(a.GetUpdatedAt())})
89+ }
90+ return out
91+}
92+
93+const releaseJSONHelp = "\nJSON fields: id, repo, tag, title, notes, draft, prerelease, latest, target_commitish, author,\ncreated_at, published_at, updated_at (RFC 3339 UTC or null), assets (id, release_id, name, label,\ncontent_type, size, digest, uploader, created_at, updated_at). list prints an array."
94+
95+func newReleaseCmd(env *cliEnv) *cobra.Command {
96+ cmd := &cobra.Command{Use: "release", Short: "Manage releases and their assets", Long: "Create and manage tagged releases. Reads follow repository visibility; writes need release:write.\nNeeds a server with the releases feature. Select releases by tag, numeric id, or latest (view/download)."}
97+ cmd.AddCommand(newReleaseCreateCmd(env), newReleaseListCmd(env), newReleaseViewCmd(env), newReleaseEditCmd(env), newReleaseDeleteCmd(env), newReleaseUploadCmd(env), newReleaseDownloadCmd(env))
98+ return cmd
99+}
100+func releaseCommand(env *cliEnv, use, short string, n int) *cobra.Command {
101+ return &cobra.Command{Use: use, Short: short, Args: cobra.ExactArgs(n), ValidArgsFunction: completeRepoNames(env)}
102+}
103+
104+func getRelease(ctx context.Context, c *client.Client, repo, selector string, latest bool) (*ermite.Release, error) {
105+ req := &ermite.GetReleaseRequest{Repo: repo}
106+ if selector == "latest" && latest {
107+ req.Selector = &ermite.GetReleaseRequest_Latest{Latest: true}
108+ } else if selector != "" && strings.Trim(selector, "0123456789") == "" {
109+ id, err := strconv.ParseInt(selector, 10, 64)
110+ if err != nil || id <= 0 {
111+ return nil, fmt.Errorf("invalid release id %q", selector)
112+ }
113+ req.Selector = &ermite.GetReleaseRequest_Id{Id: id}
114+ } else {
115+ req.Selector = &ermite.GetReleaseRequest_Tag{Tag: selector}
116+ }
117+ res, err := c.Releases().GetRelease(ctx, connect.NewRequest(req))
118+ if err != nil {
119+ return nil, c.Describe(err)
120+ }
121+ return res.Msg.GetRelease(), nil
122+}
123+func releaseState(r *ermite.Release) string {
124+ if r.GetDraft() {
125+ return "draft"
126+ }
127+ if r.GetPrerelease() {
128+ return "prerelease"
129+ }
130+ return "published"
131+}
132+func releaseURL(base, repo, tag string) string {
133+ return strings.TrimRight(base, "/") + "/" + url.PathEscape(repo) + "/releases/tag/" + url.PathEscape(tag)
134+}
135+func outputRelease(cmd *cobra.Command, c *client.Client, r *ermite.Release, asJSON bool) error {
136+ if asJSON {
137+ return writeJSON(cmd.OutOrStdout(), toJSONRelease(r))
138+ }
139+ _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\nid: %d\n", releaseURL(c.URL(), r.GetRepo(), r.GetTag()), r.GetId())
140+ return err
141+}
142+
143+type releaseFields struct {
144+ title, notes, notesFile, target, tag string
145+ draft, publish, prerelease, noPrerelease, latest bool
146+}
147+
148+func (f *releaseFields) flags(cmd *cobra.Command, edit bool) {
149+ cmd.Flags().StringVar(&f.title, "title", "", "release title")
150+ cmd.Flags().StringVar(&f.notes, "notes", "", "Markdown release notes")
151+ cmd.Flags().StringVar(&f.notesFile, "notes-file", "", "read Markdown release notes from a file (- for stdin)")
152+ cmd.MarkFlagsMutuallyExclusive("notes", "notes-file")
153+ cmd.Flags().StringVar(&f.target, "target", "", "target commit or branch for a missing tag")
154+ cmd.Flags().BoolVar(&f.draft, "draft", false, "keep as a draft")
155+ cmd.Flags().BoolVar(&f.prerelease, "prerelease", false, "mark as a prerelease")
156+ cmd.Flags().BoolVar(&f.latest, "latest", false, "pin as latest")
157+ if edit {
158+ cmd.Flags().StringVar(&f.tag, "tag", "", "new tag")
159+ cmd.Flags().BoolVar(&f.publish, "publish", false, "publish a draft")
160+ cmd.Flags().BoolVar(&f.noPrerelease, "no-prerelease", false, "clear the prerelease flag")
161+ cmd.MarkFlagsMutuallyExclusive("draft", "publish")
162+ cmd.MarkFlagsMutuallyExclusive("prerelease", "no-prerelease")
163+ }
164+}
165+func (f *releaseFields) readNotes(cmd *cobra.Command) error {
166+ if !cmd.Flags().Changed("notes-file") {
167+ return nil
168+ }
169+ var data []byte
170+ var err error
171+ if f.notesFile == "-" {
172+ data, err = io.ReadAll(cmd.InOrStdin())
173+ } else {
174+ data, err = os.ReadFile(f.notesFile)
175+ }
176+ f.notes = string(data)
177+ return err
178+}
179+func newReleaseCreateCmd(env *cliEnv) *cobra.Command {
180+ var f releaseFields
181+ var assets []string
182+ cmd := releaseCommand(env, "create <repo> <tag>", "Create a release", 2)
183+ cmd.Long = "Create a release, then upload each --asset FILE[#LABEL] (repeatable; shell expands globs)." + releaseJSONHelp
184+ f.flags(cmd, false)
185+ cmd.Flags().StringArrayVar(&assets, "asset", nil, "upload FILE[#LABEL] (repeatable)")
186+ asJSON := addJSONFlag(cmd)
187+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
188+ if err := f.readNotes(cmd); err != nil {
189+ return err
190+ }
191+ c, err := releaseClient(env, cmd)
192+ if err != nil {
193+ return err
194+ }
195+ ctx := env.ctx(cmd)
196+ res, err := c.Releases().CreateRelease(ctx, connect.NewRequest(&ermite.CreateReleaseRequest{Repo: args[0], Tag: args[1], Title: f.title, Notes: f.notes, TargetCommitish: f.target, Draft: f.draft, Prerelease: f.prerelease, MakeLatest: f.latest}))
197+ if err != nil {
198+ return c.Describe(err)
199+ }
200+ r := res.Msg.GetRelease()
201+ for _, spec := range assets {
202+ a, err := uploadReleaseFile(ctx, c, args[0], r.GetId(), spec, "", false)
203+ if err != nil {
204+ return fmt.Errorf("release %d was created, but asset upload failed: %w", r.GetId(), err)
205+ }
206+ r.Assets = append(r.Assets, a)
207+ }
208+ return outputRelease(cmd, c, r, *asJSON)
209+ }
210+ return cmd
211+}
212+func newReleaseListCmd(env *cliEnv) *cobra.Command {
213+ var limit int32
214+ cmd := releaseCommand(env, "list <repo>", "List releases, newest first", 1)
215+ cmd.Long = "List releases, including drafts visible to writers." + releaseJSONHelp
216+ cmd.Flags().Int32Var(&limit, "limit", 30, "maximum number of releases")
217+ asJSON := addJSONFlag(cmd)
218+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
219+ if limit <= 0 {
220+ return errors.New("--limit must be positive")
221+ }
222+ c, err := releaseClient(env, cmd)
223+ if err != nil {
224+ return err
225+ }
226+ all := []*ermite.Release{}
227+ page := ""
228+ for int32(len(all)) < limit {
229+ res, err := c.Releases().ListReleases(env.ctx(cmd), connect.NewRequest(&ermite.ListReleasesRequest{Repo: args[0], PageSize: limit - int32(len(all)), PageToken: page}))
230+ if err != nil {
231+ return c.Describe(err)
232+ }
233+ all = append(all, res.Msg.GetReleases()...)
234+ next := res.Msg.GetNextPageToken()
235+ if next == "" {
236+ break
237+ }
238+ if next == page {
239+ return errors.New("the server repeated a release page token")
240+ }
241+ page = next
242+ }
243+ if int32(len(all)) > limit {
244+ all = all[:limit]
245+ }
246+ if *asJSON {
247+ out := make([]jsonRelease, 0, len(all))
248+ for _, r := range all {
249+ out = append(out, toJSONRelease(r))
250+ }
251+ return writeJSON(cmd.OutOrStdout(), out)
252+ }
253+ rows := [][]string{}
254+ for _, r := range all {
255+ rows = append(rows, []string{strconv.FormatInt(r.GetId(), 10), r.GetTag(), r.GetTitle(), releaseState(r), shortTime(r.GetPublishedAt(), "-"), strconv.Itoa(len(r.GetAssets()))})
256+ }
257+ return table(cmd.OutOrStdout(), []string{"ID", "TAG", "TITLE", "STATE", "PUBLISHED", "ASSETS"}, rows)
258+ }
259+ return cmd
260+}
261+func newReleaseViewCmd(env *cliEnv) *cobra.Command {
262+ var notes bool
263+ cmd := releaseCommand(env, "view <repo> <tag|id|latest>", "View a release and its assets", 2)
264+ cmd.Long = "View release fields, Markdown notes and assets. --notes prints only Markdown." + releaseJSONHelp
265+ asJSON := addJSONFlag(cmd)
266+ cmd.Flags().BoolVar(&notes, "notes", false, "print only Markdown notes")
267+ cmd.MarkFlagsMutuallyExclusive("json", "notes")
268+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
269+ c, err := releaseClient(env, cmd)
270+ if err != nil {
271+ return err
272+ }
273+ r, err := getRelease(env.ctx(cmd), c, args[0], args[1], true)
274+ if err != nil {
275+ return err
276+ }
277+ if *asJSON {
278+ return writeJSON(cmd.OutOrStdout(), toJSONRelease(r))
279+ }
280+ if notes {
281+ _, err := fmt.Fprint(cmd.OutOrStdout(), r.GetNotes())
282+ return err
283+ }
284+ if err := fields(cmd.OutOrStdout(), [][2]string{{"id", strconv.FormatInt(r.GetId(), 10)}, {"tag", r.GetTag()}, {"title", r.GetTitle()}, {"state", releaseState(r)}, {"latest", strconv.FormatBool(r.GetLatest())}, {"target", r.GetTargetCommitish()}, {"author", r.GetAuthor()}, {"created", shortTime(r.GetCreatedAt(), "-")}, {"published", shortTime(r.GetPublishedAt(), "-")}, {"updated", shortTime(r.GetUpdatedAt(), "-")}}); err != nil {
285+ return err
286+ }
287+ if _, err := fmt.Fprintln(cmd.OutOrStdout(), clean(r.GetNotes())); err != nil {
288+ return err
289+ }
290+ rows := [][]string{}
291+ for _, a := range r.GetAssets() {
292+ rows = append(rows, []string{a.GetName(), humanBytes(a.GetSize()), a.GetContentType()})
293+ }
294+ return table(cmd.OutOrStdout(), []string{"NAME", "SIZE", "TYPE"}, rows)
295+ }
296+ return cmd
297+}
298+func newReleaseEditCmd(env *cliEnv) *cobra.Command {
299+ var f releaseFields
300+ cmd := releaseCommand(env, "edit <repo> <tag|id>", "Edit a release", 2)
301+ f.flags(cmd, true)
302+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
303+ if err := f.readNotes(cmd); err != nil {
304+ return err
305+ }
306+ paths := []string{}
307+ for _, p := range [][2]string{{"title", "title"}, {"notes", "notes"}, {"notes-file", "notes"}, {"tag", "tag"}, {"target", "target_commitish"}, {"draft", "draft"}, {"publish", "draft"}, {"prerelease", "prerelease"}, {"no-prerelease", "prerelease"}, {"latest", "make_latest"}} {
308+ if cmd.Flags().Changed(p[0]) {
309+ paths = append(paths, p[1])
310+ }
311+ }
312+ if len(paths) == 0 {
313+ return errors.New("give at least one field to edit")
314+ }
315+ c, err := releaseClient(env, cmd)
316+ if err != nil {
317+ return err
318+ }
319+ ctx := env.ctx(cmd)
320+ r, err := getRelease(ctx, c, args[0], args[1], false)
321+ if err != nil {
322+ return err
323+ }
324+ res, err := c.Releases().UpdateRelease(ctx, connect.NewRequest(&ermite.UpdateReleaseRequest{Repo: args[0], Id: r.GetId(), Tag: f.tag, Title: f.title, Notes: f.notes, TargetCommitish: f.target, Draft: f.draft && !f.publish, Prerelease: f.prerelease && !f.noPrerelease, MakeLatest: f.latest, UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}}))
325+ if err != nil {
326+ return c.Describe(err)
327+ }
328+ return outputRelease(cmd, c, res.Msg.GetRelease(), false)
329+ }
330+ return cmd
331+}
332+func newReleaseDeleteCmd(env *cliEnv) *cobra.Command {
333+ var yes bool
334+ cmd := releaseCommand(env, "delete <repo> <tag|id>", "Delete a release and its assets (keeps the tag)", 2)
335+ cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip confirmation")
336+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
337+ c, err := releaseClient(env, cmd)
338+ if err != nil {
339+ return err
340+ }
341+ ctx := env.ctx(cmd)
342+ r, err := getRelease(ctx, c, args[0], args[1], false)
343+ if err != nil {
344+ return err
345+ }
346+ if in, ok := cmd.InOrStdin().(*os.File); !yes && ok && term.IsTerminal(int(in.Fd())) {
347+ _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Delete release %q and all its assets? [y/N] ", clean(r.GetTag()))
348+ answer, err := bufio.NewReader(in).ReadString('\n')
349+ if err != nil || !strings.EqualFold(strings.TrimSpace(answer), "y") {
350+ return errors.New("aborted")
351+ }
352+ }
353+ _, err = c.Releases().DeleteRelease(ctx, connect.NewRequest(&ermite.DeleteReleaseRequest{Repo: args[0], Id: r.GetId()}))
354+ if err != nil {
355+ return c.Describe(err)
356+ }
357+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted release %d\n", r.GetId())
358+ return err
359+ }
360+ return cmd
361+}
362+func newReleaseUploadCmd(env *cliEnv) *cobra.Command {
363+ var clobber bool
364+ var contentType string
365+ cmd := releaseCommand(env, "upload <repo> <tag|id> FILE[#LABEL]...", "Upload release assets", 3)
366+ cmd.Args = cobra.MinimumNArgs(3)
367+ cmd.Flags().BoolVar(&clobber, "clobber", false, "replace assets with the same name")
368+ cmd.Flags().StringVar(&contentType, "content-type", "", "override detected media type")
369+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
370+ c, err := releaseClient(env, cmd)
371+ if err != nil {
372+ return err
373+ }
374+ ctx := env.ctx(cmd)
375+ r, err := getRelease(ctx, c, args[0], args[1], false)
376+ if err != nil {
377+ return err
378+ }
379+ for _, spec := range args[2:] {
380+ a, err := uploadReleaseFile(ctx, c, args[0], r.GetId(), spec, contentType, clobber)
381+ if err != nil {
382+ return err
383+ }
384+ noticef(cmd, "uploaded %s (%s)", clean(a.GetName()), humanBytes(a.GetSize()))
385+ }
386+ return nil
387+ }
388+ return cmd
389+}
390+func uploadReleaseFile(ctx context.Context, c *client.Client, repo string, id int64, spec, contentType string, replace bool) (*ermite.ReleaseAsset, error) {
391+ filename, label, _ := strings.Cut(spec, "#")
392+ f, err := os.Open(filename)
393+ if err != nil {
394+ return nil, err
395+ }
396+ defer func() { _ = f.Close() }()
397+ info, err := f.Stat()
398+ if err != nil {
399+ return nil, err
400+ }
401+ if !info.Mode().IsRegular() {
402+ return nil, fmt.Errorf("%q is not a regular file", filename)
403+ }
404+ if contentType == "" {
405+ contentType = mime.TypeByExtension(filepath.Ext(filename))
406+ if contentType == "" {
407+ buf := make([]byte, 512)
408+ n, err := f.Read(buf)
409+ if err != nil && !errors.Is(err, io.EOF) {
410+ return nil, err
411+ }
412+ contentType = http.DetectContentType(buf[:n])
413+ if _, err := f.Seek(0, io.SeekStart); err != nil {
414+ return nil, err
415+ }
416+ }
417+ }
418+ if _, _, err := mime.ParseMediaType(contentType); err != nil {
419+ return nil, fmt.Errorf("invalid --content-type: %w", err)
420+ }
421+ name := filepath.Base(filename)
422+ stream := c.Releases().UploadReleaseAsset(ctx)
423+ // Always close the stream, including a local read or Send failure.
424+ defer func() {
425+ if conn, err := stream.Conn(); err == nil {
426+ _ = conn.CloseRequest()
427+ _ = conn.CloseResponse()
428+ }
429+ }()
430+ err = stream.Send(&ermite.UploadReleaseAssetRequest{Repo: repo, ReleaseId: id, Name: name, Label: label, ContentType: contentType, Size: info.Size(), Replace: replace})
431+ buf := make([]byte, 1<<20)
432+ for err == nil {
433+ n, rerr := f.Read(buf)
434+ if n > 0 {
435+ err = stream.Send(&ermite.UploadReleaseAssetRequest{Data: buf[:n]})
436+ }
437+ if rerr != nil {
438+ if !errors.Is(rerr, io.EOF) {
439+ return nil, rerr
440+ }
441+ break
442+ }
443+ }
444+ // CloseAndReceive exposes a server error even when Send returned EOF.
445+ res, closeErr := stream.CloseAndReceive()
446+ if closeErr != nil {
447+ err = closeErr
448+ }
449+ if err != nil {
450+ if connect.CodeOf(err) == connect.CodeAlreadyExists && !replace {
451+ return nil, fmt.Errorf("asset %q already exists; use --clobber to replace it", clean(name))
452+ }
453+ return nil, c.Describe(err)
454+ }
455+ return res.Msg.GetAsset(), nil
456+}
457+func newReleaseDownloadCmd(env *cliEnv) *cobra.Command {
458+ var names, patterns []string
459+ var dir string
460+ cmd := releaseCommand(env, "download <repo> <tag|id|latest>", "Download assets without overwriting existing files", 2)
461+ cmd.Flags().StringArrayVar(&names, "name", nil, "asset name (repeatable)")
462+ cmd.Flags().StringArrayVar(&patterns, "pattern", nil, "asset name glob (repeatable)")
463+ cmd.Flags().StringVarP(&dir, "dir", "D", ".", "download directory")
464+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
465+ for _, p := range patterns {
466+ if _, err := filepath.Match(p, ""); err != nil {
467+ return fmt.Errorf("--pattern: %w", err)
468+ }
469+ }
470+ c, err := releaseClient(env, cmd)
471+ if err != nil {
472+ return err
473+ }
474+ ctx := env.ctx(cmd)
475+ r, err := getRelease(ctx, c, args[0], args[1], true)
476+ if err != nil {
477+ return err
478+ }
479+ picked, err := pickReleaseAssets(r.GetAssets(), names, patterns)
480+ if err != nil {
481+ return err
482+ }
483+ if len(picked) == 0 {
484+ return errors.New("no matching release assets")
485+ }
486+ for _, a := range picked {
487+ if err := checkArtifactName(a.GetName()); err != nil {
488+ return err
489+ }
490+ }
491+ if err := os.MkdirAll(dir, 0o755); err != nil {
492+ return err
493+ }
494+ root, err := os.OpenRoot(dir)
495+ if err != nil {
496+ return err
497+ }
498+ defer func() { _ = root.Close() }()
499+ for _, a := range picked {
500+ if err := downloadReleaseAsset(ctx, c, args[0], a, root); err != nil {
501+ return fmt.Errorf("asset %q: %w", clean(a.GetName()), err)
502+ }
503+ noticef(cmd, "downloaded %s to %s", clean(a.GetName()), clean(filepath.Join(dir, a.GetName())))
504+ }
505+ return nil
506+ }
507+ return cmd
508+}
509+func pickReleaseAssets(all []*ermite.ReleaseAsset, names, patterns []string) ([]*ermite.ReleaseAsset, error) {
510+ for _, name := range names {
511+ if !slices.ContainsFunc(all, func(a *ermite.ReleaseAsset) bool { return a.GetName() == name }) {
512+ return nil, fmt.Errorf("the release has no asset %q", clean(name))
513+ }
514+ }
515+ out := []*ermite.ReleaseAsset{}
516+ for _, a := range all {
517+ match := len(names) == 0 && len(patterns) == 0 || slices.Contains(names, a.GetName())
518+ for _, p := range patterns {
519+ ok, err := filepath.Match(p, a.GetName())
520+ if err != nil {
521+ return nil, err
522+ }
523+ match = match || ok
524+ }
525+ if match {
526+ out = append(out, a)
527+ }
528+ }
529+ return out, nil
530+}
531+
532+// downloadReleaseAsset owns the destination exclusively; errors remove it. Only
533+// transport failures resume, once, using the immutable asset id and kept offset.
534+func downloadReleaseAsset(ctx context.Context, c *client.Client, repo string, a *ermite.ReleaseAsset, root *os.Root) (err error) {
535+ f, err := root.OpenFile(a.GetName(), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
536+ if err != nil {
537+ return err
538+ }
539+ defer func() {
540+ cerr := f.Close()
541+ if err == nil {
542+ err = cerr
543+ }
544+ if err != nil {
545+ _ = root.Remove(a.GetName())
546+ }
547+ }()
548+ hash := sha256.New()
549+ w := io.MultiWriter(f, hash)
550+ var kept int64
551+ for attempt := 0; attempt < 2; attempt++ {
552+ stream, openErr := c.Releases().DownloadReleaseAsset(ctx, connect.NewRequest(&ermite.DownloadReleaseAssetRequest{Repo: repo, Selector: &ermite.DownloadReleaseAssetRequest_AssetId{AssetId: a.GetId()}, Offset: kept}))
553+ if openErr != nil {
554+ err = openErr
555+ } else {
556+ described := false
557+ for stream.Receive() {
558+ msg := stream.Msg()
559+ if !described {
560+ meta := msg.GetAsset()
561+ if meta == nil || meta.GetId() != a.GetId() || meta.GetSize() != a.GetSize() || meta.GetDigest() != a.GetDigest() {
562+ _ = stream.Close()
563+ return errors.New("the server did not describe the expected asset")
564+ }
565+ described = true
566+ }
567+ chunk := msg.GetChunk()
568+ if kept+int64(len(chunk)) > a.GetSize() {
569+ _ = stream.Close()
570+ return errors.New("the server sent more than the asset size")
571+ }
572+ n, werr := w.Write(chunk)
573+ kept += int64(n)
574+ if werr != nil {
575+ _ = stream.Close()
576+ return werr
577+ }
578+ }
579+ err = stream.Err()
580+ _ = stream.Close()
581+ if err == nil && (!described || kept != a.GetSize()) {
582+ err = io.ErrUnexpectedEOF
583+ }
584+ }
585+ if err == nil {
586+ break
587+ }
588+ if ctx.Err() != nil {
589+ return ctx.Err()
590+ }
591+ code := connect.CodeOf(err)
592+ if attempt == 1 || (code != connect.CodeUnavailable && code != connect.CodeInternal && !errors.Is(err, io.ErrUnexpectedEOF)) {
593+ return c.Describe(err)
594+ }
595+ }
596+ if err != nil {
597+ return c.Describe(err)
598+ }
599+ if a.GetDigest() != "sha256:"+hex.EncodeToString(hash.Sum(nil)) {
600+ return errors.New("asset digest mismatch")
601+ }
602+ return f.Sync()
603+}
A internal/app/cli_release_e2e_test.go +65 -0
@@ -0,0 +1,65 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "net/http"
7+ "net/http/httptest"
8+ "os"
9+ "path/filepath"
10+ "strconv"
11+ "testing"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/adapters/rpc"
17+)
18+
19+func TestReleaseCLIEndToEnd(t *testing.T) {
20+ c := newCLIHarness(t)
21+ c.cli(t, adminToken, "repo", "create", "proj", "--visibility", "public").ok(t)
22+ wt := c.workTree("main", "README.md")
23+ c.mustGit(wt, "tag", "v1")
24+ c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "main", "refs/tags/v1")
25+
26+ bin := func(args ...string) string {
27+ t.Helper()
28+ out, stderr, code := c.bin(t, adminToken, args...)
29+ require.Equal(t, 0, code, stderr)
30+ return out
31+ }
32+ asset := filepath.Join(t.TempDir(), "binary.dat")
33+ data := []byte("release payload\x00\xff")
34+ require.NoError(t, os.WriteFile(asset, data, 0o600))
35+ created := decode[jsonRelease](t, bin("release", "create", "proj", "v1", "--title", "Version one", "--notes", "# shipped", "--asset", asset+"#Binary", "--json"))
36+ require.Positive(t, created.ID)
37+ require.Len(t, created.Assets, 1)
38+ assert.Equal(t, "Binary", created.Assets[0].Label)
39+ viewed := decode[jsonRelease](t, bin("release", "view", "proj", "v1", "--json"))
40+ assert.Equal(t, created.ID, viewed.ID)
41+ assert.Equal(t, "# shipped", viewed.Notes)
42+ assert.Contains(t, bin("release", "list", "proj"), "Version one")
43+ bin("release", "edit", "proj", strconv.FormatInt(created.ID, 10), "--title", "Edited", "--no-prerelease")
44+ assert.Equal(t, "Edited", decode[jsonRelease](t, bin("release", "view", "proj", "latest", "--json")).Title)
45+ dir := t.TempDir()
46+ bin("release", "download", "proj", "latest", "--dir", dir, "--pattern", "*.dat")
47+ downloaded, err := os.ReadFile(filepath.Join(dir, "binary.dat"))
48+ require.NoError(t, err)
49+ assert.Equal(t, data, downloaded)
50+ _, stderr, code := c.bin(t, adminToken, "release", "upload", "proj", "v1", asset)
51+ requireOneLineError(t, stderr, code, "binary.dat", "--clobber")
52+ bin("release", "upload", "proj", "v1", asset, "--clobber")
53+ bin("release", "delete", "proj", "v1", "--yes")
54+ _, stderr, code = c.bin(t, adminToken, "release", "view", "proj", "v1")
55+ requireOneLineError(t, stderr, code, "not found")
56+ // An older server without the service still gets a clear refusal.
57+ api := rpc.New(rpc.Config{Auth: c.forge.Auth, SigningKey: c.forge.SigningKey, Sessions: c.forge.Sessions})
58+ mux := http.NewServeMux()
59+ mux.Handle("/-/api/", http.StripPrefix("/-/api", api.Handler()))
60+ srv := httptest.NewServer(mux)
61+ t.Cleanup(srv.Close)
62+ c.url = srv.URL
63+ _, stderr, code = c.bin(t, adminToken, "release", "list", "proj")
64+ requireOneLineError(t, stderr, code, "the server has no releases (it lacks the releases feature)")
65+}
@@ -0,0 +1,65 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "net/http"
7+ "net/http/httptest"
8+ "os"
9+ "path/filepath"
10+ "strconv"
11+ "testing"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/adapters/rpc"
17+)
18+
19+func TestReleaseCLIEndToEnd(t *testing.T) {
20+ c := newCLIHarness(t)
21+ c.cli(t, adminToken, "repo", "create", "proj", "--visibility", "public").ok(t)
22+ wt := c.workTree("main", "README.md")
23+ c.mustGit(wt, "tag", "v1")
24+ c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "main", "refs/tags/v1")
25+
26+ bin := func(args ...string) string {
27+ t.Helper()
28+ out, stderr, code := c.bin(t, adminToken, args...)
29+ require.Equal(t, 0, code, stderr)
30+ return out
31+ }
32+ asset := filepath.Join(t.TempDir(), "binary.dat")
33+ data := []byte("release payload\x00\xff")
34+ require.NoError(t, os.WriteFile(asset, data, 0o600))
35+ created := decode[jsonRelease](t, bin("release", "create", "proj", "v1", "--title", "Version one", "--notes", "# shipped", "--asset", asset+"#Binary", "--json"))
36+ require.Positive(t, created.ID)
37+ require.Len(t, created.Assets, 1)
38+ assert.Equal(t, "Binary", created.Assets[0].Label)
39+ viewed := decode[jsonRelease](t, bin("release", "view", "proj", "v1", "--json"))
40+ assert.Equal(t, created.ID, viewed.ID)
41+ assert.Equal(t, "# shipped", viewed.Notes)
42+ assert.Contains(t, bin("release", "list", "proj"), "Version one")
43+ bin("release", "edit", "proj", strconv.FormatInt(created.ID, 10), "--title", "Edited", "--no-prerelease")
44+ assert.Equal(t, "Edited", decode[jsonRelease](t, bin("release", "view", "proj", "latest", "--json")).Title)
45+ dir := t.TempDir()
46+ bin("release", "download", "proj", "latest", "--dir", dir, "--pattern", "*.dat")
47+ downloaded, err := os.ReadFile(filepath.Join(dir, "binary.dat"))
48+ require.NoError(t, err)
49+ assert.Equal(t, data, downloaded)
50+ _, stderr, code := c.bin(t, adminToken, "release", "upload", "proj", "v1", asset)
51+ requireOneLineError(t, stderr, code, "binary.dat", "--clobber")
52+ bin("release", "upload", "proj", "v1", asset, "--clobber")
53+ bin("release", "delete", "proj", "v1", "--yes")
54+ _, stderr, code = c.bin(t, adminToken, "release", "view", "proj", "v1")
55+ requireOneLineError(t, stderr, code, "not found")
56+ // An older server without the service still gets a clear refusal.
57+ api := rpc.New(rpc.Config{Auth: c.forge.Auth, SigningKey: c.forge.SigningKey, Sessions: c.forge.Sessions})
58+ mux := http.NewServeMux()
59+ mux.Handle("/-/api/", http.StripPrefix("/-/api", api.Handler()))
60+ srv := httptest.NewServer(mux)
61+ t.Cleanup(srv.Close)
62+ c.url = srv.URL
63+ _, stderr, code = c.bin(t, adminToken, "release", "list", "proj")
64+ requireOneLineError(t, stderr, code, "the server has no releases (it lacks the releases feature)")
65+}
A internal/app/cli_release_test.go +388 -0
@@ -0,0 +1,388 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "crypto/sha256"
8+ "fmt"
9+ "net/http"
10+ "net/http/httptest"
11+ "os"
12+ "path/filepath"
13+ "sync"
14+ "testing"
15+
16+ "connectrpc.com/connect"
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/require"
19+ "google.golang.org/protobuf/proto"
20+
21+ "bnema.dev/ermite-api/gen/ermite"
22+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
23+)
24+
25+// A focused version of cli_fake_test's Connect rig, without changing the shared fake.
26+type fakeReleaseAPI struct {
27+ ermiteconnect.UnimplementedReleaseServiceHandler
28+ mu sync.Mutex
29+ create *ermite.CreateReleaseRequest
30+ gets []*ermite.GetReleaseRequest
31+ lists []*ermite.ListReleasesRequest
32+ update *ermite.UpdateReleaseRequest
33+ deleted *ermite.DeleteReleaseRequest
34+ uploads []*ermite.UploadReleaseAssetRequest
35+ offsets []int64
36+ release *ermite.Release
37+ data []byte
38+ uploadErr error
39+ download func(*ermite.DownloadReleaseAssetRequest, *connect.ServerStream[ermite.DownloadReleaseAssetResponse]) error
40+}
41+
42+func (f *fakeReleaseAPI) serve(t *testing.T, features ...string) string {
43+ t.Helper()
44+ mux := http.NewServeMux()
45+ p, h := ermiteconnect.NewReleaseServiceHandler(f)
46+ mux.Handle("/-/api"+p, http.StripPrefix("/-/api", h))
47+ v := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
48+ return &ermite.GetVersionResponse{Features: features}, nil
49+ }}
50+ p, h = ermiteconnect.NewServerServiceHandler(v)
51+ mux.Handle("/-/api"+p, http.StripPrefix("/-/api", h))
52+ srv := httptest.NewServer(mux)
53+ t.Cleanup(srv.Close)
54+ return srv.URL
55+}
56+func (f *fakeReleaseAPI) CreateRelease(_ context.Context, r *connect.Request[ermite.CreateReleaseRequest]) (*connect.Response[ermite.CreateReleaseResponse], error) {
57+ f.mu.Lock()
58+ defer f.mu.Unlock()
59+ f.create = proto.Clone(r.Msg).(*ermite.CreateReleaseRequest)
60+ return connect.NewResponse(&ermite.CreateReleaseResponse{Release: f.release}), nil
61+}
62+func (f *fakeReleaseAPI) GetRelease(_ context.Context, r *connect.Request[ermite.GetReleaseRequest]) (*connect.Response[ermite.GetReleaseResponse], error) {
63+ f.mu.Lock()
64+ defer f.mu.Unlock()
65+ f.gets = append(f.gets, proto.Clone(r.Msg).(*ermite.GetReleaseRequest))
66+ return connect.NewResponse(&ermite.GetReleaseResponse{Release: f.release}), nil
67+}
68+func (f *fakeReleaseAPI) ListReleases(_ context.Context, r *connect.Request[ermite.ListReleasesRequest]) (*connect.Response[ermite.ListReleasesResponse], error) {
69+ f.mu.Lock()
70+ defer f.mu.Unlock()
71+ f.lists = append(f.lists, proto.Clone(r.Msg).(*ermite.ListReleasesRequest))
72+ next := ""
73+ if r.Msg.GetPageToken() == "" {
74+ next = "next"
75+ }
76+ return connect.NewResponse(&ermite.ListReleasesResponse{Releases: []*ermite.Release{f.release}, NextPageToken: next}), nil
77+}
78+func (f *fakeReleaseAPI) UpdateRelease(_ context.Context, r *connect.Request[ermite.UpdateReleaseRequest]) (*connect.Response[ermite.UpdateReleaseResponse], error) {
79+ f.mu.Lock()
80+ defer f.mu.Unlock()
81+ f.update = proto.Clone(r.Msg).(*ermite.UpdateReleaseRequest)
82+ return connect.NewResponse(&ermite.UpdateReleaseResponse{Release: f.release}), nil
83+}
84+func (f *fakeReleaseAPI) DeleteRelease(_ context.Context, r *connect.Request[ermite.DeleteReleaseRequest]) (*connect.Response[ermite.DeleteReleaseResponse], error) {
85+ f.mu.Lock()
86+ defer f.mu.Unlock()
87+ f.deleted = proto.Clone(r.Msg).(*ermite.DeleteReleaseRequest)
88+ return connect.NewResponse(&ermite.DeleteReleaseResponse{}), nil
89+}
90+func (f *fakeReleaseAPI) UploadReleaseAsset(_ context.Context, st *connect.ClientStream[ermite.UploadReleaseAssetRequest]) (*connect.Response[ermite.UploadReleaseAssetResponse], error) {
91+ var got []*ermite.UploadReleaseAssetRequest
92+ for st.Receive() {
93+ got = append(got, proto.Clone(st.Msg()).(*ermite.UploadReleaseAssetRequest))
94+ }
95+ if err := st.Err(); err != nil {
96+ return nil, err
97+ }
98+ f.mu.Lock()
99+ defer f.mu.Unlock()
100+ f.uploads = append(f.uploads, got...)
101+ if f.uploadErr != nil {
102+ return nil, f.uploadErr
103+ }
104+ return connect.NewResponse(&ermite.UploadReleaseAssetResponse{Asset: &ermite.ReleaseAsset{Id: 9, ReleaseId: 7, Name: got[0].GetName(), Size: got[0].GetSize()}}), nil
105+}
106+func (f *fakeReleaseAPI) DownloadReleaseAsset(_ context.Context, r *connect.Request[ermite.DownloadReleaseAssetRequest], st *connect.ServerStream[ermite.DownloadReleaseAssetResponse]) error {
107+ f.mu.Lock()
108+ f.offsets = append(f.offsets, r.Msg.GetOffset())
109+ fn := f.download
110+ f.mu.Unlock()
111+ if fn != nil {
112+ return fn(r.Msg, st)
113+ }
114+ if err := st.Send(&ermite.DownloadReleaseAssetResponse{Asset: f.release.Assets[0]}); err != nil {
115+ return err
116+ }
117+ return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: f.data[r.Msg.GetOffset():]})
118+}
119+func releaseFixture() *ermite.Release {
120+ return &ermite.Release{Id: 7, Repo: "proj", Tag: "v1", Title: "First", Notes: "# Notes\n", PublishedAt: ts("2026-10-10T12:00:00Z"), Assets: []*ermite.ReleaseAsset{}}
121+}
122+
123+func TestCLIReleaseCreateAndUpload(t *testing.T) {
124+ data := make([]byte, 5<<19)
125+ file := filepath.Join(t.TempDir(), "asset.bin")
126+ require.NoError(t, os.WriteFile(file, data, 0o600))
127+ notes := filepath.Join(t.TempDir(), "notes.md")
128+ require.NoError(t, os.WriteFile(notes, []byte("## hello"), 0o600))
129+ f := &fakeReleaseAPI{release: releaseFixture()}
130+ vars := envFor(f.serve(t, "releases"))
131+ res := runCLI(t, vars, "", "release", "create", "proj", "v1", "--title", "Hello", "--notes-file", notes, "--target", "main", "--draft", "--prerelease", "--latest", "--asset", file+"#Binary", "--json").ok(t)
132+ out := decode[jsonRelease](t, res.stdout)
133+ assert.Equal(t, int64(7), out.ID)
134+ require.Len(t, out.Assets, 1)
135+ f.mu.Lock()
136+ defer f.mu.Unlock()
137+ assert.True(t, proto.Equal(&ermite.CreateReleaseRequest{Repo: "proj", Tag: "v1", Title: "Hello", Notes: "## hello", TargetCommitish: "main", Draft: true, Prerelease: true, MakeLatest: true}, f.create))
138+ require.Len(t, f.uploads, 4)
139+ assert.Equal(t, "Binary", f.uploads[0].GetLabel())
140+ assert.Equal(t, int64(len(data)), f.uploads[0].GetSize())
141+ assert.Empty(t, f.uploads[0].GetData())
142+ assert.False(t, f.uploads[0].GetReplace())
143+ assert.NotEmpty(t, f.uploads[0].GetContentType())
144+ for i, n := range []int{1 << 20, 1 << 20, 1 << 19} {
145+ assert.Len(t, f.uploads[i+1].GetData(), n)
146+ assert.Empty(t, f.uploads[i+1].GetRepo())
147+ assert.Empty(t, f.uploads[i+1].GetName())
148+ }
149+}
150+func TestCLIReleaseUpload(t *testing.T) {
151+ file := filepath.Join(t.TempDir(), "x")
152+ require.NoError(t, os.WriteFile(file, []byte("hello"), 0o600))
153+ f := &fakeReleaseAPI{release: releaseFixture()}
154+ vars := envFor(f.serve(t, "releases"))
155+ runCLI(t, vars, "", "release", "upload", "proj", "7", file+"#A", "--clobber", "--content-type", "application/test").ok(t)
156+ f.mu.Lock()
157+ assert.True(t, f.uploads[0].GetReplace())
158+ assert.Equal(t, "application/test", f.uploads[0].GetContentType())
159+ assert.Equal(t, int64(7), f.gets[0].GetId())
160+ f.uploadErr = connect.NewError(connect.CodeAlreadyExists, fmt.Errorf("taken"))
161+ f.mu.Unlock()
162+ res := runCLI(t, vars, "", "release", "upload", "proj", "v1", file)
163+ require.ErrorContains(t, res.err, "--clobber")
164+ assert.Contains(t, res.err.Error(), `"x"`)
165+}
166+func TestCLIReleaseList(t *testing.T) {
167+ f := &fakeReleaseAPI{release: releaseFixture()}
168+ vars := envFor(f.serve(t, "releases"))
169+ res := runCLI(t, vars, "", "release", "list", "proj", "--limit", "1").ok(t)
170+ assert.Equal(t, "ID TAG TITLE STATE PUBLISHED ASSETS\n7 v1 First published 2026-10-10 12:00 0\n", res.stdout)
171+ res = runCLI(t, vars, "", "release", "list", "proj", "--limit", "2", "--json").ok(t)
172+ assert.Len(t, decode[[]jsonRelease](t, res.stdout), 2)
173+ f.mu.Lock()
174+ defer f.mu.Unlock()
175+ require.Len(t, f.lists, 3)
176+ assert.Equal(t, int32(2), f.lists[1].GetPageSize())
177+ assert.Equal(t, "next", f.lists[2].GetPageToken())
178+ assert.Equal(t, int32(1), f.lists[2].GetPageSize())
179+}
180+func TestCLIReleaseViewSelectors(t *testing.T) {
181+ f := &fakeReleaseAPI{release: releaseFixture()}
182+ vars := envFor(f.serve(t, "releases"))
183+ for _, sel := range []string{"v1", "7", "latest"} {
184+ runCLI(t, vars, "", "release", "view", "proj", sel, "--json").ok(t)
185+ }
186+ assert.Equal(t, "# Notes\n", runCLI(t, vars, "", "release", "view", "proj", "latest", "--notes").ok(t).stdout)
187+ res := runCLI(t, vars, "", "release", "view", "proj", "v1").ok(t)
188+ assert.Contains(t, res.stdout, "NAME SIZE TYPE")
189+ assert.Contains(t, res.stdout, "First")
190+ f.mu.Lock()
191+ defer f.mu.Unlock()
192+ assert.Equal(t, "v1", f.gets[0].GetTag())
193+ assert.Equal(t, int64(7), f.gets[1].GetId())
194+ assert.True(t, f.gets[2].GetLatest())
195+}
196+func TestCLIReleaseEdit(t *testing.T) {
197+ f := &fakeReleaseAPI{release: releaseFixture()}
198+ vars := envFor(f.serve(t, "releases"))
199+ runCLI(t, vars, "", "release", "edit", "proj", "v1", "--title=", "--notes-file", "-", "--tag", "v2", "--target", "main", "--publish", "--no-prerelease", "--latest=false").ok(t)
200+ f.mu.Lock()
201+ assert.ElementsMatch(t, []string{"title", "notes", "tag", "target_commitish", "draft", "prerelease", "make_latest"}, f.update.GetUpdateMask().GetPaths())
202+ assert.False(t, f.update.GetDraft())
203+ assert.False(t, f.update.GetPrerelease())
204+ assert.False(t, f.update.GetMakeLatest())
205+ assert.Empty(t, f.update.GetTitle())
206+ assert.Equal(t, "v2", f.update.GetTag())
207+ f.mu.Unlock()
208+ runCLI(t, vars, "", "release", "edit", "proj", "7", "--draft", "--prerelease", "--notes", "text").ok(t)
209+ f.mu.Lock()
210+ defer f.mu.Unlock()
211+ assert.True(t, f.update.GetDraft())
212+ assert.True(t, f.update.GetPrerelease())
213+ assert.Equal(t, "text", f.update.GetNotes())
214+}
215+func TestCLIReleaseDelete(t *testing.T) {
216+ for _, flags := range [][]string{nil, {"--yes"}} {
217+ f := &fakeReleaseAPI{release: releaseFixture()}
218+ vars := envFor(f.serve(t, "releases"))
219+ res := runCLI(t, vars, "", append([]string{"release", "delete", "proj", "v1"}, flags...)...).ok(t)
220+ assert.Equal(t, "deleted release 7\n", res.stdout)
221+ f.mu.Lock()
222+ assert.Equal(t, int64(7), f.deleted.GetId())
223+ assert.Equal(t, "proj", f.deleted.GetRepo())
224+ f.mu.Unlock()
225+ }
226+}
227+func TestCLIReleaseNoFeature(t *testing.T) {
228+ f := &fakeReleaseAPI{release: releaseFixture()}
229+ vars := envFor(f.serve(t))
230+ for _, args := range [][]string{{"create", "proj", "v1"}, {"list", "proj"}, {"view", "proj", "latest"}, {"edit", "proj", "v1", "--title", "x"}, {"delete", "proj", "v1"}, {"upload", "proj", "v1", "x"}, {"download", "proj", "latest"}} {
231+ res := runCLI(t, vars, "", append([]string{"release"}, args...)...)
232+ require.ErrorIs(t, res.err, errNoReleases)
233+ }
234+ f.mu.Lock()
235+ defer f.mu.Unlock()
236+ assert.Nil(t, f.create)
237+ assert.Empty(t, f.gets)
238+ assert.Nil(t, f.update)
239+ assert.Nil(t, f.deleted)
240+ assert.Empty(t, f.uploads)
241+}
242+func TestCLIReleaseFlagValidation(t *testing.T) {
243+ for _, args := range [][]string{{"create", "proj", "v1", "--notes", "x", "--notes-file", "f"}, {"edit", "proj", "v1", "--draft", "--publish"}, {"edit", "proj", "v1", "--prerelease", "--no-prerelease"}, {"edit", "proj", "v1"}, {"list", "proj", "--limit", "0"}, {"download", "proj", "v1", "--pattern", "["}, {"view", "proj", "v1", "--notes", "--json"}} {
244+ require.Error(t, runCLI(t, nil, "", append([]string{"release"}, args...)...).err)
245+ }
246+}
247+func TestCLIReleaseDownload(t *testing.T) {
248+ for _, mode := range []string{"normal", "resume", "twice broken", "digest mismatch", "unsafe", "overwrite", "empty"} {
249+ t.Run(mode, func(t *testing.T) {
250+ data := []byte("hello world")
251+ if mode == "empty" {
252+ data = nil
253+ }
254+ a := &ermite.ReleaseAsset{Id: 9, ReleaseId: 7, Name: "asset.bin", Size: int64(len(data)), Digest: fmt.Sprintf("sha256:%x", sha256.Sum256(data))}
255+ if mode == "unsafe" {
256+ a.Name = "../escape"
257+ }
258+ if mode == "digest mismatch" {
259+ a.Digest = "sha256:bad"
260+ }
261+ r := releaseFixture()
262+ r.Assets = []*ermite.ReleaseAsset{a}
263+ f := &fakeReleaseAPI{release: r, data: data}
264+ if mode == "resume" || mode == "twice broken" {
265+ f.download = func(req *ermite.DownloadReleaseAssetRequest, st *connect.ServerStream[ermite.DownloadReleaseAssetResponse]) error {
266+ if err := st.Send(&ermite.DownloadReleaseAssetResponse{Asset: a}); err != nil {
267+ return err
268+ }
269+ if req.GetOffset() == 0 {
270+ if err := st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: data[:4]}); err != nil {
271+ return err
272+ }
273+ return connect.NewError(connect.CodeUnavailable, fmt.Errorf("broken"))
274+ }
275+ if mode == "twice broken" {
276+ return connect.NewError(connect.CodeUnavailable, fmt.Errorf("broken"))
277+ }
278+ return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: data[req.GetOffset():]})
279+ }
280+ }
281+ dir := t.TempDir()
282+ path := filepath.Join(dir, a.GetName())
283+ if mode == "overwrite" {
284+ require.NoError(t, os.WriteFile(path, []byte("keep"), 0o600))
285+ }
286+ res := runCLI(t, envFor(f.serve(t, "releases")), "", "release", "download", "proj", "latest", "--dir", dir)
287+ switch mode {
288+ case "unsafe", "twice broken", "digest mismatch":
289+ require.Error(t, res.err)
290+ if mode != "unsafe" {
291+ _, err := os.Stat(path)
292+ assert.True(t, os.IsNotExist(err))
293+ }
294+ case "overwrite":
295+ require.Error(t, res.err)
296+ got, err := os.ReadFile(path)
297+ require.NoError(t, err)
298+ assert.Equal(t, "keep", string(got))
299+ default:
300+ res.ok(t)
301+ got, err := os.ReadFile(path)
302+ require.NoError(t, err)
303+ assert.Equal(t, string(data), string(got))
304+ }
305+ f.mu.Lock()
306+ defer f.mu.Unlock()
307+ if mode == "resume" || mode == "twice broken" {
308+ assert.Equal(t, []int64{0, 4}, f.offsets)
309+ }
310+ if mode == "normal" {
311+ assert.True(t, f.gets[0].GetLatest())
312+ }
313+ })
314+ }
315+}
316+func TestReleaseAssetSelection(t *testing.T) {
317+ all := []*ermite.ReleaseAsset{{Name: "a.zip"}, {Name: "b.txt"}, {Name: "c.zip"}}
318+ picked, err := pickReleaseAssets(all, []string{"b.txt"}, []string{"*.zip"})
319+ require.NoError(t, err)
320+ assert.Len(t, picked, 3)
321+ picked, err = pickReleaseAssets(all, nil, []string{"*.zip"})
322+ require.NoError(t, err)
323+ assert.Len(t, picked, 2)
324+ _, err = pickReleaseAssets(all, []string{"missing"}, nil)
325+ require.Error(t, err)
326+}
327+
328+func TestCLIReleaseDownloadProtocolFailures(t *testing.T) {
329+ for _, mode := range []string{"missing metadata", "wrong metadata", "too much data", "short data", "permission denied"} {
330+ t.Run(mode, func(t *testing.T) {
331+ data := []byte("abc")
332+ a := &ermite.ReleaseAsset{Id: 9, Name: "a", Size: 3, Digest: fmt.Sprintf("sha256:%x", sha256.Sum256(data))}
333+ r := releaseFixture()
334+ r.Assets = []*ermite.ReleaseAsset{a}
335+ f := &fakeReleaseAPI{release: r, download: func(_ *ermite.DownloadReleaseAssetRequest, st *connect.ServerStream[ermite.DownloadReleaseAssetResponse]) error {
336+ switch mode {
337+ case "missing metadata":
338+ return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: data})
339+ case "wrong metadata":
340+ return st.Send(&ermite.DownloadReleaseAssetResponse{Asset: &ermite.ReleaseAsset{Id: 10}})
341+ case "permission denied":
342+ return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("denied"))
343+ }
344+ if err := st.Send(&ermite.DownloadReleaseAssetResponse{Asset: a}); err != nil {
345+ return err
346+ }
347+ if mode == "too much data" {
348+ return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: []byte("abcd")})
349+ }
350+ return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: []byte("a")})
351+ }}
352+ dir := t.TempDir()
353+ res := runCLI(t, envFor(f.serve(t, "releases")), "", "release", "download", "proj", "v1", "--dir", dir)
354+ require.Error(t, res.err)
355+ _, err := os.Stat(filepath.Join(dir, "a"))
356+ assert.True(t, os.IsNotExist(err))
357+ f.mu.Lock()
358+ defer f.mu.Unlock()
359+ if mode == "short data" {
360+ assert.Len(t, f.offsets, 2)
361+ } else {
362+ assert.Len(t, f.offsets, 1)
363+ }
364+ })
365+ }
366+}
367+
368+func TestCLIReleaseUploadContentTypeAndEmpty(t *testing.T) {
369+ for _, data := range []string{"hello world", ""} {
370+ t.Run(fmt.Sprintf("size %d", len(data)), func(t *testing.T) {
371+ file := filepath.Join(t.TempDir(), "unknown-extension")
372+ require.NoError(t, os.WriteFile(file, []byte(data), 0o600))
373+ f := &fakeReleaseAPI{release: releaseFixture()}
374+ vars := envFor(f.serve(t, "releases"))
375+ runCLI(t, vars, "", "release", "upload", "proj", "v1", file).ok(t)
376+ f.mu.Lock()
377+ defer f.mu.Unlock()
378+ assert.Equal(t, int64(len(data)), f.uploads[0].GetSize())
379+ assert.Equal(t, "text/plain; charset=utf-8", f.uploads[0].GetContentType())
380+ if data == "" {
381+ assert.Len(t, f.uploads, 1)
382+ } else {
383+ require.Len(t, f.uploads, 2)
384+ assert.Equal(t, data, string(f.uploads[1].GetData()))
385+ }
386+ })
387+ }
388+}
@@ -0,0 +1,388 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "crypto/sha256"
8+ "fmt"
9+ "net/http"
10+ "net/http/httptest"
11+ "os"
12+ "path/filepath"
13+ "sync"
14+ "testing"
15+
16+ "connectrpc.com/connect"
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/require"
19+ "google.golang.org/protobuf/proto"
20+
21+ "bnema.dev/ermite-api/gen/ermite"
22+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
23+)
24+
25+// A focused version of cli_fake_test's Connect rig, without changing the shared fake.
26+type fakeReleaseAPI struct {
27+ ermiteconnect.UnimplementedReleaseServiceHandler
28+ mu sync.Mutex
29+ create *ermite.CreateReleaseRequest
30+ gets []*ermite.GetReleaseRequest
31+ lists []*ermite.ListReleasesRequest
32+ update *ermite.UpdateReleaseRequest
33+ deleted *ermite.DeleteReleaseRequest
34+ uploads []*ermite.UploadReleaseAssetRequest
35+ offsets []int64
36+ release *ermite.Release
37+ data []byte
38+ uploadErr error
39+ download func(*ermite.DownloadReleaseAssetRequest, *connect.ServerStream[ermite.DownloadReleaseAssetResponse]) error
40+}
41+
42+func (f *fakeReleaseAPI) serve(t *testing.T, features ...string) string {
43+ t.Helper()
44+ mux := http.NewServeMux()
45+ p, h := ermiteconnect.NewReleaseServiceHandler(f)
46+ mux.Handle("/-/api"+p, http.StripPrefix("/-/api", h))
47+ v := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
48+ return &ermite.GetVersionResponse{Features: features}, nil
49+ }}
50+ p, h = ermiteconnect.NewServerServiceHandler(v)
51+ mux.Handle("/-/api"+p, http.StripPrefix("/-/api", h))
52+ srv := httptest.NewServer(mux)
53+ t.Cleanup(srv.Close)
54+ return srv.URL
55+}
56+func (f *fakeReleaseAPI) CreateRelease(_ context.Context, r *connect.Request[ermite.CreateReleaseRequest]) (*connect.Response[ermite.CreateReleaseResponse], error) {
57+ f.mu.Lock()
58+ defer f.mu.Unlock()
59+ f.create = proto.Clone(r.Msg).(*ermite.CreateReleaseRequest)
60+ return connect.NewResponse(&ermite.CreateReleaseResponse{Release: f.release}), nil
61+}
62+func (f *fakeReleaseAPI) GetRelease(_ context.Context, r *connect.Request[ermite.GetReleaseRequest]) (*connect.Response[ermite.GetReleaseResponse], error) {
63+ f.mu.Lock()
64+ defer f.mu.Unlock()
65+ f.gets = append(f.gets, proto.Clone(r.Msg).(*ermite.GetReleaseRequest))
66+ return connect.NewResponse(&ermite.GetReleaseResponse{Release: f.release}), nil
67+}
68+func (f *fakeReleaseAPI) ListReleases(_ context.Context, r *connect.Request[ermite.ListReleasesRequest]) (*connect.Response[ermite.ListReleasesResponse], error) {
69+ f.mu.Lock()
70+ defer f.mu.Unlock()
71+ f.lists = append(f.lists, proto.Clone(r.Msg).(*ermite.ListReleasesRequest))
72+ next := ""
73+ if r.Msg.GetPageToken() == "" {
74+ next = "next"
75+ }
76+ return connect.NewResponse(&ermite.ListReleasesResponse{Releases: []*ermite.Release{f.release}, NextPageToken: next}), nil
77+}
78+func (f *fakeReleaseAPI) UpdateRelease(_ context.Context, r *connect.Request[ermite.UpdateReleaseRequest]) (*connect.Response[ermite.UpdateReleaseResponse], error) {
79+ f.mu.Lock()
80+ defer f.mu.Unlock()
81+ f.update = proto.Clone(r.Msg).(*ermite.UpdateReleaseRequest)
82+ return connect.NewResponse(&ermite.UpdateReleaseResponse{Release: f.release}), nil
83+}
84+func (f *fakeReleaseAPI) DeleteRelease(_ context.Context, r *connect.Request[ermite.DeleteReleaseRequest]) (*connect.Response[ermite.DeleteReleaseResponse], error) {
85+ f.mu.Lock()
86+ defer f.mu.Unlock()
87+ f.deleted = proto.Clone(r.Msg).(*ermite.DeleteReleaseRequest)
88+ return connect.NewResponse(&ermite.DeleteReleaseResponse{}), nil
89+}
90+func (f *fakeReleaseAPI) UploadReleaseAsset(_ context.Context, st *connect.ClientStream[ermite.UploadReleaseAssetRequest]) (*connect.Response[ermite.UploadReleaseAssetResponse], error) {
91+ var got []*ermite.UploadReleaseAssetRequest
92+ for st.Receive() {
93+ got = append(got, proto.Clone(st.Msg()).(*ermite.UploadReleaseAssetRequest))
94+ }
95+ if err := st.Err(); err != nil {
96+ return nil, err
97+ }
98+ f.mu.Lock()
99+ defer f.mu.Unlock()
100+ f.uploads = append(f.uploads, got...)
101+ if f.uploadErr != nil {
102+ return nil, f.uploadErr
103+ }
104+ return connect.NewResponse(&ermite.UploadReleaseAssetResponse{Asset: &ermite.ReleaseAsset{Id: 9, ReleaseId: 7, Name: got[0].GetName(), Size: got[0].GetSize()}}), nil
105+}
106+func (f *fakeReleaseAPI) DownloadReleaseAsset(_ context.Context, r *connect.Request[ermite.DownloadReleaseAssetRequest], st *connect.ServerStream[ermite.DownloadReleaseAssetResponse]) error {
107+ f.mu.Lock()
108+ f.offsets = append(f.offsets, r.Msg.GetOffset())
109+ fn := f.download
110+ f.mu.Unlock()
111+ if fn != nil {
112+ return fn(r.Msg, st)
113+ }
114+ if err := st.Send(&ermite.DownloadReleaseAssetResponse{Asset: f.release.Assets[0]}); err != nil {
115+ return err
116+ }
117+ return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: f.data[r.Msg.GetOffset():]})
118+}
119+func releaseFixture() *ermite.Release {
120+ return &ermite.Release{Id: 7, Repo: "proj", Tag: "v1", Title: "First", Notes: "# Notes\n", PublishedAt: ts("2026-10-10T12:00:00Z"), Assets: []*ermite.ReleaseAsset{}}
121+}
122+
123+func TestCLIReleaseCreateAndUpload(t *testing.T) {
124+ data := make([]byte, 5<<19)
125+ file := filepath.Join(t.TempDir(), "asset.bin")
126+ require.NoError(t, os.WriteFile(file, data, 0o600))
127+ notes := filepath.Join(t.TempDir(), "notes.md")
128+ require.NoError(t, os.WriteFile(notes, []byte("## hello"), 0o600))
129+ f := &fakeReleaseAPI{release: releaseFixture()}
130+ vars := envFor(f.serve(t, "releases"))
131+ res := runCLI(t, vars, "", "release", "create", "proj", "v1", "--title", "Hello", "--notes-file", notes, "--target", "main", "--draft", "--prerelease", "--latest", "--asset", file+"#Binary", "--json").ok(t)
132+ out := decode[jsonRelease](t, res.stdout)
133+ assert.Equal(t, int64(7), out.ID)
134+ require.Len(t, out.Assets, 1)
135+ f.mu.Lock()
136+ defer f.mu.Unlock()
137+ assert.True(t, proto.Equal(&ermite.CreateReleaseRequest{Repo: "proj", Tag: "v1", Title: "Hello", Notes: "## hello", TargetCommitish: "main", Draft: true, Prerelease: true, MakeLatest: true}, f.create))
138+ require.Len(t, f.uploads, 4)
139+ assert.Equal(t, "Binary", f.uploads[0].GetLabel())
140+ assert.Equal(t, int64(len(data)), f.uploads[0].GetSize())
141+ assert.Empty(t, f.uploads[0].GetData())
142+ assert.False(t, f.uploads[0].GetReplace())
143+ assert.NotEmpty(t, f.uploads[0].GetContentType())
144+ for i, n := range []int{1 << 20, 1 << 20, 1 << 19} {
145+ assert.Len(t, f.uploads[i+1].GetData(), n)
146+ assert.Empty(t, f.uploads[i+1].GetRepo())
147+ assert.Empty(t, f.uploads[i+1].GetName())
148+ }
149+}
150+func TestCLIReleaseUpload(t *testing.T) {
151+ file := filepath.Join(t.TempDir(), "x")
152+ require.NoError(t, os.WriteFile(file, []byte("hello"), 0o600))
153+ f := &fakeReleaseAPI{release: releaseFixture()}
154+ vars := envFor(f.serve(t, "releases"))
155+ runCLI(t, vars, "", "release", "upload", "proj", "7", file+"#A", "--clobber", "--content-type", "application/test").ok(t)
156+ f.mu.Lock()
157+ assert.True(t, f.uploads[0].GetReplace())
158+ assert.Equal(t, "application/test", f.uploads[0].GetContentType())
159+ assert.Equal(t, int64(7), f.gets[0].GetId())
160+ f.uploadErr = connect.NewError(connect.CodeAlreadyExists, fmt.Errorf("taken"))
161+ f.mu.Unlock()
162+ res := runCLI(t, vars, "", "release", "upload", "proj", "v1", file)
163+ require.ErrorContains(t, res.err, "--clobber")
164+ assert.Contains(t, res.err.Error(), `"x"`)
165+}
166+func TestCLIReleaseList(t *testing.T) {
167+ f := &fakeReleaseAPI{release: releaseFixture()}
168+ vars := envFor(f.serve(t, "releases"))
169+ res := runCLI(t, vars, "", "release", "list", "proj", "--limit", "1").ok(t)
170+ assert.Equal(t, "ID TAG TITLE STATE PUBLISHED ASSETS\n7 v1 First published 2026-10-10 12:00 0\n", res.stdout)
171+ res = runCLI(t, vars, "", "release", "list", "proj", "--limit", "2", "--json").ok(t)
172+ assert.Len(t, decode[[]jsonRelease](t, res.stdout), 2)
173+ f.mu.Lock()
174+ defer f.mu.Unlock()
175+ require.Len(t, f.lists, 3)
176+ assert.Equal(t, int32(2), f.lists[1].GetPageSize())
177+ assert.Equal(t, "next", f.lists[2].GetPageToken())
178+ assert.Equal(t, int32(1), f.lists[2].GetPageSize())
179+}
180+func TestCLIReleaseViewSelectors(t *testing.T) {
181+ f := &fakeReleaseAPI{release: releaseFixture()}
182+ vars := envFor(f.serve(t, "releases"))
183+ for _, sel := range []string{"v1", "7", "latest"} {
184+ runCLI(t, vars, "", "release", "view", "proj", sel, "--json").ok(t)
185+ }
186+ assert.Equal(t, "# Notes\n", runCLI(t, vars, "", "release", "view", "proj", "latest", "--notes").ok(t).stdout)
187+ res := runCLI(t, vars, "", "release", "view", "proj", "v1").ok(t)
188+ assert.Contains(t, res.stdout, "NAME SIZE TYPE")
189+ assert.Contains(t, res.stdout, "First")
190+ f.mu.Lock()
191+ defer f.mu.Unlock()
192+ assert.Equal(t, "v1", f.gets[0].GetTag())
193+ assert.Equal(t, int64(7), f.gets[1].GetId())
194+ assert.True(t, f.gets[2].GetLatest())
195+}
196+func TestCLIReleaseEdit(t *testing.T) {
197+ f := &fakeReleaseAPI{release: releaseFixture()}
198+ vars := envFor(f.serve(t, "releases"))
199+ runCLI(t, vars, "", "release", "edit", "proj", "v1", "--title=", "--notes-file", "-", "--tag", "v2", "--target", "main", "--publish", "--no-prerelease", "--latest=false").ok(t)
200+ f.mu.Lock()
201+ assert.ElementsMatch(t, []string{"title", "notes", "tag", "target_commitish", "draft", "prerelease", "make_latest"}, f.update.GetUpdateMask().GetPaths())
202+ assert.False(t, f.update.GetDraft())
203+ assert.False(t, f.update.GetPrerelease())
204+ assert.False(t, f.update.GetMakeLatest())
205+ assert.Empty(t, f.update.GetTitle())
206+ assert.Equal(t, "v2", f.update.GetTag())
207+ f.mu.Unlock()
208+ runCLI(t, vars, "", "release", "edit", "proj", "7", "--draft", "--prerelease", "--notes", "text").ok(t)
209+ f.mu.Lock()
210+ defer f.mu.Unlock()
211+ assert.True(t, f.update.GetDraft())
212+ assert.True(t, f.update.GetPrerelease())
213+ assert.Equal(t, "text", f.update.GetNotes())
214+}
215+func TestCLIReleaseDelete(t *testing.T) {
216+ for _, flags := range [][]string{nil, {"--yes"}} {
217+ f := &fakeReleaseAPI{release: releaseFixture()}
218+ vars := envFor(f.serve(t, "releases"))
219+ res := runCLI(t, vars, "", append([]string{"release", "delete", "proj", "v1"}, flags...)...).ok(t)
220+ assert.Equal(t, "deleted release 7\n", res.stdout)
221+ f.mu.Lock()
222+ assert.Equal(t, int64(7), f.deleted.GetId())
223+ assert.Equal(t, "proj", f.deleted.GetRepo())
224+ f.mu.Unlock()
225+ }
226+}
227+func TestCLIReleaseNoFeature(t *testing.T) {
228+ f := &fakeReleaseAPI{release: releaseFixture()}
229+ vars := envFor(f.serve(t))
230+ for _, args := range [][]string{{"create", "proj", "v1"}, {"list", "proj"}, {"view", "proj", "latest"}, {"edit", "proj", "v1", "--title", "x"}, {"delete", "proj", "v1"}, {"upload", "proj", "v1", "x"}, {"download", "proj", "latest"}} {
231+ res := runCLI(t, vars, "", append([]string{"release"}, args...)...)
232+ require.ErrorIs(t, res.err, errNoReleases)
233+ }
234+ f.mu.Lock()
235+ defer f.mu.Unlock()
236+ assert.Nil(t, f.create)
237+ assert.Empty(t, f.gets)
238+ assert.Nil(t, f.update)
239+ assert.Nil(t, f.deleted)
240+ assert.Empty(t, f.uploads)
241+}
242+func TestCLIReleaseFlagValidation(t *testing.T) {
243+ for _, args := range [][]string{{"create", "proj", "v1", "--notes", "x", "--notes-file", "f"}, {"edit", "proj", "v1", "--draft", "--publish"}, {"edit", "proj", "v1", "--prerelease", "--no-prerelease"}, {"edit", "proj", "v1"}, {"list", "proj", "--limit", "0"}, {"download", "proj", "v1", "--pattern", "["}, {"view", "proj", "v1", "--notes", "--json"}} {
244+ require.Error(t, runCLI(t, nil, "", append([]string{"release"}, args...)...).err)
245+ }
246+}
247+func TestCLIReleaseDownload(t *testing.T) {
248+ for _, mode := range []string{"normal", "resume", "twice broken", "digest mismatch", "unsafe", "overwrite", "empty"} {
249+ t.Run(mode, func(t *testing.T) {
250+ data := []byte("hello world")
251+ if mode == "empty" {
252+ data = nil
253+ }
254+ a := &ermite.ReleaseAsset{Id: 9, ReleaseId: 7, Name: "asset.bin", Size: int64(len(data)), Digest: fmt.Sprintf("sha256:%x", sha256.Sum256(data))}
255+ if mode == "unsafe" {
256+ a.Name = "../escape"
257+ }
258+ if mode == "digest mismatch" {
259+ a.Digest = "sha256:bad"
260+ }
261+ r := releaseFixture()
262+ r.Assets = []*ermite.ReleaseAsset{a}
263+ f := &fakeReleaseAPI{release: r, data: data}
264+ if mode == "resume" || mode == "twice broken" {
265+ f.download = func(req *ermite.DownloadReleaseAssetRequest, st *connect.ServerStream[ermite.DownloadReleaseAssetResponse]) error {
266+ if err := st.Send(&ermite.DownloadReleaseAssetResponse{Asset: a}); err != nil {
267+ return err
268+ }
269+ if req.GetOffset() == 0 {
270+ if err := st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: data[:4]}); err != nil {
271+ return err
272+ }
273+ return connect.NewError(connect.CodeUnavailable, fmt.Errorf("broken"))
274+ }
275+ if mode == "twice broken" {
276+ return connect.NewError(connect.CodeUnavailable, fmt.Errorf("broken"))
277+ }
278+ return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: data[req.GetOffset():]})
279+ }
280+ }
281+ dir := t.TempDir()
282+ path := filepath.Join(dir, a.GetName())
283+ if mode == "overwrite" {
284+ require.NoError(t, os.WriteFile(path, []byte("keep"), 0o600))
285+ }
286+ res := runCLI(t, envFor(f.serve(t, "releases")), "", "release", "download", "proj", "latest", "--dir", dir)
287+ switch mode {
288+ case "unsafe", "twice broken", "digest mismatch":
289+ require.Error(t, res.err)
290+ if mode != "unsafe" {
291+ _, err := os.Stat(path)
292+ assert.True(t, os.IsNotExist(err))
293+ }
294+ case "overwrite":
295+ require.Error(t, res.err)
296+ got, err := os.ReadFile(path)
297+ require.NoError(t, err)
298+ assert.Equal(t, "keep", string(got))
299+ default:
300+ res.ok(t)
301+ got, err := os.ReadFile(path)
302+ require.NoError(t, err)
303+ assert.Equal(t, string(data), string(got))
304+ }
305+ f.mu.Lock()
306+ defer f.mu.Unlock()
307+ if mode == "resume" || mode == "twice broken" {
308+ assert.Equal(t, []int64{0, 4}, f.offsets)
309+ }
310+ if mode == "normal" {
311+ assert.True(t, f.gets[0].GetLatest())
312+ }
313+ })
314+ }
315+}
316+func TestReleaseAssetSelection(t *testing.T) {
317+ all := []*ermite.ReleaseAsset{{Name: "a.zip"}, {Name: "b.txt"}, {Name: "c.zip"}}
318+ picked, err := pickReleaseAssets(all, []string{"b.txt"}, []string{"*.zip"})
319+ require.NoError(t, err)
320+ assert.Len(t, picked, 3)
321+ picked, err = pickReleaseAssets(all, nil, []string{"*.zip"})
322+ require.NoError(t, err)
323+ assert.Len(t, picked, 2)
324+ _, err = pickReleaseAssets(all, []string{"missing"}, nil)
325+ require.Error(t, err)
326+}
327+
328+func TestCLIReleaseDownloadProtocolFailures(t *testing.T) {
329+ for _, mode := range []string{"missing metadata", "wrong metadata", "too much data", "short data", "permission denied"} {
330+ t.Run(mode, func(t *testing.T) {
331+ data := []byte("abc")
332+ a := &ermite.ReleaseAsset{Id: 9, Name: "a", Size: 3, Digest: fmt.Sprintf("sha256:%x", sha256.Sum256(data))}
333+ r := releaseFixture()
334+ r.Assets = []*ermite.ReleaseAsset{a}
335+ f := &fakeReleaseAPI{release: r, download: func(_ *ermite.DownloadReleaseAssetRequest, st *connect.ServerStream[ermite.DownloadReleaseAssetResponse]) error {
336+ switch mode {
337+ case "missing metadata":
338+ return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: data})
339+ case "wrong metadata":
340+ return st.Send(&ermite.DownloadReleaseAssetResponse{Asset: &ermite.ReleaseAsset{Id: 10}})
341+ case "permission denied":
342+ return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("denied"))
343+ }
344+ if err := st.Send(&ermite.DownloadReleaseAssetResponse{Asset: a}); err != nil {
345+ return err
346+ }
347+ if mode == "too much data" {
348+ return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: []byte("abcd")})
349+ }
350+ return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: []byte("a")})
351+ }}
352+ dir := t.TempDir()
353+ res := runCLI(t, envFor(f.serve(t, "releases")), "", "release", "download", "proj", "v1", "--dir", dir)
354+ require.Error(t, res.err)
355+ _, err := os.Stat(filepath.Join(dir, "a"))
356+ assert.True(t, os.IsNotExist(err))
357+ f.mu.Lock()
358+ defer f.mu.Unlock()
359+ if mode == "short data" {
360+ assert.Len(t, f.offsets, 2)
361+ } else {
362+ assert.Len(t, f.offsets, 1)
363+ }
364+ })
365+ }
366+}
367+
368+func TestCLIReleaseUploadContentTypeAndEmpty(t *testing.T) {
369+ for _, data := range []string{"hello world", ""} {
370+ t.Run(fmt.Sprintf("size %d", len(data)), func(t *testing.T) {
371+ file := filepath.Join(t.TempDir(), "unknown-extension")
372+ require.NoError(t, os.WriteFile(file, []byte(data), 0o600))
373+ f := &fakeReleaseAPI{release: releaseFixture()}
374+ vars := envFor(f.serve(t, "releases"))
375+ runCLI(t, vars, "", "release", "upload", "proj", "v1", file).ok(t)
376+ f.mu.Lock()
377+ defer f.mu.Unlock()
378+ assert.Equal(t, int64(len(data)), f.uploads[0].GetSize())
379+ assert.Equal(t, "text/plain; charset=utf-8", f.uploads[0].GetContentType())
380+ if data == "" {
381+ assert.Len(t, f.uploads, 1)
382+ } else {
383+ require.Len(t, f.uploads, 2)
384+ assert.Equal(t, data, string(f.uploads[1].GetData()))
385+ }
386+ })
387+ }
388+}
M internal/app/cli_token.go +9 -0
@@ -145,6 +145,15 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
145145 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "note: the server has no issues feature, so the token has no issue:write scope")
146146 }
147147 }
148+ if slices.Contains(req.Scopes, ermite.Scope_SCOPE_RELEASE_WRITE) {
149+ if err := requireReleases(env.ctx(cmd), c); err != nil {
150+ if !errors.Is(err, errNoReleases) || explicitScope(scopes, ermite.Scope_SCOPE_RELEASE_WRITE) {
151+ return fmt.Errorf("%w (needed for the release:write scope; nothing was created)", err)
152+ }
153+ req.Scopes = slices.DeleteFunc(req.Scopes, func(s ermite.Scope) bool { return s == ermite.Scope_SCOPE_RELEASE_WRITE })
154+ _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "note: the server has no releases feature, so the token has no release:write scope")
155+ }
156+ }
148157 res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))
149158 if err != nil {
150159 return c.Describe(err)
@@ -145,6 +145,15 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
145 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "note: the server has no issues feature, so the token has no issue:write scope")145 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "note: the server has no issues feature, so the token has no issue:write scope")
146 }146 }
147 }147 }
148+ if slices.Contains(req.Scopes, ermite.Scope_SCOPE_RELEASE_WRITE) {
149+ if err := requireReleases(env.ctx(cmd), c); err != nil {
150+ if !errors.Is(err, errNoReleases) || explicitScope(scopes, ermite.Scope_SCOPE_RELEASE_WRITE) {
151+ return fmt.Errorf("%w (needed for the release:write scope; nothing was created)", err)
152+ }
153+ req.Scopes = slices.DeleteFunc(req.Scopes, func(s ermite.Scope) bool { return s == ermite.Scope_SCOPE_RELEASE_WRITE })
154+ _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "note: the server has no releases feature, so the token has no release:write scope")
155+ }
156+ }
148 res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))157 res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))
149 if err != nil {158 if err != nil {
150 return c.Describe(err)159 return c.Describe(err)
M internal/app/cli_token_test.go +40 -1
@@ -4,6 +4,7 @@ package app
44
55 import (
66 "errors"
7+ "fmt"
78 "strings"
89 "testing"
910 "time"
@@ -174,7 +175,9 @@ func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) {
174175 "cli": client.AllScopes(),
175176 } {
176177 t.Run(preset, func(t *testing.T) {
177- f := &fakeAPI{getVersion: withIssues, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
178+ f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
179+ return &ermite.GetVersionResponse{Features: []string{"issues", "releases"}}, nil
180+ }, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
178181 return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Scopes: r.GetScopes()}, Secret: "s"}, nil
179182 }}
180183 runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", preset).ok(t)
@@ -273,3 +276,39 @@ func TestTokenCompletion(t *testing.T) {
273276 res = runCLI(t, vars, "", "__complete", "token", "create", "x", "--repo", "").ok(t)
274277 assert.Contains(t, res.stdout, "alpha")
275278 }
279+
280+func TestTokenReleaseWriteFeatureGuard(t *testing.T) {
281+ for _, supported := range []bool{false, true} {
282+ t.Run(fmt.Sprint(supported), func(t *testing.T) {
283+ f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
284+ features := []string{"issues"}
285+ if supported {
286+ features = append(features, "releases")
287+ }
288+ return &ermite.GetVersionResponse{Features: features}, nil
289+ }, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
290+ return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Scopes: r.GetScopes()}, Secret: "s"}, nil
291+ }}
292+ vars := envFor(f.serve(t))
293+ res := runCLI(t, vars, "", "token", "create", "daily", "--preset", "cli").ok(t)
294+ got := lastCreateToken(t, f).GetScopes()
295+ if supported {
296+ assert.Contains(t, got, ermite.Scope_SCOPE_RELEASE_WRITE)
297+ assert.NotContains(t, res.stderr, "no release:write")
298+ } else {
299+ assert.NotContains(t, got, ermite.Scope_SCOPE_RELEASE_WRITE)
300+ assert.Contains(t, res.stderr, "the server has no releases feature, so the token has no release:write scope")
301+ }
302+ before, _ := f.snapshot()
303+ res = runCLI(t, vars, "", "token", "create", "explicit", "--scope", "release:write")
304+ if supported {
305+ res.ok(t)
306+ } else {
307+ require.ErrorIs(t, res.err, errNoReleases)
308+ after, _ := f.snapshot()
309+ assert.Equal(t, len(before)+1, len(after))
310+ assert.Equal(t, "GetVersion", after[len(after)-1])
311+ }
312+ })
313+ }
314+}
@@ -4,6 +4,7 @@ package app
4 4
5 import (5 import (
6 "errors"6 "errors"
7+ "fmt"
7 "strings"8 "strings"
8 "testing"9 "testing"
9 "time"10 "time"
@@ -174,7 +175,9 @@ func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) {
174 "cli": client.AllScopes(),175 "cli": client.AllScopes(),
175 } {176 } {
176 t.Run(preset, func(t *testing.T) {177 t.Run(preset, func(t *testing.T) {
177- f := &fakeAPI{getVersion: withIssues, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {178+ f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
179+ return &ermite.GetVersionResponse{Features: []string{"issues", "releases"}}, nil
180+ }, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
178 return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Scopes: r.GetScopes()}, Secret: "s"}, nil181 return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Scopes: r.GetScopes()}, Secret: "s"}, nil
179 }}182 }}
180 runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", preset).ok(t)183 runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", preset).ok(t)
@@ -273,3 +276,39 @@ func TestTokenCompletion(t *testing.T) {
273 res = runCLI(t, vars, "", "__complete", "token", "create", "x", "--repo", "").ok(t)276 res = runCLI(t, vars, "", "__complete", "token", "create", "x", "--repo", "").ok(t)
274 assert.Contains(t, res.stdout, "alpha")277 assert.Contains(t, res.stdout, "alpha")
275 }278 }
279+
280+func TestTokenReleaseWriteFeatureGuard(t *testing.T) {
281+ for _, supported := range []bool{false, true} {
282+ t.Run(fmt.Sprint(supported), func(t *testing.T) {
283+ f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
284+ features := []string{"issues"}
285+ if supported {
286+ features = append(features, "releases")
287+ }
288+ return &ermite.GetVersionResponse{Features: features}, nil
289+ }, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
290+ return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Scopes: r.GetScopes()}, Secret: "s"}, nil
291+ }}
292+ vars := envFor(f.serve(t))
293+ res := runCLI(t, vars, "", "token", "create", "daily", "--preset", "cli").ok(t)
294+ got := lastCreateToken(t, f).GetScopes()
295+ if supported {
296+ assert.Contains(t, got, ermite.Scope_SCOPE_RELEASE_WRITE)
297+ assert.NotContains(t, res.stderr, "no release:write")
298+ } else {
299+ assert.NotContains(t, got, ermite.Scope_SCOPE_RELEASE_WRITE)
300+ assert.Contains(t, res.stderr, "the server has no releases feature, so the token has no release:write scope")
301+ }
302+ before, _ := f.snapshot()
303+ res = runCLI(t, vars, "", "token", "create", "explicit", "--scope", "release:write")
304+ if supported {
305+ res.ok(t)
306+ } else {
307+ require.ErrorIs(t, res.err, errNoReleases)
308+ after, _ := f.snapshot()
309+ assert.Equal(t, len(before)+1, len(after))
310+ assert.Equal(t, "GetVersion", after[len(after)-1])
311+ }
312+ })
313+ }
314+}
M internal/app/cli_version.go +1 -0
@@ -109,6 +109,7 @@ func newVersionCmd(env *cliEnv) *cobra.Command {
109109
110110 // featureUse says what breaks without a feature, for the version warning.
111111 var featureUse = map[string]string{
112+ client.FeatureReleases: "`ermite release` needs: the commands will fail on this server, and explicit release:write scopes are refused",
112113 client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server",
113114 client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",
114115 client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",
@@ -109,6 +109,7 @@ func newVersionCmd(env *cliEnv) *cobra.Command {
109 109
110 // featureUse says what breaks without a feature, for the version warning.110 // featureUse says what breaks without a feature, for the version warning.
111 var featureUse = map[string]string{111 var featureUse = map[string]string{
112+ client.FeatureReleases: "`ermite release` needs: the commands will fail on this server, and explicit release:write scopes are refused",
112 client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server",113 client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server",
113 client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",114 client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",
114 client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",115 client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",
M internal/app/cli_version_test.go +5 -5
@@ -17,7 +17,7 @@ import (
1717 )
1818
1919 // allFeatures are the features this client uses.
20-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "passkeys"}
20+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "releases", "passkeys"}
2121
2222 // Every feature the client uses needs a sentence for the version warning.
2323 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {
@@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) {
4545 f := versionFake(allFeatures...)
4646 url := f.serve(t)
4747 res := runCLI(t, envFor(url), "", "version").ok(t)
48- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, merge_requests.mergeability, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, merge_requests.auto_merge, passkeys\n", res.stdout)
48+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, merge_requests.mergeability, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, merge_requests.auto_merge, releases, passkeys\n", res.stdout)
4949 assert.Empty(t, res.stderr, "no warning when every feature is there")
5050
5151 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
52- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","passkeys"],"missing_features":[]}}`, res.stdout)
52+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","releases","passkeys"],"missing_features":[]}}`, res.stdout)
5353 }
5454
5555 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
56- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "passkeys")
56+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "releases", "passkeys")
5757 url := f.serve(t)
5858 res := runCLI(t, envFor(url), "", "version").ok(t)
5959 assert.Contains(t, res.stdout, "v0.4.0")
@@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
6161 assert.NotContains(t, res.stderr, "repo.update")
6262
6363 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
64- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
64+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","releases","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
6565 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
6666
6767 f = versionFake()
@@ -17,7 +17,7 @@ import (
17 )17 )
18 18
19 // allFeatures are the features this client uses.19 // allFeatures are the features this client uses.
20-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "passkeys"}20+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "releases", "passkeys"}
21 21
22 // Every feature the client uses needs a sentence for the version warning.22 // Every feature the client uses needs a sentence for the version warning.
23 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {23 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {
@@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) {
45 f := versionFake(allFeatures...)45 f := versionFake(allFeatures...)
46 url := f.serve(t)46 url := f.serve(t)
47 res := runCLI(t, envFor(url), "", "version").ok(t)47 res := runCLI(t, envFor(url), "", "version").ok(t)
48- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, merge_requests.mergeability, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, merge_requests.auto_merge, passkeys\n", res.stdout)48+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, merge_requests.mergeability, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, merge_requests.auto_merge, releases, passkeys\n", res.stdout)
49 assert.Empty(t, res.stderr, "no warning when every feature is there")49 assert.Empty(t, res.stderr, "no warning when every feature is there")
50 50
51 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)51 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
52- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","passkeys"],"missing_features":[]}}`, res.stdout)52+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","releases","passkeys"],"missing_features":[]}}`, res.stdout)
53 }53 }
54 54
55 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {55 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
56- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "passkeys")56+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "releases", "passkeys")
57 url := f.serve(t)57 url := f.serve(t)
58 res := runCLI(t, envFor(url), "", "version").ok(t)58 res := runCLI(t, envFor(url), "", "version").ok(t)
59 assert.Contains(t, res.stdout, "v0.4.0")59 assert.Contains(t, res.stdout, "v0.4.0")
@@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
61 assert.NotContains(t, res.stderr, "repo.update")61 assert.NotContains(t, res.stderr, "repo.update")
62 62
63 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)63 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
64- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)64+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","releases","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
65 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")65 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
66 66
67 f = versionFake()67 f = versionFake()
M internal/app/forge.go +49 -4
@@ -17,6 +17,8 @@ import (
1717
1818 "github.com/bnema/zerowrap"
1919
20+ "bnema.dev/ermite/internal/adapters/blobdisk"
21+ "bnema.dev/ermite/internal/adapters/blobs3"
2022 "bnema.dev/ermite/internal/adapters/ciartifacts"
2123 "bnema.dev/ermite/internal/adapters/cilogs"
2224 "bnema.dev/ermite/internal/adapters/cisummaries"
@@ -153,7 +155,9 @@ type forge struct {
153155 EventLog *usecase.Events
154156 // CI is the CI engine: workflow trigger, planner, runner protocol and the
155157 // owner side. It starts with the server and stops with it.
156- CI *usecase.CI
158+ CI *usecase.CI
159+ Releases *usecase.Releases
160+ Blobs ports.BlobStore
157161 // Settings are the secrets, variables and environments of CI.
158162 Settings *usecase.CISettings
159163 // Mirrors exposes Warning and Badge for the hook server and web composition.
@@ -268,6 +272,13 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
268272 }
269273 log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")
270274 f.Browse = usecase.NewBrowse(store, reader)
275+ f.Blobs, err = blobStore(ctx, cfg)
276+ if err != nil {
277+ return nil, err
278+ }
279+ f.Releases = usecase.NewReleases(store.Releases(), f.Blobs, store, reader, git, f.Pushes, f.Bus, clock, ports.ReleaseLimits{AssetMax: cfg.ReleaseAssetMax, AssetsMax: int64(cfg.ReleaseAssetsMax), Quota: cfg.ReleaseQuota})
280+ f.Pushes.WithReleases(f.Releases)
281+ f.Repos.WithReleases(f.Releases)
271282
272283 // Secrets, variables and environments (ADR 0019, CI-3). With a key the
273284 // secrets are sealed under it, and the server refuses to start when the
@@ -335,6 +346,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
335346 OIDC: f.OIDC,
336347 }
337348 artifacts(&ciCfg, cfg, store)
349+ ciCfg.Blobs = f.Blobs
350+ ciCfg.ReleaseSweeper = f.Releases
338351 f.pullRequests(&ciCfg, store, git, committer)
339352 if opts.ci != nil {
340353 opts.ci(&ciCfg)
@@ -459,6 +472,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
459472 OIDCKeys: f.OIDC,
460473 JobArtifacts: jobArtifacts,
461474 Artifacts: artifacts,
475+ Releases: f.Releases,
462476 Version: serverVersion(),
463477 TrustedProxies: cfg.TrustedProxies,
464478 Limiter: limiter,
@@ -487,7 +501,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
487501 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
488502 }
489503 // The REST subset the actions call on github.api_url.
490- api := githubapi.New(githubapi.Config{Auth: f.Auth, API: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter, MaxArchivesPerClient: opts.archivesPerClient})
504+ releaseAPI := usecase.NewReleasesGitHub(f.Releases, cfg.Owner, cfg.PublicURL)
505+ api := githubapi.New(githubapi.Config{Auth: f.Auth, API: f.CI, Releases: releaseAPI, ReleaseDownload: f.Releases, TrustedProxies: cfg.TrustedProxies, Limiter: limiter, MaxArchivesPerClient: opts.archivesPerClient})
491506 for _, p := range githubapi.Patterns {
492507 routes = append(routes, httpserver.Route{Pattern: p, Handler: api})
493508 }
@@ -593,16 +608,46 @@ func (f *forge) Close(ctx context.Context) {
593608 }
594609
595610 // artifacts gives the CI the stores and limits of the artifacts (CI-4 D3):
596-// records in the kv store, contents under ci/artifacts. ERMITE_CI_ARTIFACTS=0
611+// records in the kv store, legacy contents under ci/artifacts. ERMITE_CI_ARTIFACTS=0
597612 // gives none, and the API then serves no artifact service.
598613 func artifacts(ci *usecase.CIConfig, cfg config.Config, store *kvstore.Store) {
599614 if !cfg.CIArtifacts {
600615 return
601616 }
602- ci.Artifacts, ci.ArtifactFiles = store.Artifacts(), ciartifacts.New(ciartifacts.Dir(cfg.DataDir))
617+ ci.Artifacts = store.Artifacts()
618+ if info, err := os.Stat(ciartifacts.Dir(cfg.DataDir)); err == nil && info.IsDir() {
619+ ci.ArtifactFiles = ciartifacts.New(ciartifacts.Dir(cfg.DataDir))
620+ }
603621 ci.ArtifactLimits = ports.ArtifactLimits{Retention: cfg.CIArtifactRetention, Quota: cfg.CIArtifactQuota, MaxSize: cfg.CIArtifactMax}
604622 }
605623
624+// blobStore selects and probes the content backend before any engine starts.
625+func blobStore(ctx context.Context, cfg config.Config) (ports.BlobStore, error) {
626+ var blobs ports.BlobStore
627+ backend, location := "disk", blobdisk.Dir(cfg.DataDir)
628+ if cfg.S3Bucket == "" {
629+ blobs = blobdisk.New(location)
630+ } else {
631+ backend, location = "s3", cfg.S3Bucket
632+ store, err := blobs3.New(ctx, blobs3.Config{Bucket: cfg.S3Bucket, Prefix: cfg.S3Prefix, Endpoint: cfg.S3Endpoint, Region: cfg.S3Region, AccessKeyID: cfg.S3AccessKeyID, SecretAccessKey: cfg.S3SecretAccessKey, ForcePathStyle: cfg.S3ForcePathStyle})
633+ if err != nil {
634+ return nil, fmt.Errorf("%s: %w", config.EnvS3Bucket, err)
635+ }
636+ blobs = store
637+ }
638+ probe, cancel := context.WithTimeout(ctx, 30*time.Second)
639+ defer cancel()
640+ if err := blobs.Check(probe); err != nil {
641+ if backend == "s3" {
642+ return nil, fmt.Errorf("%s: %w", config.EnvS3Bucket, err)
643+ }
644+ return nil, fmt.Errorf("blob store: %w", err)
645+ }
646+ log := zerowrap.FromCtx(ctx)
647+ log.Info().Str("backend", backend).Str("location", location).Msg("blob store ready")
648+ return blobs, nil
649+}
650+
606651 // pullRequests gives the CI the merge requests, the refs/pull refs and the
607652 // identity of the test merge commits (CI-3, D5).
608653 func (f *forge) pullRequests(cfg *usecase.CIConfig, store *kvstore.Store, git *gitcli.Git, committer domain.Identity) {
@@ -17,6 +17,8 @@ import (
17 17
18 "github.com/bnema/zerowrap"18 "github.com/bnema/zerowrap"
19 19
20+ "bnema.dev/ermite/internal/adapters/blobdisk"
21+ "bnema.dev/ermite/internal/adapters/blobs3"
20 "bnema.dev/ermite/internal/adapters/ciartifacts"22 "bnema.dev/ermite/internal/adapters/ciartifacts"
21 "bnema.dev/ermite/internal/adapters/cilogs"23 "bnema.dev/ermite/internal/adapters/cilogs"
22 "bnema.dev/ermite/internal/adapters/cisummaries"24 "bnema.dev/ermite/internal/adapters/cisummaries"
@@ -153,7 +155,9 @@ type forge struct {
153 EventLog *usecase.Events155 EventLog *usecase.Events
154 // CI is the CI engine: workflow trigger, planner, runner protocol and the156 // CI is the CI engine: workflow trigger, planner, runner protocol and the
155 // owner side. It starts with the server and stops with it.157 // owner side. It starts with the server and stops with it.
156- CI *usecase.CI158+ CI *usecase.CI
159+ Releases *usecase.Releases
160+ Blobs ports.BlobStore
157 // Settings are the secrets, variables and environments of CI.161 // Settings are the secrets, variables and environments of CI.
158 Settings *usecase.CISettings162 Settings *usecase.CISettings
159 // Mirrors exposes Warning and Badge for the hook server and web composition.163 // Mirrors exposes Warning and Badge for the hook server and web composition.
@@ -268,6 +272,13 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
268 }272 }
269 log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")273 log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")
270 f.Browse = usecase.NewBrowse(store, reader)274 f.Browse = usecase.NewBrowse(store, reader)
275+ f.Blobs, err = blobStore(ctx, cfg)
276+ if err != nil {
277+ return nil, err
278+ }
279+ f.Releases = usecase.NewReleases(store.Releases(), f.Blobs, store, reader, git, f.Pushes, f.Bus, clock, ports.ReleaseLimits{AssetMax: cfg.ReleaseAssetMax, AssetsMax: int64(cfg.ReleaseAssetsMax), Quota: cfg.ReleaseQuota})
280+ f.Pushes.WithReleases(f.Releases)
281+ f.Repos.WithReleases(f.Releases)
271 282
272 // Secrets, variables and environments (ADR 0019, CI-3). With a key the283 // Secrets, variables and environments (ADR 0019, CI-3). With a key the
273 // secrets are sealed under it, and the server refuses to start when the284 // secrets are sealed under it, and the server refuses to start when the
@@ -335,6 +346,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
335 OIDC: f.OIDC,346 OIDC: f.OIDC,
336 }347 }
337 artifacts(&ciCfg, cfg, store)348 artifacts(&ciCfg, cfg, store)
349+ ciCfg.Blobs = f.Blobs
350+ ciCfg.ReleaseSweeper = f.Releases
338 f.pullRequests(&ciCfg, store, git, committer)351 f.pullRequests(&ciCfg, store, git, committer)
339 if opts.ci != nil {352 if opts.ci != nil {
340 opts.ci(&ciCfg)353 opts.ci(&ciCfg)
@@ -459,6 +472,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
459 OIDCKeys: f.OIDC,472 OIDCKeys: f.OIDC,
460 JobArtifacts: jobArtifacts,473 JobArtifacts: jobArtifacts,
461 Artifacts: artifacts,474 Artifacts: artifacts,
475+ Releases: f.Releases,
462 Version: serverVersion(),476 Version: serverVersion(),
463 TrustedProxies: cfg.TrustedProxies,477 TrustedProxies: cfg.TrustedProxies,
464 Limiter: limiter,478 Limiter: limiter,
@@ -487,7 +501,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
487 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})501 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
488 }502 }
489 // The REST subset the actions call on github.api_url.503 // The REST subset the actions call on github.api_url.
490- api := githubapi.New(githubapi.Config{Auth: f.Auth, API: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter, MaxArchivesPerClient: opts.archivesPerClient})504+ releaseAPI := usecase.NewReleasesGitHub(f.Releases, cfg.Owner, cfg.PublicURL)
505+ api := githubapi.New(githubapi.Config{Auth: f.Auth, API: f.CI, Releases: releaseAPI, ReleaseDownload: f.Releases, TrustedProxies: cfg.TrustedProxies, Limiter: limiter, MaxArchivesPerClient: opts.archivesPerClient})
491 for _, p := range githubapi.Patterns {506 for _, p := range githubapi.Patterns {
492 routes = append(routes, httpserver.Route{Pattern: p, Handler: api})507 routes = append(routes, httpserver.Route{Pattern: p, Handler: api})
493 }508 }
@@ -593,16 +608,46 @@ func (f *forge) Close(ctx context.Context) {
593 }608 }
594 609
595 // artifacts gives the CI the stores and limits of the artifacts (CI-4 D3):610 // artifacts gives the CI the stores and limits of the artifacts (CI-4 D3):
596-// records in the kv store, contents under ci/artifacts. ERMITE_CI_ARTIFACTS=0611+// records in the kv store, legacy contents under ci/artifacts. ERMITE_CI_ARTIFACTS=0
597 // gives none, and the API then serves no artifact service.612 // gives none, and the API then serves no artifact service.
598 func artifacts(ci *usecase.CIConfig, cfg config.Config, store *kvstore.Store) {613 func artifacts(ci *usecase.CIConfig, cfg config.Config, store *kvstore.Store) {
599 if !cfg.CIArtifacts {614 if !cfg.CIArtifacts {
600 return615 return
601 }616 }
602- ci.Artifacts, ci.ArtifactFiles = store.Artifacts(), ciartifacts.New(ciartifacts.Dir(cfg.DataDir))617+ ci.Artifacts = store.Artifacts()
618+ if info, err := os.Stat(ciartifacts.Dir(cfg.DataDir)); err == nil && info.IsDir() {
619+ ci.ArtifactFiles = ciartifacts.New(ciartifacts.Dir(cfg.DataDir))
620+ }
603 ci.ArtifactLimits = ports.ArtifactLimits{Retention: cfg.CIArtifactRetention, Quota: cfg.CIArtifactQuota, MaxSize: cfg.CIArtifactMax}621 ci.ArtifactLimits = ports.ArtifactLimits{Retention: cfg.CIArtifactRetention, Quota: cfg.CIArtifactQuota, MaxSize: cfg.CIArtifactMax}
604 }622 }
605 623
624+// blobStore selects and probes the content backend before any engine starts.
625+func blobStore(ctx context.Context, cfg config.Config) (ports.BlobStore, error) {
626+ var blobs ports.BlobStore
627+ backend, location := "disk", blobdisk.Dir(cfg.DataDir)
628+ if cfg.S3Bucket == "" {
629+ blobs = blobdisk.New(location)
630+ } else {
631+ backend, location = "s3", cfg.S3Bucket
632+ store, err := blobs3.New(ctx, blobs3.Config{Bucket: cfg.S3Bucket, Prefix: cfg.S3Prefix, Endpoint: cfg.S3Endpoint, Region: cfg.S3Region, AccessKeyID: cfg.S3AccessKeyID, SecretAccessKey: cfg.S3SecretAccessKey, ForcePathStyle: cfg.S3ForcePathStyle})
633+ if err != nil {
634+ return nil, fmt.Errorf("%s: %w", config.EnvS3Bucket, err)
635+ }
636+ blobs = store
637+ }
638+ probe, cancel := context.WithTimeout(ctx, 30*time.Second)
639+ defer cancel()
640+ if err := blobs.Check(probe); err != nil {
641+ if backend == "s3" {
642+ return nil, fmt.Errorf("%s: %w", config.EnvS3Bucket, err)
643+ }
644+ return nil, fmt.Errorf("blob store: %w", err)
645+ }
646+ log := zerowrap.FromCtx(ctx)
647+ log.Info().Str("backend", backend).Str("location", location).Msg("blob store ready")
648+ return blobs, nil
649+}
650+
606 // pullRequests gives the CI the merge requests, the refs/pull refs and the651 // pullRequests gives the CI the merge requests, the refs/pull refs and the
607 // identity of the test merge commits (CI-3, D5).652 // identity of the test merge commits (CI-3, D5).
608 func (f *forge) pullRequests(cfg *usecase.CIConfig, store *kvstore.Store, git *gitcli.Git, committer domain.Identity) {653 func (f *forge) pullRequests(cfg *usecase.CIConfig, store *kvstore.Store, git *gitcli.Git, committer domain.Identity) {
A internal/app/release_e2e_test.go +160 -0
@@ -0,0 +1,160 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package app
3+
4+import (
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "net/http/httptest"
11+ "os"
12+ "path/filepath"
13+ "strings"
14+ "sync/atomic"
15+ "testing"
16+
17+ "bnema.dev/ermite/internal/adapters/blobdisk"
18+ "bnema.dev/ermite/internal/adapters/ciartifacts"
19+ "bnema.dev/ermite/internal/config"
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
22+ "bnema.dev/ermite/internal/usecase"
23+ "github.com/johannesboyne/gofakes3"
24+ "github.com/johannesboyne/gofakes3/backend/s3mem"
25+ "github.com/stretchr/testify/assert"
26+ "github.com/stretchr/testify/require"
27+)
28+
29+func TestReleaseBlobForge(t *testing.T) {
30+ for _, backend := range []string{"disk", "s3"} {
31+ t.Run(backend, func(t *testing.T) {
32+ env := map[string]string{}
33+ var admin atomic.Int64
34+ if backend == "s3" {
35+ mem := s3mem.New()
36+ require.NoError(t, mem.CreateBucket("assets"))
37+ fake := gofakes3.New(mem).Server()
38+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39+ // Allow only the startup probe/list and object/multipart subset. No bucket administration.
40+ bucket := r.URL.Path == "/assets" || r.URL.Path == "/assets/"
41+ allowed := true
42+ if bucket {
43+ allowed = r.Method == "HEAD" || (r.Method == "GET" && (r.URL.Query().Get("list-type") == "2" || r.URL.Query().Has("uploads")))
44+ }
45+ for _, key := range []string{"acl", "policy", "tagging", "lifecycle", "versioning", "cors", "notification", "encryption", "website", "logging"} {
46+ if r.URL.Query().Has(key) {
47+ allowed = false
48+ }
49+ }
50+ if !allowed {
51+ admin.Add(1)
52+ http.Error(w, "unexpected bucket operation", 500)
53+ return
54+ }
55+ fake.ServeHTTP(w, r)
56+ }))
57+ t.Cleanup(srv.Close)
58+ env = map[string]string{config.EnvS3Bucket: "assets", config.EnvS3Endpoint: srv.URL, config.EnvS3Region: "us-east-1", config.EnvS3AccessKeyID: "test-private-id", config.EnvS3SecretAccessKey: "test-private-secret", config.EnvS3ForcePathStyle: "1", config.EnvS3Prefix: "forge/"}
59+ }
60+ h := newHarness(t, harnessOpts{env: env})
61+ h.publicRepo("demo", "README.md")
62+ rel, err := h.forge.Releases.CreateRelease(h.ctx, domain.Root(), h.name("demo"), ports.CreateReleaseParams{Tag: "v1", TargetCommitish: "main", Title: "Version one"})
63+ require.NoError(t, err)
64+ asset, err := h.forge.Releases.UploadAsset(h.ctx, domain.Root(), h.name("demo"), ports.UploadAssetParams{ReleaseID: rel.ID, Name: "demo.bin", Size: 7}, strings.NewReader("payload"))
65+ require.NoError(t, err)
66+ if backend == "disk" {
67+ data, err := os.ReadFile(filepath.Join(blobdisk.Dir(h.data), "objects", filepath.FromSlash(asset.BlobKey())))
68+ require.NoError(t, err)
69+ assert.Equal(t, "payload", string(data))
70+ }
71+ req, err := http.NewRequestWithContext(h.ctx, "GET", h.url(fmt.Sprintf("/-/github/api/repos/owner/demo/releases/assets/%d", asset.ID)), nil)
72+ require.NoError(t, err)
73+ req.Header.Set("Accept", "application/octet-stream")
74+ req.Header.Set("Range", "bytes=1-3")
75+ res, err := h.srv.Client().Do(req)
76+ require.NoError(t, err)
77+ body, err := io.ReadAll(res.Body)
78+ require.NoError(t, err)
79+ _ = res.Body.Close()
80+ assert.Equal(t, 206, res.StatusCode)
81+ assert.Equal(t, "ayl", string(body))
82+ assert.Empty(t, res.Header.Get("Location"))
83+ res, err = h.srv.Client().Get(h.url(fmt.Sprintf("/-/github/api/repos/owner/demo/releases/%d", rel.ID)))
84+ require.NoError(t, err)
85+ var obj map[string]any
86+ require.NoError(t, json.NewDecoder(res.Body).Decode(&obj))
87+ _ = res.Body.Close()
88+ assert.Equal(t, 200, res.StatusCode)
89+ assert.Contains(t, obj["upload_url"], "http://example.test/-/github/api/uploads/")
90+ // New artifacts use the same production backend, not a test-local blob store.
91+ wt := h.workTree("main")
92+ h.mustGit(wt, "fetch", "-q", h.urlAuth(adminToken, "/demo.git"), "main")
93+ h.mustGit(wt, "reset", "--hard", "FETCH_HEAD")
94+ c := &ciHarness{harness: h, root: h.api(adminToken), repo: "demo", wt: wt}
95+ runner := c.registerRunner("alpha", "ubuntu-latest")
96+ _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
97+ up := c.jobArtifacts(job)
98+ a := up.upload("dist", []byte("artifact bytes"))
99+ _, data, err := up.download(a.GetId(), 0)
100+ require.NoError(t, err)
101+ assert.Equal(t, "artifact bytes", string(data))
102+ record, err := h.forge.store.Artifacts().GetArtifact(h.ctx, a.GetId())
103+ require.NoError(t, err)
104+ assert.Equal(t, domain.CIArtifactStoreBlob, record.Store)
105+ _, err = h.forge.Blobs.Stat(h.ctx, record.BlobKey())
106+ require.NoError(t, err)
107+ assert.Contains(t, h.logs.String(), "blob store ready")
108+ assert.NotContains(t, h.logs.String(), "test-private-")
109+ assert.Zero(t, admin.Load(), "no bucket-admin operations")
110+ })
111+ }
112+}
113+
114+func TestBlobStartupProbe(t *testing.T) {
115+ _, err := blobStore(context.Background(), config.Config{S3Bucket: "missing", S3Region: "us-east-1"})
116+ require.Error(t, err)
117+ assert.Contains(t, err.Error(), config.EnvS3Bucket)
118+ mem := s3mem.New()
119+ srv := httptest.NewServer(gofakes3.New(mem).Server())
120+ defer srv.Close()
121+ _, err = blobStore(context.Background(), config.Config{S3Bucket: "missing", S3Endpoint: srv.URL, S3Region: "us-east-1", S3AccessKeyID: "private-id", S3SecretAccessKey: "private-secret", S3ForcePathStyle: true})
122+ require.Error(t, err)
123+ assert.Contains(t, err.Error(), "ERMITE_S3_BUCKET")
124+ assert.Contains(t, err.Error(), "not reachable")
125+ assert.NotContains(t, err.Error(), "private-")
126+}
127+
128+func TestArtifactLegacyProductionWiring(t *testing.T) {
129+ // Seed the old tree before forge startup, exactly as existing installations do.
130+ data, err := os.MkdirTemp("", "ermite-legacy")
131+ require.NoError(t, err)
132+ t.Cleanup(func() { _ = os.RemoveAll(data) })
133+ require.NoError(t, prepareDataDir(data))
134+ require.NoError(t, os.MkdirAll(ciartifacts.Dir(data), 0700))
135+ h := newHarness(t, harnessOpts{env: map[string]string{config.EnvDataDir: data}})
136+ c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj", wt: h.workTree("main")}
137+ _, err = h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate})
138+ require.NoError(t, err)
139+ runner := c.registerRunner("alpha", "ubuntu-latest")
140+ _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
141+ up := c.jobArtifacts(job)
142+ created := up.upload("old", []byte("legacy bytes"))
143+ store := h.forge.store.Artifacts()
144+ a, err := store.GetArtifact(h.ctx, created.GetId())
145+ require.NoError(t, err)
146+ path := filepath.Join(ciartifacts.Dir(data), fmt.Sprintf("%020d", a.RunID), fmt.Sprintf("%020d", a.ID))
147+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700))
148+ require.NoError(t, os.WriteFile(path, []byte("legacy bytes"), 0600))
149+ require.NoError(t, h.forge.Blobs.Delete(h.ctx, a.BlobKey()))
150+ a.Store = domain.CIArtifactStoreLegacy
151+ used, version, err := store.ArtifactUsage(h.ctx, a.Repo)
152+ require.NoError(t, err)
153+ require.NoError(t, store.ApplyArtifacts(h.ctx, ports.CIArtifactWrite{Repo: a.Repo, Used: used, UsageVersion: version, Put: []domain.CIArtifact{a}}))
154+ _, body, err := up.download(a.ID, 0)
155+ require.NoError(t, err)
156+ assert.Equal(t, "legacy bytes", string(body))
157+ // A fresh install does not need or create the legacy tree.
158+ fresh := newHarnessWith(t, harnessOpts{}, func(o *options) { o.ci = func(cfg *usecase.CIConfig) { assert.Nil(t, cfg.ArtifactFiles) } })
159+ assert.NoDirExists(t, ciartifacts.Dir(fresh.data))
160+}
@@ -0,0 +1,160 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package app
3+
4+import (
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "net/http/httptest"
11+ "os"
12+ "path/filepath"
13+ "strings"
14+ "sync/atomic"
15+ "testing"
16+
17+ "bnema.dev/ermite/internal/adapters/blobdisk"
18+ "bnema.dev/ermite/internal/adapters/ciartifacts"
19+ "bnema.dev/ermite/internal/config"
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
22+ "bnema.dev/ermite/internal/usecase"
23+ "github.com/johannesboyne/gofakes3"
24+ "github.com/johannesboyne/gofakes3/backend/s3mem"
25+ "github.com/stretchr/testify/assert"
26+ "github.com/stretchr/testify/require"
27+)
28+
29+func TestReleaseBlobForge(t *testing.T) {
30+ for _, backend := range []string{"disk", "s3"} {
31+ t.Run(backend, func(t *testing.T) {
32+ env := map[string]string{}
33+ var admin atomic.Int64
34+ if backend == "s3" {
35+ mem := s3mem.New()
36+ require.NoError(t, mem.CreateBucket("assets"))
37+ fake := gofakes3.New(mem).Server()
38+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39+ // Allow only the startup probe/list and object/multipart subset. No bucket administration.
40+ bucket := r.URL.Path == "/assets" || r.URL.Path == "/assets/"
41+ allowed := true
42+ if bucket {
43+ allowed = r.Method == "HEAD" || (r.Method == "GET" && (r.URL.Query().Get("list-type") == "2" || r.URL.Query().Has("uploads")))
44+ }
45+ for _, key := range []string{"acl", "policy", "tagging", "lifecycle", "versioning", "cors", "notification", "encryption", "website", "logging"} {
46+ if r.URL.Query().Has(key) {
47+ allowed = false
48+ }
49+ }
50+ if !allowed {
51+ admin.Add(1)
52+ http.Error(w, "unexpected bucket operation", 500)
53+ return
54+ }
55+ fake.ServeHTTP(w, r)
56+ }))
57+ t.Cleanup(srv.Close)
58+ env = map[string]string{config.EnvS3Bucket: "assets", config.EnvS3Endpoint: srv.URL, config.EnvS3Region: "us-east-1", config.EnvS3AccessKeyID: "test-private-id", config.EnvS3SecretAccessKey: "test-private-secret", config.EnvS3ForcePathStyle: "1", config.EnvS3Prefix: "forge/"}
59+ }
60+ h := newHarness(t, harnessOpts{env: env})
61+ h.publicRepo("demo", "README.md")
62+ rel, err := h.forge.Releases.CreateRelease(h.ctx, domain.Root(), h.name("demo"), ports.CreateReleaseParams{Tag: "v1", TargetCommitish: "main", Title: "Version one"})
63+ require.NoError(t, err)
64+ asset, err := h.forge.Releases.UploadAsset(h.ctx, domain.Root(), h.name("demo"), ports.UploadAssetParams{ReleaseID: rel.ID, Name: "demo.bin", Size: 7}, strings.NewReader("payload"))
65+ require.NoError(t, err)
66+ if backend == "disk" {
67+ data, err := os.ReadFile(filepath.Join(blobdisk.Dir(h.data), "objects", filepath.FromSlash(asset.BlobKey())))
68+ require.NoError(t, err)
69+ assert.Equal(t, "payload", string(data))
70+ }
71+ req, err := http.NewRequestWithContext(h.ctx, "GET", h.url(fmt.Sprintf("/-/github/api/repos/owner/demo/releases/assets/%d", asset.ID)), nil)
72+ require.NoError(t, err)
73+ req.Header.Set("Accept", "application/octet-stream")
74+ req.Header.Set("Range", "bytes=1-3")
75+ res, err := h.srv.Client().Do(req)
76+ require.NoError(t, err)
77+ body, err := io.ReadAll(res.Body)
78+ require.NoError(t, err)
79+ _ = res.Body.Close()
80+ assert.Equal(t, 206, res.StatusCode)
81+ assert.Equal(t, "ayl", string(body))
82+ assert.Empty(t, res.Header.Get("Location"))
83+ res, err = h.srv.Client().Get(h.url(fmt.Sprintf("/-/github/api/repos/owner/demo/releases/%d", rel.ID)))
84+ require.NoError(t, err)
85+ var obj map[string]any
86+ require.NoError(t, json.NewDecoder(res.Body).Decode(&obj))
87+ _ = res.Body.Close()
88+ assert.Equal(t, 200, res.StatusCode)
89+ assert.Contains(t, obj["upload_url"], "http://example.test/-/github/api/uploads/")
90+ // New artifacts use the same production backend, not a test-local blob store.
91+ wt := h.workTree("main")
92+ h.mustGit(wt, "fetch", "-q", h.urlAuth(adminToken, "/demo.git"), "main")
93+ h.mustGit(wt, "reset", "--hard", "FETCH_HEAD")
94+ c := &ciHarness{harness: h, root: h.api(adminToken), repo: "demo", wt: wt}
95+ runner := c.registerRunner("alpha", "ubuntu-latest")
96+ _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
97+ up := c.jobArtifacts(job)
98+ a := up.upload("dist", []byte("artifact bytes"))
99+ _, data, err := up.download(a.GetId(), 0)
100+ require.NoError(t, err)
101+ assert.Equal(t, "artifact bytes", string(data))
102+ record, err := h.forge.store.Artifacts().GetArtifact(h.ctx, a.GetId())
103+ require.NoError(t, err)
104+ assert.Equal(t, domain.CIArtifactStoreBlob, record.Store)
105+ _, err = h.forge.Blobs.Stat(h.ctx, record.BlobKey())
106+ require.NoError(t, err)
107+ assert.Contains(t, h.logs.String(), "blob store ready")
108+ assert.NotContains(t, h.logs.String(), "test-private-")
109+ assert.Zero(t, admin.Load(), "no bucket-admin operations")
110+ })
111+ }
112+}
113+
114+func TestBlobStartupProbe(t *testing.T) {
115+ _, err := blobStore(context.Background(), config.Config{S3Bucket: "missing", S3Region: "us-east-1"})
116+ require.Error(t, err)
117+ assert.Contains(t, err.Error(), config.EnvS3Bucket)
118+ mem := s3mem.New()
119+ srv := httptest.NewServer(gofakes3.New(mem).Server())
120+ defer srv.Close()
121+ _, err = blobStore(context.Background(), config.Config{S3Bucket: "missing", S3Endpoint: srv.URL, S3Region: "us-east-1", S3AccessKeyID: "private-id", S3SecretAccessKey: "private-secret", S3ForcePathStyle: true})
122+ require.Error(t, err)
123+ assert.Contains(t, err.Error(), "ERMITE_S3_BUCKET")
124+ assert.Contains(t, err.Error(), "not reachable")
125+ assert.NotContains(t, err.Error(), "private-")
126+}
127+
128+func TestArtifactLegacyProductionWiring(t *testing.T) {
129+ // Seed the old tree before forge startup, exactly as existing installations do.
130+ data, err := os.MkdirTemp("", "ermite-legacy")
131+ require.NoError(t, err)
132+ t.Cleanup(func() { _ = os.RemoveAll(data) })
133+ require.NoError(t, prepareDataDir(data))
134+ require.NoError(t, os.MkdirAll(ciartifacts.Dir(data), 0700))
135+ h := newHarness(t, harnessOpts{env: map[string]string{config.EnvDataDir: data}})
136+ c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj", wt: h.workTree("main")}
137+ _, err = h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate})
138+ require.NoError(t, err)
139+ runner := c.registerRunner("alpha", "ubuntu-latest")
140+ _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
141+ up := c.jobArtifacts(job)
142+ created := up.upload("old", []byte("legacy bytes"))
143+ store := h.forge.store.Artifacts()
144+ a, err := store.GetArtifact(h.ctx, created.GetId())
145+ require.NoError(t, err)
146+ path := filepath.Join(ciartifacts.Dir(data), fmt.Sprintf("%020d", a.RunID), fmt.Sprintf("%020d", a.ID))
147+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700))
148+ require.NoError(t, os.WriteFile(path, []byte("legacy bytes"), 0600))
149+ require.NoError(t, h.forge.Blobs.Delete(h.ctx, a.BlobKey()))
150+ a.Store = domain.CIArtifactStoreLegacy
151+ used, version, err := store.ArtifactUsage(h.ctx, a.Repo)
152+ require.NoError(t, err)
153+ require.NoError(t, store.ApplyArtifacts(h.ctx, ports.CIArtifactWrite{Repo: a.Repo, Used: used, UsageVersion: version, Put: []domain.CIArtifact{a}}))
154+ _, body, err := up.download(a.ID, 0)
155+ require.NoError(t, err)
156+ assert.Equal(t, "legacy bytes", string(body))
157+ // A fresh install does not need or create the legacy tree.
158+ fresh := newHarnessWith(t, harnessOpts{}, func(o *options) { o.ci = func(cfg *usecase.CIConfig) { assert.Nil(t, cfg.ArtifactFiles) } })
159+ assert.NoDirExists(t, ciartifacts.Dir(fresh.data))
160+}
M internal/app/root.go +1 -1
@@ -31,7 +31,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
3131 root.AddCommand(
3232 newServeCmd(), newHookCmd(),
3333 newMirrorCmd(env), newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
34- newMRCmd(env), newIssueCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),
34+ newMRCmd(env), newIssueCmd(env), newReleaseCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),
3535 newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env),
3636 )
3737 return root
@@ -31,7 +31,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
31 root.AddCommand(31 root.AddCommand(
32 newServeCmd(), newHookCmd(),32 newServeCmd(), newHookCmd(),
33 newMirrorCmd(env), newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),33 newMirrorCmd(env), newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
34- newMRCmd(env), newIssueCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),34+ newMRCmd(env), newIssueCmd(env), newReleaseCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),
35 newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env),35 newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env),
36 )36 )
37 return root37 return root
M internal/app/testdata/web/blob-binary.html +15 -5
@@ -93,6 +93,10 @@
9393 </a>
9494 </li>
9595 <li>
96+<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
97+</a>
98+</li>
99+<li>
96100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97101 </a>
98102 </li>
@@ -358,31 +362,37 @@
358362 <span class="k">section</span>
359363 </a>
360364 </li>
361-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
365+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
366+<a href="/files/releases" tabindex="-1">
367+<span class="lbl">releases</span>
368+<span class="k">section</span>
369+</a>
370+</li>
371+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
362372 <a href="/files/actions" tabindex="-1">
363373 <span class="lbl">actions</span>
364374 <span class="k">section</span>
365375 </a>
366376 </li>
367-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
377+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
368378 <a href="/files/tree/feature/x" tabindex="-1">
369379 <span class="lbl">feature/x</span>
370380 <span class="k">branch</span>
371381 </a>
372382 </li>
373-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
383+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
374384 <a href="/files/tree/main" tabindex="-1">
375385 <span class="lbl">main</span>
376386 <span class="k">branch</span>
377387 </a>
378388 </li>
379-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
389+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">
380390 <a href="/files/tree/v1.0" tabindex="-1">
381391 <span class="lbl">v1.0</span>
382392 <span class="k">tag</span>
383393 </a>
384394 </li>
385-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
395+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
386396 <a href="#" data-fire="keys" tabindex="-1">
387397 <span class="lbl">turn single-key shortcuts off</span>
388398 <span class="k">action</span>
@@ -93,6 +93,10 @@
93 </a>93 </a>
94 </li>94 </li>
95 <li>95 <li>
96+<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
97+</a>
98+</li>
99+<li>
96 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97 </a>101 </a>
98 </li>102 </li>
@@ -358,31 +362,37 @@
358 <span class="k">section</span>362 <span class="k">section</span>
359 </a>363 </a>
360 </li>364 </li>
361-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">365+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
366+<a href="/files/releases" tabindex="-1">
367+<span class="lbl">releases</span>
368+<span class="k">section</span>
369+</a>
370+</li>
371+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
362 <a href="/files/actions" tabindex="-1">372 <a href="/files/actions" tabindex="-1">
363 <span class="lbl">actions</span>373 <span class="lbl">actions</span>
364 <span class="k">section</span>374 <span class="k">section</span>
365 </a>375 </a>
366 </li>376 </li>
367-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">377+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
368 <a href="/files/tree/feature/x" tabindex="-1">378 <a href="/files/tree/feature/x" tabindex="-1">
369 <span class="lbl">feature/x</span>379 <span class="lbl">feature/x</span>
370 <span class="k">branch</span>380 <span class="k">branch</span>
371 </a>381 </a>
372 </li>382 </li>
373-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">383+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
374 <a href="/files/tree/main" tabindex="-1">384 <a href="/files/tree/main" tabindex="-1">
375 <span class="lbl">main</span>385 <span class="lbl">main</span>
376 <span class="k">branch</span>386 <span class="k">branch</span>
377 </a>387 </a>
378 </li>388 </li>
379-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">389+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">
380 <a href="/files/tree/v1.0" tabindex="-1">390 <a href="/files/tree/v1.0" tabindex="-1">
381 <span class="lbl">v1.0</span>391 <span class="lbl">v1.0</span>
382 <span class="k">tag</span>392 <span class="k">tag</span>
383 </a>393 </a>
384 </li>394 </li>
385-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">395+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
386 <a href="#" data-fire="keys" tabindex="-1">396 <a href="#" data-fire="keys" tabindex="-1">
387 <span class="lbl">turn single-key shortcuts off</span>397 <span class="lbl">turn single-key shortcuts off</span>
388 <span class="k">action</span>398 <span class="k">action</span>
M internal/app/testdata/web/blob-code.html +15 -5
@@ -93,6 +93,10 @@
9393 </a>
9494 </li>
9595 <li>
96+<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
97+</a>
98+</li>
99+<li>
96100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97101 </a>
98102 </li>
@@ -428,31 +432,37 @@
428432 <span class="k">section</span>
429433 </a>
430434 </li>
431-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
435+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
436+<a href="/files/releases" tabindex="-1">
437+<span class="lbl">releases</span>
438+<span class="k">section</span>
439+</a>
440+</li>
441+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
432442 <a href="/files/actions" tabindex="-1">
433443 <span class="lbl">actions</span>
434444 <span class="k">section</span>
435445 </a>
436446 </li>
437-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
447+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
438448 <a href="/files/tree/feature/x" tabindex="-1">
439449 <span class="lbl">feature/x</span>
440450 <span class="k">branch</span>
441451 </a>
442452 </li>
443-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
453+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
444454 <a href="/files/tree/main" tabindex="-1">
445455 <span class="lbl">main</span>
446456 <span class="k">branch</span>
447457 </a>
448458 </li>
449-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
459+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">
450460 <a href="/files/tree/v1.0" tabindex="-1">
451461 <span class="lbl">v1.0</span>
452462 <span class="k">tag</span>
453463 </a>
454464 </li>
455-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
465+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
456466 <a href="#" data-fire="keys" tabindex="-1">
457467 <span class="lbl">turn single-key shortcuts off</span>
458468 <span class="k">action</span>
@@ -93,6 +93,10 @@
93 </a>93 </a>
94 </li>94 </li>
95 <li>95 <li>
96+<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
97+</a>
98+</li>
99+<li>
96 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97 </a>101 </a>
98 </li>102 </li>
@@ -428,31 +432,37 @@
428 <span class="k">section</span>432 <span class="k">section</span>
429 </a>433 </a>
430 </li>434 </li>
431-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">435+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
436+<a href="/files/releases" tabindex="-1">
437+<span class="lbl">releases</span>
438+<span class="k">section</span>
439+</a>
440+</li>
441+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
432 <a href="/files/actions" tabindex="-1">442 <a href="/files/actions" tabindex="-1">
433 <span class="lbl">actions</span>443 <span class="lbl">actions</span>
434 <span class="k">section</span>444 <span class="k">section</span>
435 </a>445 </a>
436 </li>446 </li>
437-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">447+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
438 <a href="/files/tree/feature/x" tabindex="-1">448 <a href="/files/tree/feature/x" tabindex="-1">
439 <span class="lbl">feature/x</span>449 <span class="lbl">feature/x</span>
440 <span class="k">branch</span>450 <span class="k">branch</span>
441 </a>451 </a>
442 </li>452 </li>
443-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">453+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
444 <a href="/files/tree/main" tabindex="-1">454 <a href="/files/tree/main" tabindex="-1">
445 <span class="lbl">main</span>455 <span class="lbl">main</span>
446 <span class="k">branch</span>456 <span class="k">branch</span>
447 </a>457 </a>
448 </li>458 </li>
449-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">459+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">
450 <a href="/files/tree/v1.0" tabindex="-1">460 <a href="/files/tree/v1.0" tabindex="-1">
451 <span class="lbl">v1.0</span>461 <span class="lbl">v1.0</span>
452 <span class="k">tag</span>462 <span class="k">tag</span>
453 </a>463 </a>
454 </li>464 </li>
455-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">465+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
456 <a href="#" data-fire="keys" tabindex="-1">466 <a href="#" data-fire="keys" tabindex="-1">
457 <span class="lbl">turn single-key shortcuts off</span>467 <span class="lbl">turn single-key shortcuts off</span>
458 <span class="k">action</span>468 <span class="k">action</span>
M internal/app/testdata/web/blob-empty.html +15 -5
@@ -93,6 +93,10 @@
9393 </a>
9494 </li>
9595 <li>
96+<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
97+</a>
98+</li>
99+<li>
96100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97101 </a>
98102 </li>
@@ -358,31 +362,37 @@
358362 <span class="k">section</span>
359363 </a>
360364 </li>
361-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
365+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
366+<a href="/files/releases" tabindex="-1">
367+<span class="lbl">releases</span>
368+<span class="k">section</span>
369+</a>
370+</li>
371+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
362372 <a href="/files/actions" tabindex="-1">
363373 <span class="lbl">actions</span>
364374 <span class="k">section</span>
365375 </a>
366376 </li>
367-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
377+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
368378 <a href="/files/tree/feature/x" tabindex="-1">
369379 <span class="lbl">feature/x</span>
370380 <span class="k">branch</span>
371381 </a>
372382 </li>
373-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
383+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
374384 <a href="/files/tree/main" tabindex="-1">
375385 <span class="lbl">main</span>
376386 <span class="k">branch</span>
377387 </a>
378388 </li>
379-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
389+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">
380390 <a href="/files/tree/v1.0" tabindex="-1">
381391 <span class="lbl">v1.0</span>
382392 <span class="k">tag</span>
383393 </a>
384394 </li>
385-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
395+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
386396 <a href="#" data-fire="keys" tabindex="-1">
387397 <span class="lbl">turn single-key shortcuts off</span>
388398 <span class="k">action</span>
@@ -93,6 +93,10 @@
93 </a>93 </a>
94 </li>94 </li>
95 <li>95 <li>
96+<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
97+</a>
98+</li>
99+<li>
96 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97 </a>101 </a>
98 </li>102 </li>
@@ -358,31 +362,37 @@
358 <span class="k">section</span>362 <span class="k">section</span>
359 </a>363 </a>
360 </li>364 </li>
361-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">365+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
366+<a href="/files/releases" tabindex="-1">
367+<span class="lbl">releases</span>
368+<span class="k">section</span>
369+</a>
370+</li>
371+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
362 <a href="/files/actions" tabindex="-1">372 <a href="/files/actions" tabindex="-1">
363 <span class="lbl">actions</span>373 <span class="lbl">actions</span>
364 <span class="k">section</span>374 <span class="k">section</span>
365 </a>375 </a>
366 </li>376 </li>
367-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">377+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
368 <a href="/files/tree/feature/x" tabindex="-1">378 <a href="/files/tree/feature/x" tabindex="-1">
369 <span class="lbl">feature/x</span>379 <span class="lbl">feature/x</span>
370 <span class="k">branch</span>380 <span class="k">branch</span>
371 </a>381 </a>
372 </li>382 </li>
373-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">383+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
374 <a href="/files/tree/main" tabindex="-1">384 <a href="/files/tree/main" tabindex="-1">
375 <span class="lbl">main</span>385 <span class="lbl">main</span>
376 <span class="k">branch</span>386 <span class="k">branch</span>
377 </a>387 </a>
378 </li>388 </li>
379-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">389+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">
380 <a href="/files/tree/v1.0" tabindex="-1">390 <a href="/files/tree/v1.0" tabindex="-1">
381 <span class="lbl">v1.0</span>391 <span class="lbl">v1.0</span>
382 <span class="k">tag</span>392 <span class="k">tag</span>
383 </a>393 </a>
384 </li>394 </li>
385-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">395+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
386 <a href="#" data-fire="keys" tabindex="-1">396 <a href="#" data-fire="keys" tabindex="-1">
387 <span class="lbl">turn single-key shortcuts off</span>397 <span class="lbl">turn single-key shortcuts off</span>
388 <span class="k">action</span>398 <span class="k">action</span>
M internal/app/testdata/web/blob-image.html +15 -5
@@ -93,6 +93,10 @@
9393 </a>
9494 </li>
9595 <li>
96+<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
97+</a>
98+</li>
99+<li>
96100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97101 </a>
98102 </li>
@@ -360,31 +364,37 @@
360364 <span class="k">section</span>
361365 </a>
362366 </li>
363-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
367+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
368+<a href="/files/releases" tabindex="-1">
369+<span class="lbl">releases</span>
370+<span class="k">section</span>
371+</a>
372+</li>
373+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
364374 <a href="/files/actions" tabindex="-1">
365375 <span class="lbl">actions</span>
366376 <span class="k">section</span>
367377 </a>
368378 </li>
369-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
379+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
370380 <a href="/files/tree/feature/x" tabindex="-1">
371381 <span class="lbl">feature/x</span>
372382 <span class="k">branch</span>
373383 </a>
374384 </li>
375-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
385+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
376386 <a href="/files/tree/main" tabindex="-1">
377387 <span class="lbl">main</span>
378388 <span class="k">branch</span>
379389 </a>
380390 </li>
381-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
391+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">
382392 <a href="/files/tree/v1.0" tabindex="-1">
383393 <span class="lbl">v1.0</span>
384394 <span class="k">tag</span>
385395 </a>
386396 </li>
387-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
397+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
388398 <a href="#" data-fire="keys" tabindex="-1">
389399 <span class="lbl">turn single-key shortcuts off</span>
390400 <span class="k">action</span>
@@ -93,6 +93,10 @@
93 </a>93 </a>
94 </li>94 </li>
95 <li>95 <li>
96+<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
97+</a>
98+</li>
99+<li>
96 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>100 <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97 </a>101 </a>
98 </li>102 </li>
@@ -360,31 +364,37 @@
360 <span class="k">section</span>364 <span class="k">section</span>
361 </a>365 </a>
362 </li>366 </li>
363-<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">367+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
368+<a href="/files/releases" tabindex="-1">
369+<span class="lbl">releases</span>
370+<span class="k">section</span>
371+</a>
372+</li>
373+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
364 <a href="/files/actions" tabindex="-1">374 <a href="/files/actions" tabindex="-1">
365 <span class="lbl">actions</span>375 <span class="lbl">actions</span>
366 <span class="k">section</span>376 <span class="k">section</span>
367 </a>377 </a>
368 </li>378 </li>
369-<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">379+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x">
370 <a href="/files/tree/feature/x" tabindex="-1">380 <a href="/files/tree/feature/x" tabindex="-1">
371 <span class="lbl">feature/x</span>381 <span class="lbl">feature/x</span>
372 <span class="k">branch</span>382 <span class="k">branch</span>
373 </a>383 </a>
374 </li>384 </li>
375-<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">385+<li id="cmd-o9" role="option" aria-selected="false" data-k="branch main">
376 <a href="/files/tree/main" tabindex="-1">386 <a href="/files/tree/main" tabindex="-1">
377 <span class="lbl">main</span>387 <span class="lbl">main</span>
378 <span class="k">branch</span>388 <span class="k">branch</span>
379 </a>389 </a>
380 </li>390 </li>
381-<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">391+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0">
382 <a href="/files/tree/v1.0" tabindex="-1">392 <a href="/files/tree/v1.0" tabindex="-1">
383 <span class="lbl">v1.0</span>393 <span class="lbl">v1.0</span>
384 <span class="k">tag</span>394 <span class="k">tag</span>
385 </a>395 </a>
386 </li>396 </li>
387-<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">397+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
388 <a href="#" data-fire="keys" tabindex="-1">398 <a href="#" data-fire="keys" tabindex="-1">
389 <span class="lbl">turn single-key shortcuts off</span>399 <span class="lbl">turn single-key shortcuts off</span>
390 <span class="k">action</span>400 <span class="k">action</span>
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