detached 9bfe1d5

fix: address CodeRabbit review — extract deploy helper, typed auth error, scope comment

- Extract shouldSkipDeploy helper from runPush to fix cyclomatic complexity lint
- Update isAuthError to prefer remote.HTTPError typed check with string fallback
- Add comment explaining wildcard scope in ExchangeRegistryToken
commit
9bfe1d5480c4707b1864ddfbe3c4090fe8839f05
author
brice · · 2026-03-19 05:01 UTC
parent
c2bc6fc
signature
unsigned
3 files · +29 -15
M internal/adapters/in/cli/push.go +18 -14
@@ -202,20 +202,7 @@ func runPush(ctx context.Context, req pushRequest) error {
202202 }
203203 fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain))
204204
205- // Signal the server to suppress event-based deploys for this image.
206- // The CLI will trigger an explicit deploy after push completes.
207- skipExplicitDeploy := req.NoDeploy
208- if !req.NoDeploy {
209- if err := handle.plane.DeployIntent(ctx, imageName); err != nil {
210- if isInsufficientScope(err) {
211- fmt.Fprintln(os.Stderr, "info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive")
212- skipExplicitDeploy = true
213- } else {
214- // Non-fatal: worst case we get a redundant deploy via event
215- fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err)
216- }
217- }
218- }
205+ skipExplicitDeploy := shouldSkipDeploy(ctx, handle.plane, imageName, req.NoDeploy)
219206
220207 build := buildConfig{
221208 Enabled: req.Build.Enabled,
@@ -243,6 +230,23 @@ func runPush(ctx context.Context, req pushRequest) error {
243230 return nil
244231 }
245232
233+// shouldSkipDeploy signals deploy intent and returns whether the explicit deploy
234+// should be skipped (either because --no-deploy was set, or the token lacks scope).
235+func shouldSkipDeploy(ctx context.Context, plane ControlPlane, imageName string, noDeploy bool) bool {
236+ if noDeploy {
237+ return true
238+ }
239+ if err := plane.DeployIntent(ctx, imageName); err != nil {
240+ if isInsufficientScope(err) {
241+ fmt.Fprintln(os.Stderr, "info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive")
242+ return true
243+ }
244+ // Non-fatal: worst case we get a redundant deploy via event
245+ fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err)
246+ }
247+ return false
248+}
249+
246250 // isInsufficientScope returns true if the error is an HTTP 403 Forbidden response,
247251 // meaning the token lacks the required admin scope.
248252 func isInsufficientScope(err error) bool {
@@ -202,20 +202,7 @@ func runPush(ctx context.Context, req pushRequest) error {
202 }202 }
203 fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain))203 fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain))
204 204
205- // Signal the server to suppress event-based deploys for this image.205+ skipExplicitDeploy := shouldSkipDeploy(ctx, handle.plane, imageName, req.NoDeploy)
206- // The CLI will trigger an explicit deploy after push completes.
207- skipExplicitDeploy := req.NoDeploy
208- if !req.NoDeploy {
209- if err := handle.plane.DeployIntent(ctx, imageName); err != nil {
210- if isInsufficientScope(err) {
211- fmt.Fprintln(os.Stderr, "info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive")
212- skipExplicitDeploy = true
213- } else {
214- // Non-fatal: worst case we get a redundant deploy via event
215- fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err)
216- }
217- }
218- }
219 206
220 build := buildConfig{207 build := buildConfig{
221 Enabled: req.Build.Enabled,208 Enabled: req.Build.Enabled,
@@ -243,6 +230,23 @@ func runPush(ctx context.Context, req pushRequest) error {
243 return nil230 return nil
244 }231 }
245 232
233+// shouldSkipDeploy signals deploy intent and returns whether the explicit deploy
234+// should be skipped (either because --no-deploy was set, or the token lacks scope).
235+func shouldSkipDeploy(ctx context.Context, plane ControlPlane, imageName string, noDeploy bool) bool {
236+ if noDeploy {
237+ return true
238+ }
239+ if err := plane.DeployIntent(ctx, imageName); err != nil {
240+ if isInsufficientScope(err) {
241+ fmt.Fprintln(os.Stderr, "info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive")
242+ return true
243+ }
244+ // Non-fatal: worst case we get a redundant deploy via event
245+ fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err)
246+ }
247+ return false
248+}
249+
246 // isInsufficientScope returns true if the error is an HTTP 403 Forbidden response,250 // isInsufficientScope returns true if the error is an HTTP 403 Forbidden response,
247 // meaning the token lacks the required admin scope.251 // meaning the token lacks the required admin scope.
248 func isInsufficientScope(err error) bool {252 func isInsufficientScope(err error) bool {
M internal/adapters/in/cli/push_image_ops.go +9 -1
@@ -2,8 +2,10 @@ package cli
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "io"
8+ "net/http"
79 "os"
810 "os/exec"
911 "strings"
@@ -111,11 +113,17 @@ func (d *dockerImageOps) Push(ctx context.Context, ref string) error {
111113 return pusher.Push(ctx, ref)
112114 }
113115
114-// isAuthError returns true if the error looks like an HTTP 401 authentication failure.
116+// isAuthError returns true if the error indicates an HTTP 401 authentication failure.
117+// Prefers typed HTTPError from the remote client; falls back to string matching for
118+// errors from registrypush which uses its own error format.
115119 func isAuthError(err error) bool {
116120 if err == nil {
117121 return false
118122 }
123+ var httpErr *remote.HTTPError
124+ if errors.As(err, &httpErr) {
125+ return httpErr.StatusCode == http.StatusUnauthorized
126+ }
119127 msg := err.Error()
120128 return strings.Contains(msg, "status 401") || strings.Contains(msg, "401 Unauthorized")
121129 }
@@ -2,8 +2,10 @@ package cli
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6 "io"7 "io"
8+ "net/http"
7 "os"9 "os"
8 "os/exec"10 "os/exec"
9 "strings"11 "strings"
@@ -111,11 +113,17 @@ func (d *dockerImageOps) Push(ctx context.Context, ref string) error {
111 return pusher.Push(ctx, ref)113 return pusher.Push(ctx, ref)
112 }114 }
113 115
114-// isAuthError returns true if the error looks like an HTTP 401 authentication failure.116+// isAuthError returns true if the error indicates an HTTP 401 authentication failure.
117+// Prefers typed HTTPError from the remote client; falls back to string matching for
118+// errors from registrypush which uses its own error format.
115 func isAuthError(err error) bool {119 func isAuthError(err error) bool {
116 if err == nil {120 if err == nil {
117 return false121 return false
118 }122 }
123+ var httpErr *remote.HTTPError
124+ if errors.As(err, &httpErr) {
125+ return httpErr.StatusCode == http.StatusUnauthorized
126+ }
119 msg := err.Error()127 msg := err.Error()
120 return strings.Contains(msg, "status 401") || strings.Contains(msg, "401 Unauthorized")128 return strings.Contains(msg, "status 401") || strings.Contains(msg, "401 Unauthorized")
121 }129 }
M internal/adapters/in/cli/remote/auth.go +2 -0
@@ -78,6 +78,8 @@ func (c *Client) Authenticate(ctx context.Context, username, password string) (*
7878 // The subject must match the token's sub claim (use VerifyAuth to obtain it).
7979 // Returns the raw short-lived token string (without a "Bearer " prefix).
8080 func (c *Client) ExchangeRegistryToken(ctx context.Context, subject string) (string, error) {
81+ // Wildcard scope (repository:*:push,pull) grants push/pull to all repositories.
82+ // The server downsopes this to the parent token's actual scopes during exchange.
8183 url := c.baseURL + "/auth/token?scope=repository:*:push,pull&service=gordon-registry"
8284
8385 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
@@ -78,6 +78,8 @@ func (c *Client) Authenticate(ctx context.Context, username, password string) (*
78 // The subject must match the token's sub claim (use VerifyAuth to obtain it).78 // The subject must match the token's sub claim (use VerifyAuth to obtain it).
79 // Returns the raw short-lived token string (without a "Bearer " prefix).79 // Returns the raw short-lived token string (without a "Bearer " prefix).
80 func (c *Client) ExchangeRegistryToken(ctx context.Context, subject string) (string, error) {80 func (c *Client) ExchangeRegistryToken(ctx context.Context, subject string) (string, error) {
81+ // Wildcard scope (repository:*:push,pull) grants push/pull to all repositories.
82+ // The server downsopes this to the parent token's actual scopes during exchange.
81 url := c.baseURL + "/auth/token?scope=repository:*:push,pull&service=gordon-registry"83 url := c.baseURL + "/auth/token?scope=repository:*:push,pull&service=gordon-registry"
82 84
83 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)85 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)

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 7

Tags 106

↑↓ moveenter openesc close