detached 525f93d

fix(cli): infer push registry from target Gordon instance (#12)

Bare image names passed to 'gordon images push' use the target's registry_domain, published on /admin/auth/verify (fallback /admin/status). Fully qualified refs must match that registry.
commit
525f93d5d05b3ee2d1cf00f6200f038d1672e425
author
brice · · 2026-10-08 07:39 UTC
committer
bnema · · 2026-10-08 07:39 UTC
parent
d676137
signature
unsigned
9 files · +343 -11
M docs/cli/images.md +9 -0
@@ -57,6 +57,15 @@ It auto-selects only when exactly one remote matches. If multiple remotes match,
5757 or a saved remote cannot be probed safely, the command stops and asks you to use
5858 `--remote` explicitly.
5959
60+The registry comes from the target Gordon instance. A bare image name such as
61+`myapp` or `myapp:v1` is pushed to the target's `server.registry_domain`, read
62+from `/admin/auth/verify` (any admin-scoped token, no `admin:status:read`
63+needed). A fully qualified
64+reference such as `registry.example.com/myapp` must name that same registry;
65+otherwise the push is rejected so Gordon credentials are never sent to another
66+registry. Hosts listed in `server.legacy_registry_domains` are rejected too: use
67+the canonical registry or a bare name.
68+
6069 Domain-style push targets are retired: push an image name, then
6170 `gordon apps apply` the manifest that references the pushed tag.
6271
@@ -57,6 +57,15 @@ It auto-selects only when exactly one remote matches. If multiple remotes match,
57 or a saved remote cannot be probed safely, the command stops and asks you to use57 or a saved remote cannot be probed safely, the command stops and asks you to use
58 `--remote` explicitly.58 `--remote` explicitly.
59 59
60+The registry comes from the target Gordon instance. A bare image name such as
61+`myapp` or `myapp:v1` is pushed to the target's `server.registry_domain`, read
62+from `/admin/auth/verify` (any admin-scoped token, no `admin:status:read`
63+needed). A fully qualified
64+reference such as `registry.example.com/myapp` must name that same registry;
65+otherwise the push is rejected so Gordon credentials are never sent to another
66+registry. Hosts listed in `server.legacy_registry_domains` are rejected too: use
67+the canonical registry or a bare name.
68+
60 Domain-style push targets are retired: push an image name, then69 Domain-style push targets are retired: push an image name, then
61 `gordon apps apply` the manifest that references the pushed tag.70 `gordon apps apply` the manifest that references the pushed tag.
62 71
M internal/adapters/dto/admin_auth.go +2 -0
@@ -7,4 +7,6 @@ type AuthVerifyResponse struct {
77 Scopes []string `json:"scopes,omitempty"`
88 ExpiresAt int64 `json:"expires_at,omitempty"`
99 IssuedAt int64 `json:"issued_at,omitempty"`
10+ // RegistryDomain is the Gordon registry host that push clients target.
11+ RegistryDomain string `json:"registry_domain,omitempty"`
1012 }
@@ -7,4 +7,6 @@ type AuthVerifyResponse struct {
7 Scopes []string `json:"scopes,omitempty"`7 Scopes []string `json:"scopes,omitempty"`
8 ExpiresAt int64 `json:"expires_at,omitempty"`8 ExpiresAt int64 `json:"expires_at,omitempty"`
9 IssuedAt int64 `json:"issued_at,omitempty"`9 IssuedAt int64 `json:"issued_at,omitempty"`
10+ // RegistryDomain is the Gordon registry host that push clients target.
11+ RegistryDomain string `json:"registry_domain,omitempty"`
10 }12 }
M internal/adapters/in/cli/controlplane.go +1 -0
@@ -29,6 +29,7 @@ type ControlPlane interface {
2929 DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error
3030
3131 GetStatus(ctx context.Context) (*remote.Status, error)
32+ VerifyAuth(ctx context.Context) (*dto.AuthVerifyResponse, error)
3233 GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error)
3334 GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error)
3435 GetCA(ctx context.Context) (*dto.CAResponse, error)
@@ -29,6 +29,7 @@ type ControlPlane interface {
29 DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error29 DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error
30 30
31 GetStatus(ctx context.Context) (*remote.Status, error)31 GetStatus(ctx context.Context) (*remote.Status, error)
32+ VerifyAuth(ctx context.Context) (*dto.AuthVerifyResponse, error)
32 GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error)33 GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error)
33 GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error)34 GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error)
34 GetCA(ctx context.Context) (*dto.CAResponse, error)35 GetCA(ctx context.Context) (*dto.CAResponse, error)
M internal/adapters/in/cli/mocks/mock_control_plane.go +62 -0
@@ -2092,6 +2092,68 @@ func (_c *MockControlPlane_StreamProcessLogs_Call) RunAndReturn(run func(ctx con
20922092 return _c
20932093 }
20942094
2095+// VerifyAuth provides a mock function for the type MockControlPlane
2096+func (_mock *MockControlPlane) VerifyAuth(ctx context.Context) (*dto.AuthVerifyResponse, error) {
2097+ ret := _mock.Called(ctx)
2098+
2099+ if len(ret) == 0 {
2100+ panic("no return value specified for VerifyAuth")
2101+ }
2102+
2103+ var r0 *dto.AuthVerifyResponse
2104+ var r1 error
2105+ if returnFunc, ok := ret.Get(0).(func(context.Context) (*dto.AuthVerifyResponse, error)); ok {
2106+ return returnFunc(ctx)
2107+ }
2108+ if returnFunc, ok := ret.Get(0).(func(context.Context) *dto.AuthVerifyResponse); ok {
2109+ r0 = returnFunc(ctx)
2110+ } else {
2111+ if ret.Get(0) != nil {
2112+ r0 = ret.Get(0).(*dto.AuthVerifyResponse)
2113+ }
2114+ }
2115+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
2116+ r1 = returnFunc(ctx)
2117+ } else {
2118+ r1 = ret.Error(1)
2119+ }
2120+ return r0, r1
2121+}
2122+
2123+// MockControlPlane_VerifyAuth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'VerifyAuth'
2124+type MockControlPlane_VerifyAuth_Call struct {
2125+ *mock.Call
2126+}
2127+
2128+// VerifyAuth is a helper method to define mock.On call
2129+// - ctx context.Context
2130+func (_e *MockControlPlane_Expecter) VerifyAuth(ctx any) *MockControlPlane_VerifyAuth_Call {
2131+ return &MockControlPlane_VerifyAuth_Call{Call: _e.mock.On("VerifyAuth", ctx)}
2132+}
2133+
2134+func (_c *MockControlPlane_VerifyAuth_Call) Run(run func(ctx context.Context)) *MockControlPlane_VerifyAuth_Call {
2135+ _c.Call.Run(func(args mock.Arguments) {
2136+ var arg0 context.Context
2137+ if args[0] != nil {
2138+ arg0 = args[0].(context.Context)
2139+ }
2140+ run(
2141+ arg0,
2142+ )
2143+ })
2144+ return _c
2145+}
2146+
2147+func (_c *MockControlPlane_VerifyAuth_Call) Return(authVerifyResponse *dto.AuthVerifyResponse, err error) *MockControlPlane_VerifyAuth_Call {
2148+ _c.Call.Return(authVerifyResponse, err)
2149+ return _c
2150+}
2151+
2152+func (_c *MockControlPlane_VerifyAuth_Call) RunAndReturn(run func(ctx context.Context) (*dto.AuthVerifyResponse, error)) *MockControlPlane_VerifyAuth_Call {
2153+ _c.Call.Return(run)
2154+ return _c
2155+}
2156+
20952157 // VolumeBackupStatus provides a mock function for the type MockControlPlane
20962158 func (_mock *MockControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) {
20972159 ret := _mock.Called(ctx)
@@ -2092,6 +2092,68 @@ func (_c *MockControlPlane_StreamProcessLogs_Call) RunAndReturn(run func(ctx con
2092 return _c2092 return _c
2093 }2093 }
2094 2094
2095+// VerifyAuth provides a mock function for the type MockControlPlane
2096+func (_mock *MockControlPlane) VerifyAuth(ctx context.Context) (*dto.AuthVerifyResponse, error) {
2097+ ret := _mock.Called(ctx)
2098+
2099+ if len(ret) == 0 {
2100+ panic("no return value specified for VerifyAuth")
2101+ }
2102+
2103+ var r0 *dto.AuthVerifyResponse
2104+ var r1 error
2105+ if returnFunc, ok := ret.Get(0).(func(context.Context) (*dto.AuthVerifyResponse, error)); ok {
2106+ return returnFunc(ctx)
2107+ }
2108+ if returnFunc, ok := ret.Get(0).(func(context.Context) *dto.AuthVerifyResponse); ok {
2109+ r0 = returnFunc(ctx)
2110+ } else {
2111+ if ret.Get(0) != nil {
2112+ r0 = ret.Get(0).(*dto.AuthVerifyResponse)
2113+ }
2114+ }
2115+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
2116+ r1 = returnFunc(ctx)
2117+ } else {
2118+ r1 = ret.Error(1)
2119+ }
2120+ return r0, r1
2121+}
2122+
2123+// MockControlPlane_VerifyAuth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'VerifyAuth'
2124+type MockControlPlane_VerifyAuth_Call struct {
2125+ *mock.Call
2126+}
2127+
2128+// VerifyAuth is a helper method to define mock.On call
2129+// - ctx context.Context
2130+func (_e *MockControlPlane_Expecter) VerifyAuth(ctx any) *MockControlPlane_VerifyAuth_Call {
2131+ return &MockControlPlane_VerifyAuth_Call{Call: _e.mock.On("VerifyAuth", ctx)}
2132+}
2133+
2134+func (_c *MockControlPlane_VerifyAuth_Call) Run(run func(ctx context.Context)) *MockControlPlane_VerifyAuth_Call {
2135+ _c.Call.Run(func(args mock.Arguments) {
2136+ var arg0 context.Context
2137+ if args[0] != nil {
2138+ arg0 = args[0].(context.Context)
2139+ }
2140+ run(
2141+ arg0,
2142+ )
2143+ })
2144+ return _c
2145+}
2146+
2147+func (_c *MockControlPlane_VerifyAuth_Call) Return(authVerifyResponse *dto.AuthVerifyResponse, err error) *MockControlPlane_VerifyAuth_Call {
2148+ _c.Call.Return(authVerifyResponse, err)
2149+ return _c
2150+}
2151+
2152+func (_c *MockControlPlane_VerifyAuth_Call) RunAndReturn(run func(ctx context.Context) (*dto.AuthVerifyResponse, error)) *MockControlPlane_VerifyAuth_Call {
2153+ _c.Call.Return(run)
2154+ return _c
2155+}
2156+
2095 // VolumeBackupStatus provides a mock function for the type MockControlPlane2157 // VolumeBackupStatus provides a mock function for the type MockControlPlane
2096 func (_mock *MockControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) {2158 func (_mock *MockControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) {
2097 ret := _mock.Called(ctx)2159 ret := _mock.Called(ctx)
M internal/adapters/in/cli/push.go +83 -11
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "fmt"
88 "io"
9+ "net/http"
910 "os"
1011 "os/exec"
1112 "path/filepath"
@@ -147,10 +148,13 @@ func looksLikeLegacyDomain(arg string) bool {
147148 }
148149
149150 // resolveImageTarget determines the registry and image name from the input.
151+// A bare image name (no registry host) is pushed to the target Gordon's
152+// registry domain. A fully qualified reference must name that same
153+// registry, so the Gordon registry credentials never leave the target.
150154 // Push transfers OCI content only and never triggers deploys or route
151155 // lookups. The deploy target comes from the app manifest applied
152156 // separately via `gordon apps apply`.
153-func resolveImageTarget(out io.Writer, imageArg, dockerfile string) (registry, imageName, sourceRef string, err error) {
157+func resolveImageTarget(ctx context.Context, out io.Writer, handle *controlPlaneHandle, imageArg, dockerfile string) (registry, imageName, sourceRef string, err error) {
154158 if imageArg == "" {
155159 imageArg, err = detectImageName(dockerfile)
156160 if err != nil {
@@ -164,13 +168,80 @@ func resolveImageTarget(out io.Writer, imageArg, dockerfile string) (registry, i
164168 return "", "", "", fmt.Errorf("domain-style push targets are retired: push an image name, then `gordon apps apply` (got %q)", imageArg)
165169 }
166170 classified := classifyPushArgument(imageArg)
171+ if classified.repository == "" {
172+ return "", "", "", fmt.Errorf("cannot parse image name from %q", imageArg)
173+ }
174+
175+ targetRegistry, err := targetRegistryDomain(ctx, handle)
176+ if err != nil {
177+ return "", "", "", err
178+ }
179+
167180 registry, imageName, _ = parseImageRef(classified.sourceRef)
168- if registry == "" || imageName == "" {
169- return "", "", "", fmt.Errorf("cannot parse registry/image from %q", imageArg)
181+ if registry == "" {
182+ if err := cliWritef(out, "Registry: %s %s\n", styles.Theme.Bold.Render(targetRegistry), styles.Theme.Muted.Render("(from target)")); err != nil {
183+ return "", "", "", err
184+ }
185+ return targetRegistry, classified.repository, classified.sourceRef, nil
186+ }
187+ if !sameRegistryHost(registry, targetRegistry) {
188+ return "", "", "", fmt.Errorf("image registry %q does not match the target Gordon registry %q; retag the image as %s/%s or select the matching remote", registry, targetRegistry, targetRegistry, imageName)
170189 }
171190 return registry, imageName, classified.sourceRef, nil
172191 }
173192
193+// targetRegistryDomain asks the target Gordon instance for its registry domain.
194+// Remote targets publish it on /admin/auth/verify, which any admin-scoped
195+// token can read, so push needs no admin:status:read scope. The local admin
196+// socket and servers that predate that field are asked through /admin/status.
197+func targetRegistryDomain(ctx context.Context, handle *controlPlaneHandle) (string, error) {
198+ if handle.isRemote {
199+ verify, err := handle.plane.VerifyAuth(ctx)
200+ if isForbidden(err) {
201+ return "", fmt.Errorf("failed to read target registry domain: token needs an admin scope (e.g. admin:apps:read): %w", err)
202+ }
203+ if err != nil {
204+ return "", fmt.Errorf("failed to read target registry domain: %w", err)
205+ }
206+ if !verify.Valid {
207+ return "", errors.New("failed to read target registry domain: token is not valid")
208+ }
209+ if registryDomain := strings.TrimSpace(verify.RegistryDomain); registryDomain != "" {
210+ return registryDomain, nil
211+ }
212+ }
213+
214+ status, err := handle.plane.GetStatus(ctx)
215+ if isForbidden(err) && handle.isRemote {
216+ return "", fmt.Errorf("failed to read target registry domain: the server did not report it on /admin/auth/verify (older server or server.registry_domain unset); upgrade or configure the server, or grant the token admin:status:read: %w", err)
217+ }
218+ if err != nil {
219+ return "", fmt.Errorf("failed to read target registry domain: %w", err)
220+ }
221+ registryDomain := strings.TrimSpace(status.RegistryDomain)
222+ if registryDomain == "" {
223+ return "", errors.New("target Gordon instance does not report a registry domain; set server.registry_domain")
224+ }
225+ return registryDomain, nil
226+}
227+
228+func isForbidden(err error) bool {
229+ httpErr, ok := errors.AsType[*remote.HTTPError](err)
230+ return ok && httpErr.StatusCode == http.StatusForbidden
231+}
232+
233+func sameRegistryHost(a, b string) bool {
234+ normalize := func(s string) string { return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(s)), ".") }
235+ return normalize(a) == normalize(b)
236+}
237+
238+// isRegistryHost applies Docker's rule for the first path component of an
239+// image reference: it names a registry only if it contains a dot or a port,
240+// or is "localhost".
241+func isRegistryHost(component string) bool {
242+ return strings.ContainsAny(component, ".:") || component == "localhost"
243+}
244+
174245 // resolveVersion determines and validates the version tag.
175246 func resolveVersion(ctx context.Context, tag string) (string, error) {
176247 version := determineVersion(ctx, tag)
@@ -189,11 +260,6 @@ func runPush(ctx context.Context, out io.Writer, req pushRequest) error {
189260 }
190261 defer handle.close()
191262
192- registry, imageName, sourceRef, err := resolveImageTarget(out, req.ImageArg, dockerfile)
193- if err != nil {
194- return err
195- }
196-
197263 version, err := resolveVersion(ctx, req.Tag)
198264 if err != nil {
199265 return err
@@ -202,6 +268,11 @@ func runPush(ctx context.Context, out io.Writer, req pushRequest) error {
202268 return err
203269 }
204270
271+ registry, imageName, sourceRef, err := resolveImageTarget(ctx, out, handle, req.ImageArg, dockerfile)
272+ if err != nil {
273+ return err
274+ }
275+
205276 img := imagePush{Registry: registry, ImageName: imageName, SourceRef: sourceRef, Version: version}
206277 img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)
207278
@@ -574,11 +645,12 @@ func isInteractiveTerminal() bool {
574645 return (info.Mode() & os.ModeCharDevice) != 0
575646 }
576647
577-// parseImageRef splits "registry/name:tag" into components.
648+// parseImageRef splits "registry/name:tag" into components. It returns empty
649+// values when the reference does not start with a registry host.
578650 func parseImageRef(image string) (registry, name, tag string) {
579- // Handle "reg.bnema.dev/jp-bot:latest" or "reg.bnema.dev/jp-bot"
651+ // Handle "reg.example.com/app:latest" or "reg.example.com/app"
580652 parts := strings.SplitN(image, "/", 2)
581- if len(parts) != 2 {
653+ if len(parts) != 2 || !isRegistryHost(parts[0]) {
582654 return "", "", ""
583655 }
584656 registry = parts[0]
@@ -6,6 +6,7 @@ import (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8 "io"8 "io"
9+ "net/http"
9 "os"10 "os"
10 "os/exec"11 "os/exec"
11 "path/filepath"12 "path/filepath"
@@ -147,10 +148,13 @@ func looksLikeLegacyDomain(arg string) bool {
147 }148 }
148 149
149 // resolveImageTarget determines the registry and image name from the input.150 // resolveImageTarget determines the registry and image name from the input.
151+// A bare image name (no registry host) is pushed to the target Gordon's
152+// registry domain. A fully qualified reference must name that same
153+// registry, so the Gordon registry credentials never leave the target.
150 // Push transfers OCI content only and never triggers deploys or route154 // Push transfers OCI content only and never triggers deploys or route
151 // lookups. The deploy target comes from the app manifest applied155 // lookups. The deploy target comes from the app manifest applied
152 // separately via `gordon apps apply`.156 // separately via `gordon apps apply`.
153-func resolveImageTarget(out io.Writer, imageArg, dockerfile string) (registry, imageName, sourceRef string, err error) {157+func resolveImageTarget(ctx context.Context, out io.Writer, handle *controlPlaneHandle, imageArg, dockerfile string) (registry, imageName, sourceRef string, err error) {
154 if imageArg == "" {158 if imageArg == "" {
155 imageArg, err = detectImageName(dockerfile)159 imageArg, err = detectImageName(dockerfile)
156 if err != nil {160 if err != nil {
@@ -164,13 +168,80 @@ func resolveImageTarget(out io.Writer, imageArg, dockerfile string) (registry, i
164 return "", "", "", fmt.Errorf("domain-style push targets are retired: push an image name, then `gordon apps apply` (got %q)", imageArg)168 return "", "", "", fmt.Errorf("domain-style push targets are retired: push an image name, then `gordon apps apply` (got %q)", imageArg)
165 }169 }
166 classified := classifyPushArgument(imageArg)170 classified := classifyPushArgument(imageArg)
171+ if classified.repository == "" {
172+ return "", "", "", fmt.Errorf("cannot parse image name from %q", imageArg)
173+ }
174+
175+ targetRegistry, err := targetRegistryDomain(ctx, handle)
176+ if err != nil {
177+ return "", "", "", err
178+ }
179+
167 registry, imageName, _ = parseImageRef(classified.sourceRef)180 registry, imageName, _ = parseImageRef(classified.sourceRef)
168- if registry == "" || imageName == "" {181+ if registry == "" {
169- return "", "", "", fmt.Errorf("cannot parse registry/image from %q", imageArg)182+ if err := cliWritef(out, "Registry: %s %s\n", styles.Theme.Bold.Render(targetRegistry), styles.Theme.Muted.Render("(from target)")); err != nil {
183+ return "", "", "", err
184+ }
185+ return targetRegistry, classified.repository, classified.sourceRef, nil
186+ }
187+ if !sameRegistryHost(registry, targetRegistry) {
188+ return "", "", "", fmt.Errorf("image registry %q does not match the target Gordon registry %q; retag the image as %s/%s or select the matching remote", registry, targetRegistry, targetRegistry, imageName)
170 }189 }
171 return registry, imageName, classified.sourceRef, nil190 return registry, imageName, classified.sourceRef, nil
172 }191 }
173 192
193+// targetRegistryDomain asks the target Gordon instance for its registry domain.
194+// Remote targets publish it on /admin/auth/verify, which any admin-scoped
195+// token can read, so push needs no admin:status:read scope. The local admin
196+// socket and servers that predate that field are asked through /admin/status.
197+func targetRegistryDomain(ctx context.Context, handle *controlPlaneHandle) (string, error) {
198+ if handle.isRemote {
199+ verify, err := handle.plane.VerifyAuth(ctx)
200+ if isForbidden(err) {
201+ return "", fmt.Errorf("failed to read target registry domain: token needs an admin scope (e.g. admin:apps:read): %w", err)
202+ }
203+ if err != nil {
204+ return "", fmt.Errorf("failed to read target registry domain: %w", err)
205+ }
206+ if !verify.Valid {
207+ return "", errors.New("failed to read target registry domain: token is not valid")
208+ }
209+ if registryDomain := strings.TrimSpace(verify.RegistryDomain); registryDomain != "" {
210+ return registryDomain, nil
211+ }
212+ }
213+
214+ status, err := handle.plane.GetStatus(ctx)
215+ if isForbidden(err) && handle.isRemote {
216+ return "", fmt.Errorf("failed to read target registry domain: the server did not report it on /admin/auth/verify (older server or server.registry_domain unset); upgrade or configure the server, or grant the token admin:status:read: %w", err)
217+ }
218+ if err != nil {
219+ return "", fmt.Errorf("failed to read target registry domain: %w", err)
220+ }
221+ registryDomain := strings.TrimSpace(status.RegistryDomain)
222+ if registryDomain == "" {
223+ return "", errors.New("target Gordon instance does not report a registry domain; set server.registry_domain")
224+ }
225+ return registryDomain, nil
226+}
227+
228+func isForbidden(err error) bool {
229+ httpErr, ok := errors.AsType[*remote.HTTPError](err)
230+ return ok && httpErr.StatusCode == http.StatusForbidden
231+}
232+
233+func sameRegistryHost(a, b string) bool {
234+ normalize := func(s string) string { return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(s)), ".") }
235+ return normalize(a) == normalize(b)
236+}
237+
238+// isRegistryHost applies Docker's rule for the first path component of an
239+// image reference: it names a registry only if it contains a dot or a port,
240+// or is "localhost".
241+func isRegistryHost(component string) bool {
242+ return strings.ContainsAny(component, ".:") || component == "localhost"
243+}
244+
174 // resolveVersion determines and validates the version tag.245 // resolveVersion determines and validates the version tag.
175 func resolveVersion(ctx context.Context, tag string) (string, error) {246 func resolveVersion(ctx context.Context, tag string) (string, error) {
176 version := determineVersion(ctx, tag)247 version := determineVersion(ctx, tag)
@@ -189,11 +260,6 @@ func runPush(ctx context.Context, out io.Writer, req pushRequest) error {
189 }260 }
190 defer handle.close()261 defer handle.close()
191 262
192- registry, imageName, sourceRef, err := resolveImageTarget(out, req.ImageArg, dockerfile)
193- if err != nil {
194- return err
195- }
196-
197 version, err := resolveVersion(ctx, req.Tag)263 version, err := resolveVersion(ctx, req.Tag)
198 if err != nil {264 if err != nil {
199 return err265 return err
@@ -202,6 +268,11 @@ func runPush(ctx context.Context, out io.Writer, req pushRequest) error {
202 return err268 return err
203 }269 }
204 270
271+ registry, imageName, sourceRef, err := resolveImageTarget(ctx, out, handle, req.ImageArg, dockerfile)
272+ if err != nil {
273+ return err
274+ }
275+
205 img := imagePush{Registry: registry, ImageName: imageName, SourceRef: sourceRef, Version: version}276 img := imagePush{Registry: registry, ImageName: imageName, SourceRef: sourceRef, Version: version}
206 img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)277 img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)
207 278
@@ -574,11 +645,12 @@ func isInteractiveTerminal() bool {
574 return (info.Mode() & os.ModeCharDevice) != 0645 return (info.Mode() & os.ModeCharDevice) != 0
575 }646 }
576 647
577-// parseImageRef splits "registry/name:tag" into components.648+// parseImageRef splits "registry/name:tag" into components. It returns empty
649+// values when the reference does not start with a registry host.
578 func parseImageRef(image string) (registry, name, tag string) {650 func parseImageRef(image string) (registry, name, tag string) {
579- // Handle "reg.bnema.dev/jp-bot:latest" or "reg.bnema.dev/jp-bot"651+ // Handle "reg.example.com/app:latest" or "reg.example.com/app"
580 parts := strings.SplitN(image, "/", 2)652 parts := strings.SplitN(image, "/", 2)
581- if len(parts) != 2 {653+ if len(parts) != 2 || !isRegistryHost(parts[0]) {
582 return "", "", ""654 return "", "", ""
583 }655 }
584 registry = parts[0]656 registry = parts[0]
A internal/adapters/in/cli/push_registry_target_test.go +142 -0
@@ -0,0 +1,142 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "net/http"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/gordon/internal/adapters/dto"
16+ climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
17+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
18+)
19+
20+// remoteWithRegistry returns a remote handle whose /auth/verify reports registryDomain.
21+func remoteWithRegistry(t *testing.T, registryDomain string) *controlPlaneHandle {
22+ t.Helper()
23+ plane := climocks.NewMockControlPlane(t)
24+ plane.EXPECT().VerifyAuth(mock.Anything).Return(&dto.AuthVerifyResponse{Valid: true, RegistryDomain: registryDomain}, nil).Once()
25+ return &controlPlaneHandle{plane: plane, isRemote: true}
26+}
27+
28+func TestResolveImageTarget_BareNameUsesTargetRegistry(t *testing.T) {
29+ tests := []struct {
30+ name string
31+ arg string
32+ wantName string
33+ wantSource string
34+ }{
35+ {"bare name", "myapp", "myapp", "myapp"},
36+ {"bare name with tag", "myapp:v1.2.3", "myapp", "myapp:v1.2.3"},
37+ {"namespaced name", "team/myapp:v1", "team/myapp", "team/myapp:v1"},
38+ {"bare digest", "myapp@sha256:deadbeef", "myapp", "myapp@sha256:deadbeef"},
39+ }
40+ for _, tt := range tests {
41+ t.Run(tt.name, func(t *testing.T) {
42+ var out bytes.Buffer
43+ registry, name, source, err := resolveImageTarget(context.Background(), &out, remoteWithRegistry(t, "reg.example.com"), tt.arg, "Dockerfile")
44+ require.NoError(t, err)
45+ assert.Equal(t, "reg.example.com", registry)
46+ assert.Equal(t, tt.wantName, name)
47+ assert.Equal(t, tt.wantSource, source)
48+ assert.Contains(t, out.String(), "reg.example.com")
49+ })
50+ }
51+}
52+
53+func TestResolveImageTarget_QualifiedRefMatchingTarget(t *testing.T) {
54+ registry, name, source, err := resolveImageTarget(context.Background(), io.Discard, remoteWithRegistry(t, "reg.example.com."), "Reg.Example.com/myapp:v1", "Dockerfile")
55+ require.NoError(t, err)
56+ assert.Equal(t, "Reg.Example.com", registry)
57+ assert.Equal(t, "myapp", name)
58+ assert.Equal(t, "Reg.Example.com/myapp:v1", source)
59+}
60+
61+func TestResolveImageTarget_RejectsMismatchedRegistry(t *testing.T) {
62+ for _, ref := range []string{"other.example.org/myapp:v1", "reg.example.com:443/myapp:v1"} {
63+ t.Run(ref, func(t *testing.T) {
64+ _, _, _, err := resolveImageTarget(context.Background(), io.Discard, remoteWithRegistry(t, "reg.example.com"), ref, "Dockerfile")
65+ require.Error(t, err)
66+ assert.Contains(t, err.Error(), "reg.example.com/myapp")
67+ })
68+ }
69+}
70+
71+func TestTargetRegistryDomain_RemoteFallsBackToStatusForOlderServers(t *testing.T) {
72+ plane := climocks.NewMockControlPlane(t)
73+ plane.EXPECT().VerifyAuth(mock.Anything).Return(&dto.AuthVerifyResponse{Valid: true}, nil).Once()
74+ plane.EXPECT().GetStatus(mock.Anything).Return(&remote.Status{RegistryDomain: "reg.example.com"}, nil).Once()
75+
76+ got, err := targetRegistryDomain(context.Background(), &controlPlaneHandle{plane: plane, isRemote: true})
77+ require.NoError(t, err)
78+ assert.Equal(t, "reg.example.com", got)
79+}
80+
81+func TestTargetRegistryDomain_LocalSocketUsesStatus(t *testing.T) {
82+ plane := climocks.NewMockControlPlane(t)
83+ plane.EXPECT().GetStatus(mock.Anything).Return(&remote.Status{RegistryDomain: "reg.example.com"}, nil).Once()
84+
85+ got, err := targetRegistryDomain(context.Background(), &controlPlaneHandle{plane: plane})
86+ require.NoError(t, err)
87+ assert.Equal(t, "reg.example.com", got)
88+}
89+
90+func TestTargetRegistryDomain_Errors(t *testing.T) {
91+ tests := []struct {
92+ name string
93+ setup func(*climocks.MockControlPlane)
94+ wantErr string
95+ }{
96+ {
97+ name: "verify fails",
98+ setup: func(p *climocks.MockControlPlane) {
99+ p.EXPECT().VerifyAuth(mock.Anything).Return(nil, errors.New("boom")).Once()
100+ },
101+ wantErr: "boom",
102+ },
103+ {
104+ name: "token without admin scope",
105+ setup: func(p *climocks.MockControlPlane) {
106+ p.EXPECT().VerifyAuth(mock.Anything).Return(nil, &remote.HTTPError{StatusCode: http.StatusForbidden, Status: "403 Forbidden"}).Once()
107+ },
108+ wantErr: "admin scope",
109+ },
110+ {
111+ name: "token invalid",
112+ setup: func(p *climocks.MockControlPlane) {
113+ p.EXPECT().VerifyAuth(mock.Anything).Return(&dto.AuthVerifyResponse{Valid: false}, nil).Once()
114+ },
115+ wantErr: "token is not valid",
116+ },
117+ {
118+ name: "older server denies status",
119+ setup: func(p *climocks.MockControlPlane) {
120+ p.EXPECT().VerifyAuth(mock.Anything).Return(&dto.AuthVerifyResponse{Valid: true}, nil).Once()
121+ p.EXPECT().GetStatus(mock.Anything).Return(nil, &remote.HTTPError{StatusCode: http.StatusForbidden, Status: "403 Forbidden"}).Once()
122+ },
123+ wantErr: "admin:status:read",
124+ },
125+ {
126+ name: "no registry domain configured",
127+ setup: func(p *climocks.MockControlPlane) {
128+ p.EXPECT().VerifyAuth(mock.Anything).Return(&dto.AuthVerifyResponse{Valid: true}, nil).Once()
129+ p.EXPECT().GetStatus(mock.Anything).Return(&remote.Status{}, nil).Once()
130+ },
131+ wantErr: "registry_domain",
132+ },
133+ }
134+ for _, tt := range tests {
135+ t.Run(tt.name, func(t *testing.T) {
136+ plane := climocks.NewMockControlPlane(t)
137+ tt.setup(plane)
138+ _, err := targetRegistryDomain(context.Background(), &controlPlaneHandle{plane: plane, isRemote: true})
139+ require.ErrorContains(t, err, tt.wantErr)
140+ })
141+ }
142+}
@@ -0,0 +1,142 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "net/http"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/gordon/internal/adapters/dto"
16+ climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
17+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
18+)
19+
20+// remoteWithRegistry returns a remote handle whose /auth/verify reports registryDomain.
21+func remoteWithRegistry(t *testing.T, registryDomain string) *controlPlaneHandle {
22+ t.Helper()
23+ plane := climocks.NewMockControlPlane(t)
24+ plane.EXPECT().VerifyAuth(mock.Anything).Return(&dto.AuthVerifyResponse{Valid: true, RegistryDomain: registryDomain}, nil).Once()
25+ return &controlPlaneHandle{plane: plane, isRemote: true}
26+}
27+
28+func TestResolveImageTarget_BareNameUsesTargetRegistry(t *testing.T) {
29+ tests := []struct {
30+ name string
31+ arg string
32+ wantName string
33+ wantSource string
34+ }{
35+ {"bare name", "myapp", "myapp", "myapp"},
36+ {"bare name with tag", "myapp:v1.2.3", "myapp", "myapp:v1.2.3"},
37+ {"namespaced name", "team/myapp:v1", "team/myapp", "team/myapp:v1"},
38+ {"bare digest", "myapp@sha256:deadbeef", "myapp", "myapp@sha256:deadbeef"},
39+ }
40+ for _, tt := range tests {
41+ t.Run(tt.name, func(t *testing.T) {
42+ var out bytes.Buffer
43+ registry, name, source, err := resolveImageTarget(context.Background(), &out, remoteWithRegistry(t, "reg.example.com"), tt.arg, "Dockerfile")
44+ require.NoError(t, err)
45+ assert.Equal(t, "reg.example.com", registry)
46+ assert.Equal(t, tt.wantName, name)
47+ assert.Equal(t, tt.wantSource, source)
48+ assert.Contains(t, out.String(), "reg.example.com")
49+ })
50+ }
51+}
52+
53+func TestResolveImageTarget_QualifiedRefMatchingTarget(t *testing.T) {
54+ registry, name, source, err := resolveImageTarget(context.Background(), io.Discard, remoteWithRegistry(t, "reg.example.com."), "Reg.Example.com/myapp:v1", "Dockerfile")
55+ require.NoError(t, err)
56+ assert.Equal(t, "Reg.Example.com", registry)
57+ assert.Equal(t, "myapp", name)
58+ assert.Equal(t, "Reg.Example.com/myapp:v1", source)
59+}
60+
61+func TestResolveImageTarget_RejectsMismatchedRegistry(t *testing.T) {
62+ for _, ref := range []string{"other.example.org/myapp:v1", "reg.example.com:443/myapp:v1"} {
63+ t.Run(ref, func(t *testing.T) {
64+ _, _, _, err := resolveImageTarget(context.Background(), io.Discard, remoteWithRegistry(t, "reg.example.com"), ref, "Dockerfile")
65+ require.Error(t, err)
66+ assert.Contains(t, err.Error(), "reg.example.com/myapp")
67+ })
68+ }
69+}
70+
71+func TestTargetRegistryDomain_RemoteFallsBackToStatusForOlderServers(t *testing.T) {
72+ plane := climocks.NewMockControlPlane(t)
73+ plane.EXPECT().VerifyAuth(mock.Anything).Return(&dto.AuthVerifyResponse{Valid: true}, nil).Once()
74+ plane.EXPECT().GetStatus(mock.Anything).Return(&remote.Status{RegistryDomain: "reg.example.com"}, nil).Once()
75+
76+ got, err := targetRegistryDomain(context.Background(), &controlPlaneHandle{plane: plane, isRemote: true})
77+ require.NoError(t, err)
78+ assert.Equal(t, "reg.example.com", got)
79+}
80+
81+func TestTargetRegistryDomain_LocalSocketUsesStatus(t *testing.T) {
82+ plane := climocks.NewMockControlPlane(t)
83+ plane.EXPECT().GetStatus(mock.Anything).Return(&remote.Status{RegistryDomain: "reg.example.com"}, nil).Once()
84+
85+ got, err := targetRegistryDomain(context.Background(), &controlPlaneHandle{plane: plane})
86+ require.NoError(t, err)
87+ assert.Equal(t, "reg.example.com", got)
88+}
89+
90+func TestTargetRegistryDomain_Errors(t *testing.T) {
91+ tests := []struct {
92+ name string
93+ setup func(*climocks.MockControlPlane)
94+ wantErr string
95+ }{
96+ {
97+ name: "verify fails",
98+ setup: func(p *climocks.MockControlPlane) {
99+ p.EXPECT().VerifyAuth(mock.Anything).Return(nil, errors.New("boom")).Once()
100+ },
101+ wantErr: "boom",
102+ },
103+ {
104+ name: "token without admin scope",
105+ setup: func(p *climocks.MockControlPlane) {
106+ p.EXPECT().VerifyAuth(mock.Anything).Return(nil, &remote.HTTPError{StatusCode: http.StatusForbidden, Status: "403 Forbidden"}).Once()
107+ },
108+ wantErr: "admin scope",
109+ },
110+ {
111+ name: "token invalid",
112+ setup: func(p *climocks.MockControlPlane) {
113+ p.EXPECT().VerifyAuth(mock.Anything).Return(&dto.AuthVerifyResponse{Valid: false}, nil).Once()
114+ },
115+ wantErr: "token is not valid",
116+ },
117+ {
118+ name: "older server denies status",
119+ setup: func(p *climocks.MockControlPlane) {
120+ p.EXPECT().VerifyAuth(mock.Anything).Return(&dto.AuthVerifyResponse{Valid: true}, nil).Once()
121+ p.EXPECT().GetStatus(mock.Anything).Return(nil, &remote.HTTPError{StatusCode: http.StatusForbidden, Status: "403 Forbidden"}).Once()
122+ },
123+ wantErr: "admin:status:read",
124+ },
125+ {
126+ name: "no registry domain configured",
127+ setup: func(p *climocks.MockControlPlane) {
128+ p.EXPECT().VerifyAuth(mock.Anything).Return(&dto.AuthVerifyResponse{Valid: true}, nil).Once()
129+ p.EXPECT().GetStatus(mock.Anything).Return(&remote.Status{}, nil).Once()
130+ },
131+ wantErr: "registry_domain",
132+ },
133+ }
134+ for _, tt := range tests {
135+ t.Run(tt.name, func(t *testing.T) {
136+ plane := climocks.NewMockControlPlane(t)
137+ tt.setup(plane)
138+ _, err := targetRegistryDomain(context.Background(), &controlPlaneHandle{plane: plane, isRemote: true})
139+ require.ErrorContains(t, err, tt.wantErr)
140+ })
141+ }
142+}
M internal/adapters/in/cli/push_test.go +4 -0
@@ -55,6 +55,10 @@ func TestParseImageRef(t *testing.T) {
5555 {"no tag", "reg.example.com/myapp", "reg.example.com", "myapp", "latest"},
5656 {"latest tag", "reg.example.com/myapp:latest", "reg.example.com", "myapp", "latest"},
5757 {"no slash", "myapp:v1.0.0", "", "", ""},
58+ {"namespace without registry host", "team/myapp:v1", "", "", ""},
59+ {"localhost", "localhost/myapp:v1", "localhost", "myapp", "v1"},
60+ {"registry with port", "localhost:5000/myapp:v1", "localhost:5000", "myapp", "v1"},
61+ {"registry with namespace", "reg.example.com/team/myapp", "reg.example.com", "team/myapp", "latest"},
5862 }
5963
6064 for _, tt := range tests {
@@ -55,6 +55,10 @@ func TestParseImageRef(t *testing.T) {
55 {"no tag", "reg.example.com/myapp", "reg.example.com", "myapp", "latest"},55 {"no tag", "reg.example.com/myapp", "reg.example.com", "myapp", "latest"},
56 {"latest tag", "reg.example.com/myapp:latest", "reg.example.com", "myapp", "latest"},56 {"latest tag", "reg.example.com/myapp:latest", "reg.example.com", "myapp", "latest"},
57 {"no slash", "myapp:v1.0.0", "", "", ""},57 {"no slash", "myapp:v1.0.0", "", "", ""},
58+ {"namespace without registry host", "team/myapp:v1", "", "", ""},
59+ {"localhost", "localhost/myapp:v1", "localhost", "myapp", "v1"},
60+ {"registry with port", "localhost:5000/myapp:v1", "localhost:5000", "myapp", "v1"},
61+ {"registry with namespace", "reg.example.com/team/myapp", "reg.example.com", "team/myapp", "latest"},
58 }62 }
59 63
60 for _, tt := range tests {64 for _, tt := range tests {
M internal/adapters/in/http/admin/handler.go +5 -0
@@ -932,6 +932,11 @@ func (h *Handler) handleAuthVerify(w http.ResponseWriter, r *http.Request) {
932932 ExpiresAt: status.ExpiresAt,
933933 IssuedAt: status.IssuedAt,
934934 }
935+ if status.Valid && h.configSvc != nil {
936+ // Push clients need the registry host but not admin:status:read,
937+ // so it is published to every admin-scoped token here.
938+ response.RegistryDomain = h.configSvc.GetRegistryDomain()
939+ }
935940
936941 h.sendJSON(w, http.StatusOK, response)
937942 }
@@ -932,6 +932,11 @@ func (h *Handler) handleAuthVerify(w http.ResponseWriter, r *http.Request) {
932 ExpiresAt: status.ExpiresAt,932 ExpiresAt: status.ExpiresAt,
933 IssuedAt: status.IssuedAt,933 IssuedAt: status.IssuedAt,
934 }934 }
935+ if status.Valid && h.configSvc != nil {
936+ // Push clients need the registry host but not admin:status:read,
937+ // so it is published to every admin-scoped token here.
938+ response.RegistryDomain = h.configSvc.GetRegistryDomain()
939+ }
935 940
936 h.sendJSON(w, http.StatusOK, response)941 h.sendJSON(w, http.StatusOK, response)
937 }942 }
M internal/adapters/in/http/admin/handler_test.go +35 -0
@@ -208,6 +208,41 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) {
208208 }
209209 }
210210
211+func TestHandler_AuthVerify_PublishesRegistryDomainForValidTokens(t *testing.T) {
212+ tests := []struct {
213+ name string
214+ valid bool
215+ wantDomain string
216+ }{
217+ {name: "valid token without status scope", valid: true, wantDomain: "registry.example.com"},
218+ {name: "invalid token", valid: false, wantDomain: ""},
219+ }
220+ for _, tt := range tests {
221+ t.Run(tt.name, func(t *testing.T) {
222+ configSvc := inmocks.NewMockConfigService(t)
223+ authSvc := inmocks.NewMockAuthService(t)
224+ handler := newTestHandler(t, func(d *HandlerDeps) {
225+ d.ConfigSvc = configSvc
226+ d.AuthSvc = authSvc
227+ })
228+ authSvc.EXPECT().GetAuthStatus(mock.Anything).Return(&domain.AuthStatus{Valid: tt.valid, Subject: "ci"}, nil).Once()
229+ if tt.valid {
230+ configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Once()
231+ }
232+
233+ req := httptest.NewRequest(http.MethodGet, "/admin/auth/verify", nil)
234+ req = req.WithContext(ctxWithScopes("push", "pull", "admin:apps:read", "admin:apps:write"))
235+ rec := httptest.NewRecorder()
236+ handler.ServeHTTP(rec, req)
237+
238+ require.Equal(t, http.StatusOK, rec.Code)
239+ var resp dto.AuthVerifyResponse
240+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
241+ assert.Equal(t, tt.wantDomain, resp.RegistryDomain)
242+ })
243+ }
244+}
245+
211246 // Config endpoint tests
212247
213248 func TestHandler_Config_HidesSensitiveInventoryByDefault(t *testing.T) {
@@ -208,6 +208,41 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) {
208 }208 }
209 }209 }
210 210
211+func TestHandler_AuthVerify_PublishesRegistryDomainForValidTokens(t *testing.T) {
212+ tests := []struct {
213+ name string
214+ valid bool
215+ wantDomain string
216+ }{
217+ {name: "valid token without status scope", valid: true, wantDomain: "registry.example.com"},
218+ {name: "invalid token", valid: false, wantDomain: ""},
219+ }
220+ for _, tt := range tests {
221+ t.Run(tt.name, func(t *testing.T) {
222+ configSvc := inmocks.NewMockConfigService(t)
223+ authSvc := inmocks.NewMockAuthService(t)
224+ handler := newTestHandler(t, func(d *HandlerDeps) {
225+ d.ConfigSvc = configSvc
226+ d.AuthSvc = authSvc
227+ })
228+ authSvc.EXPECT().GetAuthStatus(mock.Anything).Return(&domain.AuthStatus{Valid: tt.valid, Subject: "ci"}, nil).Once()
229+ if tt.valid {
230+ configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Once()
231+ }
232+
233+ req := httptest.NewRequest(http.MethodGet, "/admin/auth/verify", nil)
234+ req = req.WithContext(ctxWithScopes("push", "pull", "admin:apps:read", "admin:apps:write"))
235+ rec := httptest.NewRecorder()
236+ handler.ServeHTTP(rec, req)
237+
238+ require.Equal(t, http.StatusOK, rec.Code)
239+ var resp dto.AuthVerifyResponse
240+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
241+ assert.Equal(t, tt.wantDomain, resp.RegistryDomain)
242+ })
243+ }
244+}
245+
211 // Config endpoint tests246 // Config endpoint tests
212 247
213 func TestHandler_Config_HidesSensitiveInventoryByDefault(t *testing.T) {248 func TestHandler_Config_HidesSensitiveInventoryByDefault(t *testing.T) {

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