detached 2b6f969

fix: address review — DeployIntent always called, repo validation, docker login stdin, ARG docs

- Always call DeployIntent even with --no-deploy to suppress server auto-deploy
- Validate repo is non-empty in parseAndConvertScopes (returns error)
- Use --password-stdin for docker login example in deployment docs
- Add ARG usage note for injected build env vars in push docs
commit
2b6f9698b558a09c69bdc59b6c9e2478b486689b
author
brice · · 2026-03-19 05:09 UTC
parent
9bfe1d5
signature
unsigned
5 files · +41 -16
M docs/cli/push.md +3 -1
@@ -50,7 +50,9 @@ To push attachment images (databases, caches, etc.), use `gordon attachments pus
5050 `v1.2.3-4-gabc1234` or `v1.2.3-dirty`). If no tag is found, `latest` is used.
5151 - When `--build` is set, the command builds with `docker buildx build --load`
5252 and injects `VERSION`, `GIT_TAG`, `GIT_SHA`, and `BUILD_TIME` into the build
53- environment plus any `--build-arg` values.
53+ environment plus any `--build-arg` values. To use these in your Dockerfile,
54+ declare them with `ARG` (e.g., `ARG VERSION`) then reference via `ENV` or
55+ in build steps.
5456 - Use `-f/--file` to build from a Dockerfile outside the current directory root.
5557 - The version tag and `latest` are both pushed (unless the version is `latest`).
5658
@@ -50,7 +50,9 @@ To push attachment images (databases, caches, etc.), use `gordon attachments pus
50 `v1.2.3-4-gabc1234` or `v1.2.3-dirty`). If no tag is found, `latest` is used.50 `v1.2.3-4-gabc1234` or `v1.2.3-dirty`). If no tag is found, `latest` is used.
51 - When `--build` is set, the command builds with `docker buildx build --load`51 - When `--build` is set, the command builds with `docker buildx build --load`
52 and injects `VERSION`, `GIT_TAG`, `GIT_SHA`, and `BUILD_TIME` into the build52 and injects `VERSION`, `GIT_TAG`, `GIT_SHA`, and `BUILD_TIME` into the build
53- environment plus any `--build-arg` values.53+ environment plus any `--build-arg` values. To use these in your Dockerfile,
54+ declare them with `ARG` (e.g., `ARG VERSION`) then reference via `ENV` or
55+ in build steps.
54 - Use `-f/--file` to build from a Dockerfile outside the current directory root.56 - Use `-f/--file` to build from a Dockerfile outside the current directory root.
55 - The version tag and `latest` are both pushed (unless the version is `latest`).57 - The version tag and `latest` are both pushed (unless the version is `latest`).
56 58
M docs/deployment/index.md +1 -1
@@ -50,7 +50,7 @@ Standard Docker workflow — no Gordon binary required on the runner.
5050 - Registry endpoint is `gordon.example.com` (not a separate registry host)
5151
5252 ```bash
53-docker login -u ci-bot -p "$GORDON_TOKEN" gordon.example.com
53+echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin gordon.example.com
5454 docker build -t gordon.example.com/myapp:v1.2.0 .
5555 docker push gordon.example.com/myapp:v1.2.0
5656 ```
@@ -50,7 +50,7 @@ Standard Docker workflow — no Gordon binary required on the runner.
50 - Registry endpoint is `gordon.example.com` (not a separate registry host)50 - Registry endpoint is `gordon.example.com` (not a separate registry host)
51 51
52 ```bash52 ```bash
53-docker login -u ci-bot -p "$GORDON_TOKEN" gordon.example.com53+echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin gordon.example.com
54 docker build -t gordon.example.com/myapp:v1.2.0 .54 docker build -t gordon.example.com/myapp:v1.2.0 .
55 docker push gordon.example.com/myapp:v1.2.055 docker push gordon.example.com/myapp:v1.2.0
56 ```56 ```
M internal/adapters/in/cli/auth.go +13 -5
@@ -441,7 +441,12 @@ The hash can be stored in your secrets backend and referenced in the config:
441441 // runTokenGenerate generates a new authentication token.
442442 // parseAndConvertScopes parses a comma-separated scope string and converts
443443 // simple scopes (push, pull, *) to Docker v2 format (repository:*:action).
444-func parseAndConvertScopes(scopesStr, repo string) []string {
444+func parseAndConvertScopes(scopesStr, repo string) ([]string, error) {
445+ repo = strings.TrimSpace(repo)
446+ if repo == "" {
447+ return nil, fmt.Errorf("repository name cannot be empty")
448+ }
449+
445450 rawScopes := strings.Split(scopesStr, ",")
446451 for i := range rawScopes {
447452 rawScopes[i] = strings.TrimSpace(rawScopes[i])
@@ -457,10 +462,10 @@ func parseAndConvertScopes(scopesStr, repo string) []string {
457462 }
458463
459464 if !hasSimpleScope {
460- return rawScopes
465+ return rawScopes, nil
461466 }
462467
463- // Convert simple scopes to v2 format: repository:*:push,pull
468+ // Convert simple scopes to v2 format: repository:<repo>:push,pull
464469 var scopes []string
465470 var actions []string
466471 for _, s := range rawScopes {
@@ -474,7 +479,7 @@ func parseAndConvertScopes(scopesStr, repo string) []string {
474479 if len(actions) > 0 {
475480 scopes = append(scopes, "repository:"+repo+":"+strings.Join(actions, ","))
476481 }
477- return scopes
482+ return scopes, nil
478483 }
479484
480485 func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) error {
@@ -492,7 +497,10 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) er
492497 }
493498 }
494499
495- scopes := parseAndConvertScopes(scopesStr, repo)
500+ scopes, err := parseAndConvertScopes(scopesStr, repo)
501+ if err != nil {
502+ return fmt.Errorf("invalid scopes: %w", err)
503+ }
496504
497505 // Create auth service
498506 log := zerowrap.New(zerowrap.Config{Level: "warn"})
@@ -441,7 +441,12 @@ The hash can be stored in your secrets backend and referenced in the config:
441 // runTokenGenerate generates a new authentication token.441 // runTokenGenerate generates a new authentication token.
442 // parseAndConvertScopes parses a comma-separated scope string and converts442 // parseAndConvertScopes parses a comma-separated scope string and converts
443 // simple scopes (push, pull, *) to Docker v2 format (repository:*:action).443 // simple scopes (push, pull, *) to Docker v2 format (repository:*:action).
444-func parseAndConvertScopes(scopesStr, repo string) []string {444+func parseAndConvertScopes(scopesStr, repo string) ([]string, error) {
445+ repo = strings.TrimSpace(repo)
446+ if repo == "" {
447+ return nil, fmt.Errorf("repository name cannot be empty")
448+ }
449+
445 rawScopes := strings.Split(scopesStr, ",")450 rawScopes := strings.Split(scopesStr, ",")
446 for i := range rawScopes {451 for i := range rawScopes {
447 rawScopes[i] = strings.TrimSpace(rawScopes[i])452 rawScopes[i] = strings.TrimSpace(rawScopes[i])
@@ -457,10 +462,10 @@ func parseAndConvertScopes(scopesStr, repo string) []string {
457 }462 }
458 463
459 if !hasSimpleScope {464 if !hasSimpleScope {
460- return rawScopes465+ return rawScopes, nil
461 }466 }
462 467
463- // Convert simple scopes to v2 format: repository:*:push,pull468+ // Convert simple scopes to v2 format: repository:<repo>:push,pull
464 var scopes []string469 var scopes []string
465 var actions []string470 var actions []string
466 for _, s := range rawScopes {471 for _, s := range rawScopes {
@@ -474,7 +479,7 @@ func parseAndConvertScopes(scopesStr, repo string) []string {
474 if len(actions) > 0 {479 if len(actions) > 0 {
475 scopes = append(scopes, "repository:"+repo+":"+strings.Join(actions, ","))480 scopes = append(scopes, "repository:"+repo+":"+strings.Join(actions, ","))
476 }481 }
477- return scopes482+ return scopes, nil
478 }483 }
479 484
480 func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) error {485 func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) error {
@@ -492,7 +497,10 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) er
492 }497 }
493 }498 }
494 499
495- scopes := parseAndConvertScopes(scopesStr, repo)500+ scopes, err := parseAndConvertScopes(scopesStr, repo)
501+ if err != nil {
502+ return fmt.Errorf("invalid scopes: %w", err)
503+ }
496 504
497 // Create auth service505 // Create auth service
498 log := zerowrap.New(zerowrap.Config{Level: "warn"})506 log := zerowrap.New(zerowrap.Config{Level: "warn"})
M internal/adapters/in/cli/auth_scopes_test.go +21 -5
@@ -4,30 +4,46 @@ import (
44 "testing"
55
66 "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
78 )
89
910 func TestParseAndConvertScopes_DefaultWildcard(t *testing.T) {
10- got := parseAndConvertScopes("push,pull", "*")
11+ got, err := parseAndConvertScopes("push,pull", "*")
12+ require.NoError(t, err)
1113 assert.Equal(t, []string{"repository:*:push,pull"}, got)
1214 }
1315
1416 func TestParseAndConvertScopes_SpecificRepo(t *testing.T) {
15- got := parseAndConvertScopes("push,pull", "myapp")
17+ got, err := parseAndConvertScopes("push,pull", "myapp")
18+ require.NoError(t, err)
1619 assert.Equal(t, []string{"repository:myapp:push,pull"}, got)
1720 }
1821
1922 func TestParseAndConvertScopes_SpecificRepoPushOnly(t *testing.T) {
20- got := parseAndConvertScopes("push", "myapp")
23+ got, err := parseAndConvertScopes("push", "myapp")
24+ require.NoError(t, err)
2125 assert.Equal(t, []string{"repository:myapp:push"}, got)
2226 }
2327
2428 func TestParseAndConvertScopes_MixedWithAdminScopes(t *testing.T) {
25- got := parseAndConvertScopes("push,pull,admin:routes:read", "myapp")
29+ got, err := parseAndConvertScopes("push,pull,admin:routes:read", "myapp")
30+ require.NoError(t, err)
2631 assert.ElementsMatch(t, []string{"repository:myapp:push,pull", "admin:routes:read"}, got)
2732 }
2833
2934 func TestParseAndConvertScopes_AlreadyV2Format_RepoIgnored(t *testing.T) {
3035 // When scopes are already in v2 format, --repo is ignored (scopes returned as-is)
31- got := parseAndConvertScopes("repository:specific:push", "*")
36+ got, err := parseAndConvertScopes("repository:specific:push", "*")
37+ require.NoError(t, err)
3238 assert.Equal(t, []string{"repository:specific:push"}, got)
3339 }
40+
41+func TestParseAndConvertScopes_EmptyRepo(t *testing.T) {
42+ _, err := parseAndConvertScopes("push,pull", "")
43+ assert.EqualError(t, err, "repository name cannot be empty")
44+}
45+
46+func TestParseAndConvertScopes_WhitespaceRepo(t *testing.T) {
47+ _, err := parseAndConvertScopes("push,pull", " ")
48+ assert.EqualError(t, err, "repository name cannot be empty")
49+}
@@ -4,30 +4,46 @@ import (
4 "testing"4 "testing"
5 5
6 "github.com/stretchr/testify/assert"6 "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
7 )8 )
8 9
9 func TestParseAndConvertScopes_DefaultWildcard(t *testing.T) {10 func TestParseAndConvertScopes_DefaultWildcard(t *testing.T) {
10- got := parseAndConvertScopes("push,pull", "*")11+ got, err := parseAndConvertScopes("push,pull", "*")
12+ require.NoError(t, err)
11 assert.Equal(t, []string{"repository:*:push,pull"}, got)13 assert.Equal(t, []string{"repository:*:push,pull"}, got)
12 }14 }
13 15
14 func TestParseAndConvertScopes_SpecificRepo(t *testing.T) {16 func TestParseAndConvertScopes_SpecificRepo(t *testing.T) {
15- got := parseAndConvertScopes("push,pull", "myapp")17+ got, err := parseAndConvertScopes("push,pull", "myapp")
18+ require.NoError(t, err)
16 assert.Equal(t, []string{"repository:myapp:push,pull"}, got)19 assert.Equal(t, []string{"repository:myapp:push,pull"}, got)
17 }20 }
18 21
19 func TestParseAndConvertScopes_SpecificRepoPushOnly(t *testing.T) {22 func TestParseAndConvertScopes_SpecificRepoPushOnly(t *testing.T) {
20- got := parseAndConvertScopes("push", "myapp")23+ got, err := parseAndConvertScopes("push", "myapp")
24+ require.NoError(t, err)
21 assert.Equal(t, []string{"repository:myapp:push"}, got)25 assert.Equal(t, []string{"repository:myapp:push"}, got)
22 }26 }
23 27
24 func TestParseAndConvertScopes_MixedWithAdminScopes(t *testing.T) {28 func TestParseAndConvertScopes_MixedWithAdminScopes(t *testing.T) {
25- got := parseAndConvertScopes("push,pull,admin:routes:read", "myapp")29+ got, err := parseAndConvertScopes("push,pull,admin:routes:read", "myapp")
30+ require.NoError(t, err)
26 assert.ElementsMatch(t, []string{"repository:myapp:push,pull", "admin:routes:read"}, got)31 assert.ElementsMatch(t, []string{"repository:myapp:push,pull", "admin:routes:read"}, got)
27 }32 }
28 33
29 func TestParseAndConvertScopes_AlreadyV2Format_RepoIgnored(t *testing.T) {34 func TestParseAndConvertScopes_AlreadyV2Format_RepoIgnored(t *testing.T) {
30 // When scopes are already in v2 format, --repo is ignored (scopes returned as-is)35 // When scopes are already in v2 format, --repo is ignored (scopes returned as-is)
31- got := parseAndConvertScopes("repository:specific:push", "*")36+ got, err := parseAndConvertScopes("repository:specific:push", "*")
37+ require.NoError(t, err)
32 assert.Equal(t, []string{"repository:specific:push"}, got)38 assert.Equal(t, []string{"repository:specific:push"}, got)
33 }39 }
40+
41+func TestParseAndConvertScopes_EmptyRepo(t *testing.T) {
42+ _, err := parseAndConvertScopes("push,pull", "")
43+ assert.EqualError(t, err, "repository name cannot be empty")
44+}
45+
46+func TestParseAndConvertScopes_WhitespaceRepo(t *testing.T) {
47+ _, err := parseAndConvertScopes("push,pull", " ")
48+ assert.EqualError(t, err, "repository name cannot be empty")
49+}
M internal/adapters/in/cli/push.go +3 -4
@@ -233,9 +233,8 @@ func runPush(ctx context.Context, req pushRequest) error {
233233 // shouldSkipDeploy signals deploy intent and returns whether the explicit deploy
234234 // should be skipped (either because --no-deploy was set, or the token lacks scope).
235235 func shouldSkipDeploy(ctx context.Context, plane ControlPlane, imageName string, noDeploy bool) bool {
236- if noDeploy {
237- return true
238- }
236+ // Always call DeployIntent to suppress server-side auto-deploy, even with --no-deploy.
237+ // Without this, the server's event listener would still auto-deploy the pushed image.
239238 if err := plane.DeployIntent(ctx, imageName); err != nil {
240239 if isInsufficientScope(err) {
241240 fmt.Fprintln(os.Stderr, "info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive")
@@ -244,7 +243,7 @@ func shouldSkipDeploy(ctx context.Context, plane ControlPlane, imageName string,
244243 // Non-fatal: worst case we get a redundant deploy via event
245244 fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err)
246245 }
247- return false
246+ return noDeploy
248247 }
249248
250249 // isInsufficientScope returns true if the error is an HTTP 403 Forbidden response,
@@ -233,9 +233,8 @@ func runPush(ctx context.Context, req pushRequest) error {
233 // shouldSkipDeploy signals deploy intent and returns whether the explicit deploy233 // 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).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 {235 func shouldSkipDeploy(ctx context.Context, plane ControlPlane, imageName string, noDeploy bool) bool {
236- if noDeploy {236+ // Always call DeployIntent to suppress server-side auto-deploy, even with --no-deploy.
237- return true237+ // Without this, the server's event listener would still auto-deploy the pushed image.
238- }
239 if err := plane.DeployIntent(ctx, imageName); err != nil {238 if err := plane.DeployIntent(ctx, imageName); err != nil {
240 if isInsufficientScope(err) {239 if isInsufficientScope(err) {
241 fmt.Fprintln(os.Stderr, "info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive")240 fmt.Fprintln(os.Stderr, "info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive")
@@ -244,7 +243,7 @@ func shouldSkipDeploy(ctx context.Context, plane ControlPlane, imageName string,
244 // Non-fatal: worst case we get a redundant deploy via event243 // 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)244 fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err)
246 }245 }
247- return false246+ return noDeploy
248 }247 }
249 248
250 // isInsufficientScope returns true if the error is an HTTP 403 Forbidden response,249 // isInsufficientScope returns true if the error is an HTTP 403 Forbidden response,

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