detached 0933a48

feat(cli): ermite release commands and release:write in the cli preset

commit
0933a4824f5eaeb869c983ad0bb8650e20bb7121
author
brice · · 2026-10-21 15:16 UTC
committer
brice · · 2026-10-21 17:53 UTC
parent
f67dc6a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +1221 -11
M docs/cli.md +45 -0
@@ -96,6 +96,51 @@ Issues need a server with the `issues` feature and `ermite repo edit <repo> --is
9696
9797 Mirrors push repositories to a remote over SSH, never forcing. See [Push mirror](mirror.md).
9898
99+## Releases
100+
101+Releases need a server advertising the `releases` feature. Published releases follow
102+repository visibility; drafts are visible only to release writers. Writes need
103+`release:write` (included in the `cli` token preset); branch-limited tokens cannot
104+write releases. On an older server the preset drops this scope with a note, while
105+an explicit `--scope release:write` fails before creating a token.
106+
107+```sh
108+ermite release create <repo> <tag> [--title T] [--notes TEXT | --notes-file F] [--target REF] [--draft] [--prerelease] [--latest] [--asset FILE[#LABEL]]… [--json]
109+ermite release list <repo> [--limit N] [--json]
110+ermite release view <repo> <tag|id|latest> [--json | --notes]
111+ermite release edit <repo> <tag|id> [--title T] [--notes TEXT | --notes-file F] [--tag NEW] [--target REF] [--draft | --publish] [--prerelease | --no-prerelease] [--latest]
112+ermite release delete <repo> <tag|id> [--yes]
113+ermite release upload <repo> <tag|id> FILE[#LABEL]… [--clobber] [--content-type T]
114+ermite release download <repo> <tag|id|latest> [--pattern GLOB]… [--name N]… [--dir D]
115+```
116+
117+All-digit selectors are release ids; other selectors are tags. `latest` selects
118+the latest release for view/download. `--latest` pins a release as latest.
119+Non-draft releases require an existing tag, or `--target` to create one; an
120+existing tag is never moved. `--notes-file -` reads Markdown from standard input.
121+Edit changes only supplied fields: `--title=''` and `--notes=''` clear them;
122+`--publish` and `--no-prerelease` clear the corresponding flags.
123+
124+Create uploads all `--asset` files, then prints the release web URL and id.
125+Each asset's name is the file basename; an optional `#LABEL` sets its display
126+label (quote it). Upload accepts multiple file arguments, including shell-expanded
127+globs. For create, repeat `--asset` for each file. Content type is detected from
128+the extension, or sniffed from the first bytes; upload's `--content-type` overrides
129+it. A duplicate name fails with a hint to use `--clobber`. A failed upload after
130+create leaves the release in place and reports its id.
131+
132+List defaults to 30 entries and follows server pagination up to `--limit`.
133+View shows fields, notes and an asset table; `--notes` prints Markdown alone.
134+Create, list and view support stable JSON (fields are listed in their help).
135+Delete removes the release and assets, not its Git tag; it asks for confirmation
136+only on a terminal unless `--yes` is given.
137+
138+Download defaults to all assets, into `--dir` (default `.`). Repeat `--name` or
139+quote and repeat `--pattern` to select a union of names and globs. Files are never
140+overwritten. One interrupted stream is resumed automatically from the bytes
141+already received; size and SHA-256 digest are verified. Failed or mismatching
142+downloads are removed. Resume is within the command, not across invocations.
143+
99144 ## Output
100145
101146 - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`.
@@ -96,6 +96,51 @@ Issues need a server with the `issues` feature and `ermite repo edit <repo> --is
96 96
97 Mirrors push repositories to a remote over SSH, never forcing. See [Push mirror](mirror.md).97 Mirrors push repositories to a remote over SSH, never forcing. See [Push mirror](mirror.md).
98 98
99+## Releases
100+
101+Releases need a server advertising the `releases` feature. Published releases follow
102+repository visibility; drafts are visible only to release writers. Writes need
103+`release:write` (included in the `cli` token preset); branch-limited tokens cannot
104+write releases. On an older server the preset drops this scope with a note, while
105+an explicit `--scope release:write` fails before creating a token.
106+
107+```sh
108+ermite release create <repo> <tag> [--title T] [--notes TEXT | --notes-file F] [--target REF] [--draft] [--prerelease] [--latest] [--asset FILE[#LABEL]]… [--json]
109+ermite release list <repo> [--limit N] [--json]
110+ermite release view <repo> <tag|id|latest> [--json | --notes]
111+ermite release edit <repo> <tag|id> [--title T] [--notes TEXT | --notes-file F] [--tag NEW] [--target REF] [--draft | --publish] [--prerelease | --no-prerelease] [--latest]
112+ermite release delete <repo> <tag|id> [--yes]
113+ermite release upload <repo> <tag|id> FILE[#LABEL]… [--clobber] [--content-type T]
114+ermite release download <repo> <tag|id|latest> [--pattern GLOB]… [--name N]… [--dir D]
115+```
116+
117+All-digit selectors are release ids; other selectors are tags. `latest` selects
118+the latest release for view/download. `--latest` pins a release as latest.
119+Non-draft releases require an existing tag, or `--target` to create one; an
120+existing tag is never moved. `--notes-file -` reads Markdown from standard input.
121+Edit changes only supplied fields: `--title=''` and `--notes=''` clear them;
122+`--publish` and `--no-prerelease` clear the corresponding flags.
123+
124+Create uploads all `--asset` files, then prints the release web URL and id.
125+Each asset's name is the file basename; an optional `#LABEL` sets its display
126+label (quote it). Upload accepts multiple file arguments, including shell-expanded
127+globs. For create, repeat `--asset` for each file. Content type is detected from
128+the extension, or sniffed from the first bytes; upload's `--content-type` overrides
129+it. A duplicate name fails with a hint to use `--clobber`. A failed upload after
130+create leaves the release in place and reports its id.
131+
132+List defaults to 30 entries and follows server pagination up to `--limit`.
133+View shows fields, notes and an asset table; `--notes` prints Markdown alone.
134+Create, list and view support stable JSON (fields are listed in their help).
135+Delete removes the release and assets, not its Git tag; it asks for confirmation
136+only on a terminal unless `--yes` is given.
137+
138+Download defaults to all assets, into `--dir` (default `.`). Repeat `--name` or
139+quote and repeat `--pattern` to select a union of names and globs. Files are never
140+overwritten. One interrupted stream is resumed automatically from the bytes
141+already received; size and SHA-256 digest are verified. Failed or mismatching
142+downloads are removed. Resume is within the command, not across invocations.
143+
99 ## Output144 ## Output
100 145
101 - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`.146 - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`.
M internal/adapters/client/client.go +5 -0
@@ -80,6 +80,11 @@ func (c *Client) Artifacts() ermiteconnect.ArtifactServiceClient {
8080 return ermiteconnect.NewArtifactServiceClient(c.http, c.base, c.opts...)
8181 }
8282
83+// Releases returns the ReleaseService client.
84+func (c *Client) Releases() ermiteconnect.ReleaseServiceClient {
85+ return ermiteconnect.NewReleaseServiceClient(c.http, c.base, c.opts...)
86+}
87+
8388 // Tokens returns the TokenService client.
8489 func (c *Client) Tokens() ermiteconnect.TokenServiceClient {
8590 return ermiteconnect.NewTokenServiceClient(c.http, c.base, c.opts...)
@@ -80,6 +80,11 @@ func (c *Client) Artifacts() ermiteconnect.ArtifactServiceClient {
80 return ermiteconnect.NewArtifactServiceClient(c.http, c.base, c.opts...)80 return ermiteconnect.NewArtifactServiceClient(c.http, c.base, c.opts...)
81 }81 }
82 82
83+// Releases returns the ReleaseService client.
84+func (c *Client) Releases() ermiteconnect.ReleaseServiceClient {
85+ return ermiteconnect.NewReleaseServiceClient(c.http, c.base, c.opts...)
86+}
87+
83 // Tokens returns the TokenService client.88 // Tokens returns the TokenService client.
84 func (c *Client) Tokens() ermiteconnect.TokenServiceClient {89 func (c *Client) Tokens() ermiteconnect.TokenServiceClient {
85 return ermiteconnect.NewTokenServiceClient(c.http, c.base, c.opts...)90 return ermiteconnect.NewTokenServiceClient(c.http, c.base, c.opts...)
M internal/adapters/client/client_test.go +3 -3
@@ -139,15 +139,15 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139139 func TestMissingFeatures(t *testing.T) {
140140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge},
142+ []string{client.FeatureReleases, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge},
143143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSession,
145+ client.FeatureReleases, client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSession,
146146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,
147147 client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, client.FeatureAutoMerge,
148148 }))
149149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
150- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureSession,
150+ client.FeatureReleases, client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureSession,
151151 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge,
152152 }))
153153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
@@ -139,15 +139,15 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139 func TestMissingFeatures(t *testing.T) {139 func TestMissingFeatures(t *testing.T) {
140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141 assert.Equal(t,141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge},142+ []string{client.FeatureReleases, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge},
143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSession,145+ client.FeatureReleases, client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSession,
146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,
147 client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, client.FeatureAutoMerge,147 client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, client.FeatureAutoMerge,
148 }))148 }))
149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
150- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureSession,150+ client.FeatureReleases, client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureSession,
151 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge,151 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge,
152 }))152 }))
153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
M internal/adapters/client/features.go +3 -1
@@ -12,6 +12,8 @@ const (
1212 FeatureMarkReady = "merge_requests.ready"
1313 // FeatureIgnoreChecks permits merging with checks bypassed.
1414 FeatureIgnoreChecks = "merge_requests.ignore_checks"
15+ // FeatureReleases is ReleaseService (`ermite release`).
16+ FeatureReleases = "releases"
1517 // FeatureRepoUpdate is RepoService.UpdateRepo (`ermite repo edit`).
1618 FeatureRepoUpdate = "repo.update"
1719 // FeatureEventsResume is EventService.Watch with resume_after, which
@@ -69,7 +71,7 @@ const (
6971
7072 // UsedFeatures lists the features the CLI uses, in the order they are reported.
7173 func UsedFeatures() []string {
72- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures, FeatureAutoMerge}
74+ return []string{FeatureReleases, FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures, FeatureAutoMerge}
7375 }
7476
7577 // MissingFeatures returns the features the CLI uses that have is lacking.
@@ -12,6 +12,8 @@ const (
12 FeatureMarkReady = "merge_requests.ready"12 FeatureMarkReady = "merge_requests.ready"
13 // FeatureIgnoreChecks permits merging with checks bypassed.13 // FeatureIgnoreChecks permits merging with checks bypassed.
14 FeatureIgnoreChecks = "merge_requests.ignore_checks"14 FeatureIgnoreChecks = "merge_requests.ignore_checks"
15+ // FeatureReleases is ReleaseService (`ermite release`).
16+ FeatureReleases = "releases"
15 // FeatureRepoUpdate is RepoService.UpdateRepo (`ermite repo edit`).17 // FeatureRepoUpdate is RepoService.UpdateRepo (`ermite repo edit`).
16 FeatureRepoUpdate = "repo.update"18 FeatureRepoUpdate = "repo.update"
17 // FeatureEventsResume is EventService.Watch with resume_after, which19 // FeatureEventsResume is EventService.Watch with resume_after, which
@@ -69,7 +71,7 @@ const (
69 71
70 // UsedFeatures lists the features the CLI uses, in the order they are reported.72 // UsedFeatures lists the features the CLI uses, in the order they are reported.
71 func UsedFeatures() []string {73 func UsedFeatures() []string {
72- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures, FeatureAutoMerge}74+ return []string{FeatureReleases, FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures, FeatureAutoMerge}
73 }75 }
74 76
75 // MissingFeatures returns the features the CLI uses that have is lacking.77 // MissingFeatures returns the features the CLI uses that have is lacking.
A internal/adapters/client/release_test.go +43 -0
@@ -0,0 +1,43 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package client_test
4+
5+import (
6+ "context"
7+ "net/http"
8+ "net/http/httptest"
9+ "testing"
10+
11+ "connectrpc.com/connect"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
17+ "bnema.dev/ermite/internal/adapters/client"
18+)
19+
20+type releaseAPI struct {
21+ ermiteconnect.UnimplementedReleaseServiceHandler
22+}
23+
24+func (releaseAPI) GetRelease(_ context.Context, r *connect.Request[ermite.GetReleaseRequest]) (*connect.Response[ermite.GetReleaseResponse], error) {
25+ if r.Header().Get("Authorization") != "Bearer secret" || r.Header().Get("User-Agent") != "ermite/test" {
26+ return nil, connect.NewError(connect.CodeUnauthenticated, nil)
27+ }
28+ return connect.NewResponse(&ermite.GetReleaseResponse{Release: &ermite.Release{Id: r.Msg.GetId(), Repo: r.Msg.GetRepo()}}), nil
29+}
30+func TestReleasesAccessor(t *testing.T) {
31+ mux := http.NewServeMux()
32+ p, h := ermiteconnect.NewReleaseServiceHandler(releaseAPI{})
33+ mux.Handle("/-/api"+p, http.StripPrefix("/-/api", h))
34+ srv := httptest.NewServer(mux)
35+ t.Cleanup(srv.Close)
36+ c, err := client.New(client.Config{URL: srv.URL, Token: "secret", UserAgent: "ermite/test"})
37+ require.NoError(t, err)
38+ res, err := c.Releases().GetRelease(t.Context(), connect.NewRequest(&ermite.GetReleaseRequest{Repo: "proj", Selector: &ermite.GetReleaseRequest_Id{Id: 7}}))
39+ require.NoError(t, err)
40+ assert.Equal(t, int64(7), res.Msg.GetRelease().GetId())
41+ assert.Equal(t, "proj", res.Msg.GetRelease().GetRepo())
42+ assert.Contains(t, client.UsedFeatures(), client.FeatureReleases)
43+}
@@ -0,0 +1,43 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package client_test
4+
5+import (
6+ "context"
7+ "net/http"
8+ "net/http/httptest"
9+ "testing"
10+
11+ "connectrpc.com/connect"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
17+ "bnema.dev/ermite/internal/adapters/client"
18+)
19+
20+type releaseAPI struct {
21+ ermiteconnect.UnimplementedReleaseServiceHandler
22+}
23+
24+func (releaseAPI) GetRelease(_ context.Context, r *connect.Request[ermite.GetReleaseRequest]) (*connect.Response[ermite.GetReleaseResponse], error) {
25+ if r.Header().Get("Authorization") != "Bearer secret" || r.Header().Get("User-Agent") != "ermite/test" {
26+ return nil, connect.NewError(connect.CodeUnauthenticated, nil)
27+ }
28+ return connect.NewResponse(&ermite.GetReleaseResponse{Release: &ermite.Release{Id: r.Msg.GetId(), Repo: r.Msg.GetRepo()}}), nil
29+}
30+func TestReleasesAccessor(t *testing.T) {
31+ mux := http.NewServeMux()
32+ p, h := ermiteconnect.NewReleaseServiceHandler(releaseAPI{})
33+ mux.Handle("/-/api"+p, http.StripPrefix("/-/api", h))
34+ srv := httptest.NewServer(mux)
35+ t.Cleanup(srv.Close)
36+ c, err := client.New(client.Config{URL: srv.URL, Token: "secret", UserAgent: "ermite/test"})
37+ require.NoError(t, err)
38+ res, err := c.Releases().GetRelease(t.Context(), connect.NewRequest(&ermite.GetReleaseRequest{Repo: "proj", Selector: &ermite.GetReleaseRequest_Id{Id: 7}}))
39+ require.NoError(t, err)
40+ assert.Equal(t, int64(7), res.Msg.GetRelease().GetId())
41+ assert.Equal(t, "proj", res.Msg.GetRelease().GetRepo())
42+ assert.Contains(t, client.UsedFeatures(), client.FeatureReleases)
43+}
M internal/app/cli_e2e_test.go +6 -5
@@ -334,9 +334,9 @@ 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. Releases are wired by P8; this rig predates it.
338338 res = c.cli(t, "", "version", "--json").ok(t)
339- assert.Equal(t, 1, strings.Count(res.stderr, "warning:"), res.stderr)
339+ assert.Equal(t, 2, strings.Count(res.stderr, "warning:"), res.stderr)
340340 assert.Contains(t, res.stderr, "the server does not support passkeys")
341341 type versionOut struct {
342342 Client struct{ Version string }
@@ -351,11 +351,11 @@ func TestAC6CLIEndToEnd(t *testing.T) {
351351 assert.Equal(t, c.url, ver.Server.URL)
352352 assert.NotEmpty(t, ver.Server.Version)
353353 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)
354- assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
354+ assert.Equal(t, []string{"releases", "passkeys"}, ver.Server.MissingFeatures)
355355 stdout, stderr, code = c.bin(t, secret, "version")
356356 require.Equal(t, 0, code, stderr)
357357 assert.Contains(t, stdout, "server")
358- assert.Equal(t, 1, strings.Count(stderr, "warning:"), stderr)
358+ assert.Equal(t, 2, strings.Count(stderr, "warning:"), stderr)
359359 assert.Contains(t, stderr, "the server does not support passkeys")
360360
361361 // Tokens: create a second one, list, revoke it, see it stop working.
@@ -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())-1, "the cli preset drops release:write until P8 wires releases")
393+ assert.NotContains(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,9 +334,9 @@ 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. Releases are wired by P8; this rig predates it.
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, 2, 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")
341 type versionOut struct {341 type versionOut struct {
342 Client struct{ Version string }342 Client struct{ Version string }
@@ -351,11 +351,11 @@ func TestAC6CLIEndToEnd(t *testing.T) {
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"}, ver.Server.Features)
354- assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)354+ assert.Equal(t, []string{"releases", "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)
357 assert.Contains(t, stdout, "server")357 assert.Contains(t, stdout, "server")
358- assert.Equal(t, 1, strings.Count(stderr, "warning:"), stderr)358+ assert.Equal(t, 2, strings.Count(stderr, "warning:"), stderr)
359 assert.Contains(t, stderr, "the server does not support passkeys")359 assert.Contains(t, stderr, "the server does not support passkeys")
360 360
361 // Tokens: create a second one, list, revoke it, see it stop working.361 // Tokens: create a second one, list, revoke it, see it stop working.
@@ -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())-1, "the cli preset drops release:write until P8 wires releases")
393+ assert.NotContains(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 +74 -0
@@ -0,0 +1,74 @@
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/blobdisk"
17+ "bnema.dev/ermite/internal/adapters/rpc"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/usecase"
20+)
21+
22+func TestReleaseCLIEndToEnd(t *testing.T) {
23+ c := newCLIHarness(t)
24+ c.cli(t, adminToken, "repo", "create", "proj", "--visibility", "public").ok(t)
25+ wt := c.workTree("main", "README.md")
26+ c.mustGit(wt, "tag", "v1")
27+ c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "main", "refs/tags/v1")
28+
29+ // P8 owns production wiring. Compose the real release service and RPC here,
30+ // sharing the forge's database, auth, git reader, pushes and event bus.
31+ f := c.forge
32+ releases := usecase.NewReleases(f.store.Releases(), blobdisk.New(filepath.Join(c.data, "release-test-blobs")), f.store, f.git, f.git, f.Pushes, f.Bus, systemClock{}, ports.ReleaseLimits{})
33+ api := rpc.New(rpc.Config{Auth: f.Auth, Releases: releases, SigningKey: f.SigningKey, Sessions: f.Sessions})
34+ mux := http.NewServeMux()
35+ mux.Handle("/-/api/", http.StripPrefix("/-/api", api.Handler()))
36+ srv := httptest.NewServer(mux)
37+ t.Cleanup(srv.Close)
38+ oldURL := c.url
39+ c.url = srv.URL
40+ bin := func(args ...string) string {
41+ t.Helper()
42+ out, stderr, code := c.bin(t, adminToken, args...)
43+ require.Equal(t, 0, code, stderr)
44+ return out
45+ }
46+ asset := filepath.Join(t.TempDir(), "binary.dat")
47+ data := []byte("release payload\x00\xff")
48+ require.NoError(t, os.WriteFile(asset, data, 0o600))
49+ created := decode[jsonRelease](t, bin("release", "create", "proj", "v1", "--title", "Version one", "--notes", "# shipped", "--asset", asset+"#Binary", "--json"))
50+ require.Positive(t, created.ID)
51+ require.Len(t, created.Assets, 1)
52+ assert.Equal(t, "Binary", created.Assets[0].Label)
53+ viewed := decode[jsonRelease](t, bin("release", "view", "proj", "v1", "--json"))
54+ assert.Equal(t, created.ID, viewed.ID)
55+ assert.Equal(t, "# shipped", viewed.Notes)
56+ assert.Contains(t, bin("release", "list", "proj"), "Version one")
57+ bin("release", "edit", "proj", strconv.FormatInt(created.ID, 10), "--title", "Edited", "--no-prerelease")
58+ assert.Equal(t, "Edited", decode[jsonRelease](t, bin("release", "view", "proj", "latest", "--json")).Title)
59+ dir := t.TempDir()
60+ bin("release", "download", "proj", "latest", "--dir", dir, "--pattern", "*.dat")
61+ downloaded, err := os.ReadFile(filepath.Join(dir, "binary.dat"))
62+ require.NoError(t, err)
63+ assert.Equal(t, data, downloaded)
64+ _, stderr, code := c.bin(t, adminToken, "release", "upload", "proj", "v1", asset)
65+ requireOneLineError(t, stderr, code, "binary.dat", "--clobber")
66+ bin("release", "upload", "proj", "v1", asset, "--clobber")
67+ bin("release", "delete", "proj", "v1", "--yes")
68+ _, stderr, code = c.bin(t, adminToken, "release", "view", "proj", "v1")
69+ requireOneLineError(t, stderr, code, "not found")
70+ // The unmodified forge intentionally has no release wiring yet.
71+ c.url = oldURL
72+ _, stderr, code = c.bin(t, adminToken, "release", "list", "proj")
73+ requireOneLineError(t, stderr, code, "the server has no releases (it lacks the releases feature)")
74+}
@@ -0,0 +1,74 @@
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/blobdisk"
17+ "bnema.dev/ermite/internal/adapters/rpc"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/usecase"
20+)
21+
22+func TestReleaseCLIEndToEnd(t *testing.T) {
23+ c := newCLIHarness(t)
24+ c.cli(t, adminToken, "repo", "create", "proj", "--visibility", "public").ok(t)
25+ wt := c.workTree("main", "README.md")
26+ c.mustGit(wt, "tag", "v1")
27+ c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "main", "refs/tags/v1")
28+
29+ // P8 owns production wiring. Compose the real release service and RPC here,
30+ // sharing the forge's database, auth, git reader, pushes and event bus.
31+ f := c.forge
32+ releases := usecase.NewReleases(f.store.Releases(), blobdisk.New(filepath.Join(c.data, "release-test-blobs")), f.store, f.git, f.git, f.Pushes, f.Bus, systemClock{}, ports.ReleaseLimits{})
33+ api := rpc.New(rpc.Config{Auth: f.Auth, Releases: releases, SigningKey: f.SigningKey, Sessions: f.Sessions})
34+ mux := http.NewServeMux()
35+ mux.Handle("/-/api/", http.StripPrefix("/-/api", api.Handler()))
36+ srv := httptest.NewServer(mux)
37+ t.Cleanup(srv.Close)
38+ oldURL := c.url
39+ c.url = srv.URL
40+ bin := func(args ...string) string {
41+ t.Helper()
42+ out, stderr, code := c.bin(t, adminToken, args...)
43+ require.Equal(t, 0, code, stderr)
44+ return out
45+ }
46+ asset := filepath.Join(t.TempDir(), "binary.dat")
47+ data := []byte("release payload\x00\xff")
48+ require.NoError(t, os.WriteFile(asset, data, 0o600))
49+ created := decode[jsonRelease](t, bin("release", "create", "proj", "v1", "--title", "Version one", "--notes", "# shipped", "--asset", asset+"#Binary", "--json"))
50+ require.Positive(t, created.ID)
51+ require.Len(t, created.Assets, 1)
52+ assert.Equal(t, "Binary", created.Assets[0].Label)
53+ viewed := decode[jsonRelease](t, bin("release", "view", "proj", "v1", "--json"))
54+ assert.Equal(t, created.ID, viewed.ID)
55+ assert.Equal(t, "# shipped", viewed.Notes)
56+ assert.Contains(t, bin("release", "list", "proj"), "Version one")
57+ bin("release", "edit", "proj", strconv.FormatInt(created.ID, 10), "--title", "Edited", "--no-prerelease")
58+ assert.Equal(t, "Edited", decode[jsonRelease](t, bin("release", "view", "proj", "latest", "--json")).Title)
59+ dir := t.TempDir()
60+ bin("release", "download", "proj", "latest", "--dir", dir, "--pattern", "*.dat")
61+ downloaded, err := os.ReadFile(filepath.Join(dir, "binary.dat"))
62+ require.NoError(t, err)
63+ assert.Equal(t, data, downloaded)
64+ _, stderr, code := c.bin(t, adminToken, "release", "upload", "proj", "v1", asset)
65+ requireOneLineError(t, stderr, code, "binary.dat", "--clobber")
66+ bin("release", "upload", "proj", "v1", asset, "--clobber")
67+ bin("release", "delete", "proj", "v1", "--yes")
68+ _, stderr, code = c.bin(t, adminToken, "release", "view", "proj", "v1")
69+ requireOneLineError(t, stderr, code, "not found")
70+ // The unmodified forge intentionally has no release wiring yet.
71+ c.url = oldURL
72+ _, stderr, code = c.bin(t, adminToken, "release", "list", "proj")
73+ requireOneLineError(t, stderr, code, "the server has no releases (it lacks the releases feature)")
74+}
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/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

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
esc
close a dialog, leave a field

Branches 2

Tags 42

↑↓ moveenter openesc close