detached a4e31e9

Merge pull request #132 from bnema/feature/push-token-auth

feat(push): auto-exchange token for registry auth in remote mode
commit
a4e31e931ccd74e61d2247df91bf455c7ee92041
author
bnema · · 2026-03-19 05:12 UTC
committer
GitHub · · 2026-03-19 05:12 UTC
merge of
abb3644 + 2b6f969 · diff against the first parent
signature
unsigned
18 files · +1532 -214
A .github/actions/setup-gordon/action.yml +78 -0
@@ -0,0 +1,78 @@
1+name: 'Setup Gordon'
2+description: 'Install the Gordon CLI binary into the runner PATH'
3+author: 'Gordon Contributors'
4+
5+branding:
6+ icon: 'download-cloud'
7+ color: 'blue'
8+
9+inputs:
10+ version:
11+ description: 'Gordon version to install (e.g. v2.0.0 or latest)'
12+ required: false
13+ default: 'latest'
14+
15+outputs:
16+ version:
17+ description: 'The version of Gordon that was installed'
18+ value: ${{ steps.install.outputs.version }}
19+ path:
20+ description: 'Path to the installed Gordon binary'
21+ value: ${{ steps.install.outputs.path }}
22+
23+runs:
24+ using: 'composite'
25+ steps:
26+ - name: Install Gordon
27+ id: install
28+ shell: bash
29+ run: |
30+ set -euo pipefail
31+
32+ # Map RUNNER_OS to gordon OS identifier
33+ case "${RUNNER_OS}" in
34+ Linux) os="linux" ;;
35+ macOS) os="darwin" ;;
36+ *) echo "Unsupported OS: ${RUNNER_OS}" >&2; exit 1 ;;
37+ esac
38+
39+ # Map RUNNER_ARCH to gordon arch identifier
40+ case "${RUNNER_ARCH}" in
41+ X64) arch="amd64" ;;
42+ ARM64) arch="arm64" ;;
43+ *) echo "Unsupported architecture: ${RUNNER_ARCH}" >&2; exit 1 ;;
44+ esac
45+
46+ version="${{ inputs.version }}"
47+
48+ # Resolve 'latest' to an actual tag via redirect URL
49+ if [ "${version}" = "latest" ]; then
50+ redirect_url="$(curl -fsSL -o /dev/null -w '%{redirect_url}' "https://github.com/bnema/gordon/releases/latest")"
51+ version="${redirect_url##*/}"
52+ if [ -z "${version}" ]; then
53+ echo "Failed to resolve latest Gordon version" >&2
54+ exit 1
55+ fi
56+ fi
57+
58+ archive="gordon_${os}_${arch}.tar.gz"
59+ url="https://github.com/bnema/gordon/releases/download/${version}/${archive}"
60+ dest="/tmp/${archive}"
61+ install_path="/usr/local/bin/gordon"
62+
63+ echo "Installing Gordon ${version} (${os}/${arch}) from ${url}"
64+
65+ # Download the release archive
66+ curl -fsSL -o "${dest}" "${url}"
67+
68+ # Extract just the gordon binary and clean up
69+ tar -xzf "${dest}" -C /usr/local/bin/ gordon
70+ rm -f "${dest}"
71+
72+ # Make executable and verify
73+ chmod +x "${install_path}"
74+ gordon version
75+
76+ # Set outputs
77+ echo "version=${version}" >> "${GITHUB_OUTPUT}"
78+ echo "path=${install_path}" >> "${GITHUB_OUTPUT}"
@@ -0,0 +1,78 @@
1+name: 'Setup Gordon'
2+description: 'Install the Gordon CLI binary into the runner PATH'
3+author: 'Gordon Contributors'
4+
5+branding:
6+ icon: 'download-cloud'
7+ color: 'blue'
8+
9+inputs:
10+ version:
11+ description: 'Gordon version to install (e.g. v2.0.0 or latest)'
12+ required: false
13+ default: 'latest'
14+
15+outputs:
16+ version:
17+ description: 'The version of Gordon that was installed'
18+ value: ${{ steps.install.outputs.version }}
19+ path:
20+ description: 'Path to the installed Gordon binary'
21+ value: ${{ steps.install.outputs.path }}
22+
23+runs:
24+ using: 'composite'
25+ steps:
26+ - name: Install Gordon
27+ id: install
28+ shell: bash
29+ run: |
30+ set -euo pipefail
31+
32+ # Map RUNNER_OS to gordon OS identifier
33+ case "${RUNNER_OS}" in
34+ Linux) os="linux" ;;
35+ macOS) os="darwin" ;;
36+ *) echo "Unsupported OS: ${RUNNER_OS}" >&2; exit 1 ;;
37+ esac
38+
39+ # Map RUNNER_ARCH to gordon arch identifier
40+ case "${RUNNER_ARCH}" in
41+ X64) arch="amd64" ;;
42+ ARM64) arch="arm64" ;;
43+ *) echo "Unsupported architecture: ${RUNNER_ARCH}" >&2; exit 1 ;;
44+ esac
45+
46+ version="${{ inputs.version }}"
47+
48+ # Resolve 'latest' to an actual tag via redirect URL
49+ if [ "${version}" = "latest" ]; then
50+ redirect_url="$(curl -fsSL -o /dev/null -w '%{redirect_url}' "https://github.com/bnema/gordon/releases/latest")"
51+ version="${redirect_url##*/}"
52+ if [ -z "${version}" ]; then
53+ echo "Failed to resolve latest Gordon version" >&2
54+ exit 1
55+ fi
56+ fi
57+
58+ archive="gordon_${os}_${arch}.tar.gz"
59+ url="https://github.com/bnema/gordon/releases/download/${version}/${archive}"
60+ dest="/tmp/${archive}"
61+ install_path="/usr/local/bin/gordon"
62+
63+ echo "Installing Gordon ${version} (${os}/${arch}) from ${url}"
64+
65+ # Download the release archive
66+ curl -fsSL -o "${dest}" "${url}"
67+
68+ # Extract just the gordon binary and clean up
69+ tar -xzf "${dest}" -C /usr/local/bin/ gordon
70+ rm -f "${dest}"
71+
72+ # Make executable and verify
73+ chmod +x "${install_path}"
74+ gordon version
75+
76+ # Set outputs
77+ echo "version=${version}" >> "${GITHUB_OUTPUT}"
78+ echo "path=${install_path}" >> "${GITHUB_OUTPUT}"
M docs/cli/push.md +62 -12
@@ -7,14 +7,14 @@ Tag, push, and optionally deploy an image to the Gordon registry.
77 ### Synopsis
88
99 ```bash
10-gordon push <domain> [options]
10+gordon push [image] [options]
1111 ```
1212
1313 ### Arguments
1414
1515 | Argument | Description |
1616 |----------|-------------|
17-| `<domain>` | The route domain to resolve the registry and repository from |
17+| `[image]` | Image name to push (optional). If omitted, auto-detected from Dockerfile labels or current directory name |
1818
1919 ### Options
2020
@@ -27,14 +27,20 @@ gordon push <domain> [options]
2727 | `--tag` | Override version tag (default: tag ref from CI, then `git describe --tags --dirty`) |
2828 | `--no-confirm` | Skip deploy confirmation prompt |
2929 | `--no-deploy` | Push only; skip deployment prompt |
30+| `--domain` | Explicit domain override (legacy mode) |
3031 | `--remote` | Remote Gordon URL |
3132 | `--token` | Authentication token for remote |
3233
3334 ### Description
3435
35-`gordon push` resolves the route image for `<domain>`, tags the image for the
36+`gordon push` resolves the target image from the backend, tags it for the
3637 Gordon registry, pushes it, and optionally deploys it.
3738
39+Image resolution order:
40+1. If `--domain` is provided, uses domain-based route lookup
41+2. If an image name is provided as argument, resolves domain(s) from the backend
42+3. If no argument, auto-detects from Dockerfile `gordon.domain` label or current directory name
43+
3844 To push attachment images (databases, caches, etc.), use `gordon attachments push`.
3945
4046 - For first deploys, run `gordon bootstrap` first to create or update the route, attachments, and secrets, then run `gordon push` to upload and deploy the image.
@@ -43,33 +49,75 @@ To push attachment images (databases, caches, etc.), use `gordon attachments pus
4349 then falls back to `git describe --tags --dirty` (for example
4450 `v1.2.3-4-gabc1234` or `v1.2.3-dirty`). If no tag is found, `latest` is used.
4551 - When `--build` is set, the command builds with `docker buildx build --load`
46- and injects `VERSION` into the build environment plus any `--build-arg` values.
52+ and injects `VERSION`, `GIT_TAG`, `GIT_SHA`, and `BUILD_TIME` into the build
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.
4756 - Use `-f/--file` to build from a Dockerfile outside the current directory root.
4857 - The version tag and `latest` are both pushed (unless the version is `latest`).
4958
59+### Authentication
60+
61+When used with `--remote`, gordon push authenticates in two ways:
62+
63+- **Admin API** (route resolution, deploy): uses `--token` or `$GORDON_TOKEN` as Bearer token
64+- **Registry push**: automatically exchanges the token for a short-lived (5 min) registry access token via `/auth/token` -- no `docker login` required
65+
66+This means CI/CD pipelines only need a single secret (`GORDON_TOKEN`).
67+
68+### Deploy Modes
69+
70+When the token has `admin:config:write` scope, the CLI manages deployment explicitly
71+(DeployIntent → push → Deploy). This gives the CLI control over deploy timing.
72+
73+When the token lacks `admin:config:write`, the CLI pushes the image and the server
74+auto-deploys when it receives it via its event listener. The CLI logs:
75+`info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive`
76+
77+### Version Auto-Detection
78+
79+Gordon reads version tags from CI environment variables (in priority order):
80+
81+| CI System | Variable | Example |
82+|-----------|----------|---------|
83+| GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` |
84+| GitHub Actions | `$GITHUB_REF_TYPE` + `$GITHUB_REF_NAME` | `tag` + `v1.2.0` |
85+| GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` |
86+| Azure DevOps | `$BUILD_SOURCEBRANCH` | `refs/tags/v1.2.0` |
87+| Any | `git describe --tags` | `v1.2.3-4-gabc1234` |
88+| Fallback | - | `latest` |
89+
5090 ### Examples
5191
5292 ```bash
53-# Build, push, and deploy
54-gordon push myapp.example.com --build
93+# Build, push, and deploy (auto-detect image name)
94+gordon push --build --remote https://gordon.example.com --no-confirm
95+
96+# Specify image name explicitly
97+gordon push myapp --build --remote https://gordon.example.com --no-confirm
5598
5699 # Push existing local image, skip deploy
57-gordon push myapp.example.com --tag v1.2.0 --no-deploy
100+gordon push myapp --tag v1.2.0 --no-deploy
58101
59102 # Push and deploy without confirmation
60-gordon push myapp.example.com --no-confirm
103+gordon push myapp --no-confirm
61104
62105 # Build for ARM and pass build args
63-gordon push myapp.example.com --build --platform linux/arm64 --build-arg CGO_ENABLED=0
106+gordon push myapp --build --platform linux/arm64 --build-arg CGO_ENABLED=0
64107
65108 # Build from a custom Dockerfile path
66-gordon push myapp.example.com --build -f docker/app/Dockerfile
109+gordon push myapp --build -f docker/app/Dockerfile
110+
111+# CI/CD usage (single env var, no docker login needed)
112+export GORDON_TOKEN="your-token"
113+gordon push --build --remote https://gordon.example.com --no-confirm
67114 ```
68115
69116 ### Notes
70117
71118 - Remote mode required. See [CLI Overview](./index.md) for targeting options.
72-- `gordon push` still requires the route to already exist so it can resolve the target image.
119+- `gordon push` requires the route to already exist so it can resolve the target image.
120+ Use `gordon bootstrap` for first deploys.
73121 - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux,
74122 install the `docker-buildx-plugin` package.
75123 - Gordon uses native registry uploads instead of shelling out to `docker push`.
@@ -81,6 +129,8 @@ gordon push myapp.example.com --build -f docker/app/Dockerfile
81129 ## Related
82130
83131 - [CLI Overview](./index.md)
132+- [Deployment Overview](../deployment/index.md)
133+- [GitHub Actions](../deployment/github-actions.md)
84134 - [Attachments Commands](./attachments.md)
85135 - [Routes Command](./routes.md)
86-- [Serve Command](./serve.md)
136+- [Authentication](../config/auth.md)
@@ -7,14 +7,14 @@ Tag, push, and optionally deploy an image to the Gordon registry.
7 ### Synopsis7 ### Synopsis
8 8
9 ```bash9 ```bash
10-gordon push <domain> [options]10+gordon push [image] [options]
11 ```11 ```
12 12
13 ### Arguments13 ### Arguments
14 14
15 | Argument | Description |15 | Argument | Description |
16 |----------|-------------|16 |----------|-------------|
17-| `<domain>` | The route domain to resolve the registry and repository from |17+| `[image]` | Image name to push (optional). If omitted, auto-detected from Dockerfile labels or current directory name |
18 18
19 ### Options19 ### Options
20 20
@@ -27,14 +27,20 @@ gordon push <domain> [options]
27 | `--tag` | Override version tag (default: tag ref from CI, then `git describe --tags --dirty`) |27 | `--tag` | Override version tag (default: tag ref from CI, then `git describe --tags --dirty`) |
28 | `--no-confirm` | Skip deploy confirmation prompt |28 | `--no-confirm` | Skip deploy confirmation prompt |
29 | `--no-deploy` | Push only; skip deployment prompt |29 | `--no-deploy` | Push only; skip deployment prompt |
30+| `--domain` | Explicit domain override (legacy mode) |
30 | `--remote` | Remote Gordon URL |31 | `--remote` | Remote Gordon URL |
31 | `--token` | Authentication token for remote |32 | `--token` | Authentication token for remote |
32 33
33 ### Description34 ### Description
34 35
35-`gordon push` resolves the route image for `<domain>`, tags the image for the36+`gordon push` resolves the target image from the backend, tags it for the
36 Gordon registry, pushes it, and optionally deploys it.37 Gordon registry, pushes it, and optionally deploys it.
37 38
39+Image resolution order:
40+1. If `--domain` is provided, uses domain-based route lookup
41+2. If an image name is provided as argument, resolves domain(s) from the backend
42+3. If no argument, auto-detects from Dockerfile `gordon.domain` label or current directory name
43+
38 To push attachment images (databases, caches, etc.), use `gordon attachments push`.44 To push attachment images (databases, caches, etc.), use `gordon attachments push`.
39 45
40 - For first deploys, run `gordon bootstrap` first to create or update the route, attachments, and secrets, then run `gordon push` to upload and deploy the image.46 - For first deploys, run `gordon bootstrap` first to create or update the route, attachments, and secrets, then run `gordon push` to upload and deploy the image.
@@ -43,33 +49,75 @@ To push attachment images (databases, caches, etc.), use `gordon attachments pus
43 then falls back to `git describe --tags --dirty` (for example49 then falls back to `git describe --tags --dirty` (for example
44 `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.
45 - 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`
46- and injects `VERSION` into the build environment plus any `--build-arg` values.52+ and injects `VERSION`, `GIT_TAG`, `GIT_SHA`, and `BUILD_TIME` into the build
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.
47 - 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.
48 - 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`).
49 58
59+### Authentication
60+
61+When used with `--remote`, gordon push authenticates in two ways:
62+
63+- **Admin API** (route resolution, deploy): uses `--token` or `$GORDON_TOKEN` as Bearer token
64+- **Registry push**: automatically exchanges the token for a short-lived (5 min) registry access token via `/auth/token` -- no `docker login` required
65+
66+This means CI/CD pipelines only need a single secret (`GORDON_TOKEN`).
67+
68+### Deploy Modes
69+
70+When the token has `admin:config:write` scope, the CLI manages deployment explicitly
71+(DeployIntent → push → Deploy). This gives the CLI control over deploy timing.
72+
73+When the token lacks `admin:config:write`, the CLI pushes the image and the server
74+auto-deploys when it receives it via its event listener. The CLI logs:
75+`info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive`
76+
77+### Version Auto-Detection
78+
79+Gordon reads version tags from CI environment variables (in priority order):
80+
81+| CI System | Variable | Example |
82+|-----------|----------|---------|
83+| GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` |
84+| GitHub Actions | `$GITHUB_REF_TYPE` + `$GITHUB_REF_NAME` | `tag` + `v1.2.0` |
85+| GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` |
86+| Azure DevOps | `$BUILD_SOURCEBRANCH` | `refs/tags/v1.2.0` |
87+| Any | `git describe --tags` | `v1.2.3-4-gabc1234` |
88+| Fallback | - | `latest` |
89+
50 ### Examples90 ### Examples
51 91
52 ```bash92 ```bash
53-# Build, push, and deploy93+# Build, push, and deploy (auto-detect image name)
54-gordon push myapp.example.com --build94+gordon push --build --remote https://gordon.example.com --no-confirm
95+
96+# Specify image name explicitly
97+gordon push myapp --build --remote https://gordon.example.com --no-confirm
55 98
56 # Push existing local image, skip deploy99 # Push existing local image, skip deploy
57-gordon push myapp.example.com --tag v1.2.0 --no-deploy100+gordon push myapp --tag v1.2.0 --no-deploy
58 101
59 # Push and deploy without confirmation102 # Push and deploy without confirmation
60-gordon push myapp.example.com --no-confirm103+gordon push myapp --no-confirm
61 104
62 # Build for ARM and pass build args105 # Build for ARM and pass build args
63-gordon push myapp.example.com --build --platform linux/arm64 --build-arg CGO_ENABLED=0106+gordon push myapp --build --platform linux/arm64 --build-arg CGO_ENABLED=0
64 107
65 # Build from a custom Dockerfile path108 # Build from a custom Dockerfile path
66-gordon push myapp.example.com --build -f docker/app/Dockerfile109+gordon push myapp --build -f docker/app/Dockerfile
110+
111+# CI/CD usage (single env var, no docker login needed)
112+export GORDON_TOKEN="your-token"
113+gordon push --build --remote https://gordon.example.com --no-confirm
67 ```114 ```
68 115
69 ### Notes116 ### Notes
70 117
71 - Remote mode required. See [CLI Overview](./index.md) for targeting options.118 - Remote mode required. See [CLI Overview](./index.md) for targeting options.
72-- `gordon push` still requires the route to already exist so it can resolve the target image.119+- `gordon push` requires the route to already exist so it can resolve the target image.
120+ Use `gordon bootstrap` for first deploys.
73 - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux,121 - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux,
74 install the `docker-buildx-plugin` package.122 install the `docker-buildx-plugin` package.
75 - Gordon uses native registry uploads instead of shelling out to `docker push`.123 - Gordon uses native registry uploads instead of shelling out to `docker push`.
@@ -81,6 +129,8 @@ gordon push myapp.example.com --build -f docker/app/Dockerfile
81 ## Related129 ## Related
82 130
83 - [CLI Overview](./index.md)131 - [CLI Overview](./index.md)
132+- [Deployment Overview](../deployment/index.md)
133+- [GitHub Actions](../deployment/github-actions.md)
84 - [Attachments Commands](./attachments.md)134 - [Attachments Commands](./attachments.md)
85 - [Routes Command](./routes.md)135 - [Routes Command](./routes.md)
86-- [Serve Command](./serve.md)136+- [Authentication](../config/auth.md)
A docs/deployment/generic-ci.md +203 -0
@@ -0,0 +1,203 @@
1+# Generic CI/CD Deployment
2+
3+Deploy with Gordon from any CI/CD system.
4+
5+## Requirements
6+
7+- Docker available on CI runner (for building images)
8+- Network access to your Gordon server (HTTPS)
9+- Gordon binary (optional but recommended)
10+
11+## Recommended: gordon push
12+
13+### 1. Generate Deployment Token
14+
15+On your Gordon server:
16+
17+```bash
18+gordon auth token generate \
19+ --subject ci-deploy \
20+ --scopes "push,pull,admin:routes:read,admin:config:write" \
21+ --expiry 0
22+```
23+
24+### 2. Configure CI Secrets
25+
26+Add these secrets to your CI system:
27+
28+| Secret | Value |
29+|--------|-------|
30+| `GORDON_TOKEN` | The generated token |
31+| `GORDON_REMOTE` | `https://gordon.example.com` |
32+
33+### 3. Pipeline Steps
34+
35+```bash
36+# 1. Install Gordon binary
37+curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \
38+ -o /usr/local/bin/gordon
39+chmod +x /usr/local/bin/gordon
40+
41+# 2. Build, push, and deploy
42+export GORDON_TOKEN="$GORDON_TOKEN"
43+gordon push --build \
44+ --remote "$GORDON_REMOTE" \
45+ --no-confirm
46+```
47+
48+Gordon auto-detects the version from CI environment variables:
49+
50+| CI System | Variable | Example |
51+|-----------|----------|---------|
52+| GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` |
53+| GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` |
54+| Azure DevOps | `$BUILD_SOURCEBRANCH` | `refs/tags/v1.2.0` |
55+| Other | `--tag` flag | `gordon push --tag v1.2.0` |
56+
57+## Alternative: docker push
58+
59+If installing the Gordon binary is not possible:
60+
61+```bash
62+# 1. Login to Gordon registry
63+echo "$GORDON_TOKEN" | docker login gordon.example.com \
64+ -u ci-deploy --password-stdin
65+
66+# 2. Build and push
67+docker build -t gordon.example.com/myapp:v1.0.0 .
68+docker push gordon.example.com/myapp:v1.0.0
69+```
70+
71+This requires the token subject (`ci-deploy`) as the username.
72+Gordon auto-deploys when it receives the image.
73+
74+## CI System Examples
75+
76+### Jenkins
77+
78+```groovy
79+pipeline {
80+ agent any
81+ environment {
82+ GORDON_TOKEN = credentials('gordon-token')
83+ GORDON_REMOTE = 'https://gordon.example.com'
84+ }
85+ stages {
86+ stage('Deploy') {
87+ steps {
88+ sh '''
89+ curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \
90+ -o /usr/local/bin/gordon
91+ chmod +x /usr/local/bin/gordon
92+ gordon push --build --remote "$GORDON_REMOTE" --no-confirm
93+ '''
94+ }
95+ }
96+ }
97+}
98+```
99+
100+### CircleCI
101+
102+```yaml
103+version: 2.1
104+
105+jobs:
106+ deploy:
107+ docker:
108+ - image: docker:24
109+ steps:
110+ - checkout
111+ - setup_remote_docker
112+ - run:
113+ name: Install Gordon
114+ command: |
115+ apk add --no-cache curl
116+ curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \
117+ -o /usr/local/bin/gordon
118+ chmod +x /usr/local/bin/gordon
119+ - run:
120+ name: Deploy
121+ command: |
122+ gordon push --build \
123+ --remote "$GORDON_REMOTE" \
124+ --no-confirm
125+
126+workflows:
127+ deploy:
128+ jobs:
129+ - deploy:
130+ filters:
131+ tags:
132+ only: /^v.*/
133+ branches:
134+ ignore: /.*/
135+```
136+
137+### Drone
138+
139+```yaml
140+kind: pipeline
141+type: docker
142+name: deploy
143+
144+steps:
145+ - name: deploy
146+ image: docker:24
147+ environment:
148+ GORDON_TOKEN:
149+ from_secret: gordon_token
150+ GORDON_REMOTE:
151+ from_secret: gordon_remote
152+ commands:
153+ - apk add --no-cache curl
154+ - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
155+ - chmod +x /usr/local/bin/gordon
156+ - gordon push --build --remote "$GORDON_REMOTE" --no-confirm
157+
158+trigger:
159+ event:
160+ - tag
161+```
162+
163+## Token Scopes Reference
164+
165+| Workflow | Required Scopes |
166+|----------|----------------|
167+| Build + push + deploy | `push,pull,admin:routes:read,admin:config:write` |
168+| Push only (no deploy) | `push,pull,admin:routes:read` |
169+| docker push (auto-deploy) | `push,pull` |
170+
171+## Troubleshooting
172+
173+### "failed to verify token"
174+
175+The token is invalid or has been revoked. Generate a new one.
176+
177+### "no route configured for image"
178+
179+The route must exist before pushing. Create it with:
180+
181+```bash
182+gordon routes add myapp.example.com myapp
183+# or for first deploy:
184+gordon bootstrap myapp.example.com myapp
185+```
186+
187+### Version shows "latest"
188+
189+Gordon could not detect a version tag. Either:
190+- Tag your git repo: `git tag v1.0.0 && git push --tags`
191+- Pass explicitly: `gordon push --tag v1.0.0`
192+
193+### Large images time out
194+
195+Gordon uploads in 50MB chunks. For very large images (> 1GB), the push may take several minutes. Ensure your CI runner timeout is sufficient.
196+
197+## Related
198+
199+- [GitHub Actions](./github-actions.md)
200+- [GitLab CI](./gitlab-ci.md)
201+- [Deployment Overview](./index.md)
202+- [Authentication](../config/auth.md)
203+- [Push Command](../cli/push.md)
@@ -0,0 +1,203 @@
1+# Generic CI/CD Deployment
2+
3+Deploy with Gordon from any CI/CD system.
4+
5+## Requirements
6+
7+- Docker available on CI runner (for building images)
8+- Network access to your Gordon server (HTTPS)
9+- Gordon binary (optional but recommended)
10+
11+## Recommended: gordon push
12+
13+### 1. Generate Deployment Token
14+
15+On your Gordon server:
16+
17+```bash
18+gordon auth token generate \
19+ --subject ci-deploy \
20+ --scopes "push,pull,admin:routes:read,admin:config:write" \
21+ --expiry 0
22+```
23+
24+### 2. Configure CI Secrets
25+
26+Add these secrets to your CI system:
27+
28+| Secret | Value |
29+|--------|-------|
30+| `GORDON_TOKEN` | The generated token |
31+| `GORDON_REMOTE` | `https://gordon.example.com` |
32+
33+### 3. Pipeline Steps
34+
35+```bash
36+# 1. Install Gordon binary
37+curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \
38+ -o /usr/local/bin/gordon
39+chmod +x /usr/local/bin/gordon
40+
41+# 2. Build, push, and deploy
42+export GORDON_TOKEN="$GORDON_TOKEN"
43+gordon push --build \
44+ --remote "$GORDON_REMOTE" \
45+ --no-confirm
46+```
47+
48+Gordon auto-detects the version from CI environment variables:
49+
50+| CI System | Variable | Example |
51+|-----------|----------|---------|
52+| GitHub Actions | `$GITHUB_REF` | `refs/tags/v1.2.0` |
53+| GitLab CI | `$CI_COMMIT_TAG` | `v1.2.0` |
54+| Azure DevOps | `$BUILD_SOURCEBRANCH` | `refs/tags/v1.2.0` |
55+| Other | `--tag` flag | `gordon push --tag v1.2.0` |
56+
57+## Alternative: docker push
58+
59+If installing the Gordon binary is not possible:
60+
61+```bash
62+# 1. Login to Gordon registry
63+echo "$GORDON_TOKEN" | docker login gordon.example.com \
64+ -u ci-deploy --password-stdin
65+
66+# 2. Build and push
67+docker build -t gordon.example.com/myapp:v1.0.0 .
68+docker push gordon.example.com/myapp:v1.0.0
69+```
70+
71+This requires the token subject (`ci-deploy`) as the username.
72+Gordon auto-deploys when it receives the image.
73+
74+## CI System Examples
75+
76+### Jenkins
77+
78+```groovy
79+pipeline {
80+ agent any
81+ environment {
82+ GORDON_TOKEN = credentials('gordon-token')
83+ GORDON_REMOTE = 'https://gordon.example.com'
84+ }
85+ stages {
86+ stage('Deploy') {
87+ steps {
88+ sh '''
89+ curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \
90+ -o /usr/local/bin/gordon
91+ chmod +x /usr/local/bin/gordon
92+ gordon push --build --remote "$GORDON_REMOTE" --no-confirm
93+ '''
94+ }
95+ }
96+ }
97+}
98+```
99+
100+### CircleCI
101+
102+```yaml
103+version: 2.1
104+
105+jobs:
106+ deploy:
107+ docker:
108+ - image: docker:24
109+ steps:
110+ - checkout
111+ - setup_remote_docker
112+ - run:
113+ name: Install Gordon
114+ command: |
115+ apk add --no-cache curl
116+ curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 \
117+ -o /usr/local/bin/gordon
118+ chmod +x /usr/local/bin/gordon
119+ - run:
120+ name: Deploy
121+ command: |
122+ gordon push --build \
123+ --remote "$GORDON_REMOTE" \
124+ --no-confirm
125+
126+workflows:
127+ deploy:
128+ jobs:
129+ - deploy:
130+ filters:
131+ tags:
132+ only: /^v.*/
133+ branches:
134+ ignore: /.*/
135+```
136+
137+### Drone
138+
139+```yaml
140+kind: pipeline
141+type: docker
142+name: deploy
143+
144+steps:
145+ - name: deploy
146+ image: docker:24
147+ environment:
148+ GORDON_TOKEN:
149+ from_secret: gordon_token
150+ GORDON_REMOTE:
151+ from_secret: gordon_remote
152+ commands:
153+ - apk add --no-cache curl
154+ - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
155+ - chmod +x /usr/local/bin/gordon
156+ - gordon push --build --remote "$GORDON_REMOTE" --no-confirm
157+
158+trigger:
159+ event:
160+ - tag
161+```
162+
163+## Token Scopes Reference
164+
165+| Workflow | Required Scopes |
166+|----------|----------------|
167+| Build + push + deploy | `push,pull,admin:routes:read,admin:config:write` |
168+| Push only (no deploy) | `push,pull,admin:routes:read` |
169+| docker push (auto-deploy) | `push,pull` |
170+
171+## Troubleshooting
172+
173+### "failed to verify token"
174+
175+The token is invalid or has been revoked. Generate a new one.
176+
177+### "no route configured for image"
178+
179+The route must exist before pushing. Create it with:
180+
181+```bash
182+gordon routes add myapp.example.com myapp
183+# or for first deploy:
184+gordon bootstrap myapp.example.com myapp
185+```
186+
187+### Version shows "latest"
188+
189+Gordon could not detect a version tag. Either:
190+- Tag your git repo: `git tag v1.0.0 && git push --tags`
191+- Pass explicitly: `gordon push --tag v1.0.0`
192+
193+### Large images time out
194+
195+Gordon uploads in 50MB chunks. For very large images (> 1GB), the push may take several minutes. Ensure your CI runner timeout is sufficient.
196+
197+## Related
198+
199+- [GitHub Actions](./github-actions.md)
200+- [GitLab CI](./gitlab-ci.md)
201+- [Deployment Overview](./index.md)
202+- [Authentication](../config/auth.md)
203+- [Push Command](../cli/push.md)
M docs/deployment/github-actions.md +252 -52
@@ -1,21 +1,26 @@
11 # GitHub Actions Deployment
22
3-Automated deployment with GitHub Actions using Gordon's official action.
3+Two approaches for deploying with GitHub Actions: the `gordon push` CLI (recommended) or a Docker-based workflow using Gordon's official action.
44
55 ## Prerequisites
66
77 1. Gordon server running with registry authentication enabled
8-2. Generated deployment token
8+2. Deployment token generated with the required scopes
99 3. GitHub repository secrets configured
1010
11-## Quick Setup
11+## Recommended: gordon push
1212
13-### 1. Generate Deployment Token
13+Use the `gordon push` CLI for a lightweight, single-step build and deploy.
14+
15+### 1. Generate Token
1416
1517 On your Gordon server:
1618
1719 ```bash
18-gordon auth token generate --subject github-actions --scopes push,pull --expiry 0
20+gordon auth token generate \
21+ --subject github-actions \
22+ --scopes "push,pull,admin:routes:read,admin:config:write" \
23+ --expiry 0
1924 ```
2025
2126 Save the token output.
@@ -26,70 +31,238 @@ In your repository: Settings → Secrets → Actions → New repository secret
2631
2732 | Secret | Value |
2833 |--------|-------|
29-| `GORDON_REGISTRY` | `registry.mydomain.com` |
30-| `GORDON_USERNAME` | `github-actions` |
3134 | `GORDON_TOKEN` | The generated token |
35+| `GORDON_REMOTE` | `https://gordon.example.com` |
3236
33-### 3. Create Workflow
37+### 3. Setup Gordon CLI
3438
35-Create `.github/workflows/deploy.yml`:
39+Add this step to your workflow to install the Gordon binary:
40+
41+```yaml
42+- name: Setup Gordon
43+ uses: bnema/gordon/.github/actions/setup-gordon@main
44+ # with:
45+ # version: latest # default, or pin to e.g. v2.0.0
46+```
47+
48+### 4. Workflow Examples
49+
50+#### Deploy on Tag Push
51+
52+Gordon auto-detects the version from `$GITHUB_REF` (e.g., `refs/tags/v1.2.0` → `v1.2.0`).
3653
3754 ```yaml
3855 name: Deploy to Gordon
3956
4057 on:
4158 push:
42- tags:
43- - 'v*'
59+ tags: ['v*']
4460
4561 jobs:
4662 deploy:
4763 runs-on: ubuntu-latest
4864 steps:
49- - uses: actions/checkout@v6
65+ - uses: actions/checkout@v4
66+
67+ - name: Setup Gordon
68+ uses: bnema/gordon/.github/actions/setup-gordon@main
69+
70+ - name: Build and Deploy
71+ env:
72+ GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
73+ run: |
74+ gordon push --build \
75+ --remote ${{ secrets.GORDON_REMOTE }} \
76+ --no-confirm
77+```
5078
51- - name: Deploy to Gordon
52- uses: bnema/gordon/.github/actions/deploy@main
79+#### Continuous Deploy on Main
80+
81+Deploy on every push to main. The version defaults to `latest` or the output of `git describe`.
82+
83+```yaml
84+name: Deploy to Gordon
85+
86+on:
87+ push:
88+ branches:
89+ - main
90+
91+jobs:
92+ deploy:
93+ runs-on: ubuntu-latest
94+ steps:
95+ - uses: actions/checkout@v4
5396 with:
54- registry: ${{ secrets.GORDON_REGISTRY }}
55- username: ${{ secrets.GORDON_USERNAME }}
56- password: ${{ secrets.GORDON_TOKEN }}
97+ fetch-depth: 0
98+
99+ - name: Setup Gordon
100+ uses: bnema/gordon/.github/actions/setup-gordon@main
101+
102+ - name: Build and Deploy
103+ env:
104+ GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
105+ run: |
106+ gordon push --build \
107+ --remote ${{ secrets.GORDON_REMOTE }} \
108+ --tag latest \
109+ --no-confirm
57110 ```
58111
59-### 4. Deploy
112+#### Manual Dispatch
60113
61-```bash
62-git tag v1.0.0
63-git push origin v1.0.0
114+Allow manual deployments with an optional tag input:
115+
116+```yaml
117+name: Deploy to Gordon
118+
119+on:
120+ workflow_dispatch:
121+ inputs:
122+ tag:
123+ description: 'Tag to deploy (leave empty to use git describe)'
124+ required: false
125+
126+jobs:
127+ deploy:
128+ runs-on: ubuntu-latest
129+ steps:
130+ - uses: actions/checkout@v4
131+ with:
132+ ref: ${{ github.event.inputs.tag || github.ref }}
133+ fetch-depth: 0
134+
135+ - name: Setup Gordon
136+ uses: bnema/gordon/.github/actions/setup-gordon@main
137+
138+ - name: Build and Deploy
139+ env:
140+ GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
141+ run: |
142+ gordon push --build \
143+ --remote ${{ secrets.GORDON_REMOTE }} \
144+ ${{ github.event.inputs.tag && format('--tag {0}', github.event.inputs.tag) || '' }} \
145+ --no-confirm
146+```
147+
148+#### Monorepo
149+
150+Deploy multiple services with separate `gordon push` calls:
151+
152+```yaml
153+name: Deploy Services
154+
155+on:
156+ push:
157+ tags: ['v*']
158+
159+jobs:
160+ deploy:
161+ runs-on: ubuntu-latest
162+ steps:
163+ - uses: actions/checkout@v4
164+
165+ - name: Setup Gordon
166+ uses: bnema/gordon/.github/actions/setup-gordon@main
167+
168+ - name: Deploy API
169+ env:
170+ GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
171+ run: |
172+ gordon push --build \
173+ --remote ${{ secrets.GORDON_REMOTE }} \
174+ --file ./services/api/Dockerfile \
175+ myapp-api \
176+ --no-confirm
177+
178+ - name: Deploy Web
179+ env:
180+ GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
181+ run: |
182+ gordon push --build \
183+ --remote ${{ secrets.GORDON_REMOTE }} \
184+ --file ./services/web/Dockerfile \
185+ myapp-web \
186+ --no-confirm
64187 ```
65188
66-## Action Reference
189+#### With Build Args
190+
191+Pass build arguments to the Docker build:
192+
193+```yaml
194+- name: Build and Deploy
195+ env:
196+ GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
197+ run: |
198+ gordon push --build \
199+ --remote ${{ secrets.GORDON_REMOTE }} \
200+ --build-arg NODE_ENV=production \
201+ --build-arg API_URL=https://api.example.com \
202+ --build-arg BUILD_DATE=${{ github.event.head_commit.timestamp }} \
203+ --no-confirm
204+```
67205
68-### Inputs
206+### setup-gordon Action Reference
69207
70208 | Input | Required | Default | Description |
71209 |-------|----------|---------|-------------|
72-| `registry` | Yes | - | Gordon registry URL |
73-| `username` | Yes | - | Registry username |
74-| `password` | Yes | - | Registry token |
75-| `image` | No | repo name | Image name |
76-| `tag` | No | git tag/SHA | Image tag |
77-| `dockerfile` | No | `./Dockerfile` | Dockerfile path |
78-| `context` | No | `.` | Build context |
79-| `push-latest` | No | `false` | Also push :latest tag |
80-| `platforms` | No | `linux/amd64` | Target platforms |
81-| `build-args` | No | - | Build arguments |
82-
83-### Outputs
210+| `version` | No | `latest` | Gordon version to install (`v2.0.0` or `latest`) |
84211
85212 | Output | Description |
86213 |--------|-------------|
87-| `image` | Full image name |
88-| `tag` | Image tag |
214+| `version` | The version actually installed |
215+| `path` | Path to the installed binary |
216+
217+## Alternative: Docker-based Workflow
89218
90-## Workflow Examples
219+For environments where installing the Gordon binary is not desired, use Docker directly to build and push to the Gordon registry. Gordon auto-deploys when it receives the image — no explicit deploy step is needed.
91220
92-### Deploy on Tag Push
221+### Setup
222+
223+Add these secrets to your repository:
224+
225+| Secret | Value |
226+|--------|-------|
227+| `GORDON_REGISTRY` | `registry.gordon.example.com` |
228+| `GORDON_USERNAME` | `github-actions` |
229+| `GORDON_TOKEN` | The generated token |
230+
231+### Workflow
232+
233+```yaml
234+name: Deploy to Gordon
235+
236+on:
237+ push:
238+ tags: ['v*']
239+
240+jobs:
241+ deploy:
242+ runs-on: ubuntu-latest
243+ steps:
244+ - uses: actions/checkout@v4
245+
246+ - name: Login to Gordon Registry
247+ run: echo "${{ secrets.GORDON_TOKEN }}" | docker login ${{ secrets.GORDON_REGISTRY }} -u ${{ secrets.GORDON_USERNAME }} --password-stdin
248+
249+ - name: Build and Push
250+ run: |
251+ docker build -t ${{ secrets.GORDON_REGISTRY }}/myapp:${{ github.ref_name }} .
252+ docker push ${{ secrets.GORDON_REGISTRY }}/myapp:${{ github.ref_name }}
253+```
254+
255+## Alternative: Gordon GitHub Action
256+
257+Use the official `bnema/gordon` action for a fully declarative workflow. This action handles login, build, and push in a single step.
258+
259+### Setup
260+
261+Requires the same 3 secrets as the Docker-based workflow: `GORDON_REGISTRY`, `GORDON_USERNAME`, `GORDON_TOKEN`.
262+
263+### Workflow Examples
264+
265+#### Deploy on Tag Push
93266
94267 ```yaml
95268 name: Deploy to Gordon
@@ -103,7 +276,7 @@ jobs:
103276 deploy:
104277 runs-on: ubuntu-latest
105278 steps:
106- - uses: actions/checkout@v6
279+ - uses: actions/checkout@v4
107280
108281 - name: Deploy to Gordon
109282 uses: bnema/gordon/.github/actions/deploy@main
@@ -113,7 +286,7 @@ jobs:
113286 password: ${{ secrets.GORDON_TOKEN }}
114287 ```
115288
116-### Continuous Deployment
289+#### Continuous Deployment
117290
118291 Deploy on every push to main:
119292
@@ -129,7 +302,7 @@ jobs:
129302 deploy:
130303 runs-on: ubuntu-latest
131304 steps:
132- - uses: actions/checkout@v6
305+ - uses: actions/checkout@v4
133306
134307 - name: Deploy to Gordon
135308 uses: bnema/gordon/.github/actions/deploy@main
@@ -140,9 +313,9 @@ jobs:
140313 push-latest: 'true'
141314 ```
142315
143-### Manual Deployment
316+#### Manual Deployment
144317
145-Allow manual deployments with custom tag:
318+Allow manual deployments with a custom tag:
146319
147320 ```yaml
148321 name: Deploy to Gordon
@@ -158,7 +331,7 @@ jobs:
158331 deploy:
159332 runs-on: ubuntu-latest
160333 steps:
161- - uses: actions/checkout@v6
334+ - uses: actions/checkout@v4
162335 with:
163336 ref: ${{ github.event.inputs.tag || github.ref }}
164337
@@ -171,7 +344,7 @@ jobs:
171344 tag: ${{ github.event.inputs.tag }}
172345 ```
173346
174-### Monorepo Deployment
347+#### Monorepo Deployment
175348
176349 Deploy multiple services:
177350
@@ -187,7 +360,7 @@ jobs:
187360 deploy-api:
188361 runs-on: ubuntu-latest
189362 steps:
190- - uses: actions/checkout@v6
363+ - uses: actions/checkout@v4
191364 - uses: bnema/gordon/.github/actions/deploy@main
192365 with:
193366 registry: ${{ secrets.GORDON_REGISTRY }}
@@ -200,7 +373,7 @@ jobs:
200373 deploy-web:
201374 runs-on: ubuntu-latest
202375 steps:
203- - uses: actions/checkout@v6
376+ - uses: actions/checkout@v4
204377 - uses: bnema/gordon/.github/actions/deploy@main
205378 with:
206379 registry: ${{ secrets.GORDON_REGISTRY }}
@@ -211,7 +384,7 @@ jobs:
211384 context: ./services/web
212385 ```
213386
214-### With Build Arguments
387+#### With Build Arguments
215388
216389 ```yaml
217390 - uses: bnema/gordon/.github/actions/deploy@main
@@ -225,7 +398,7 @@ jobs:
225398 BUILD_DATE=${{ github.event.head_commit.timestamp }}
226399 ```
227400
228-### Multi-Platform Build
401+#### Multi-Platform Build
229402
230403 ```yaml
231404 - uses: bnema/gordon/.github/actions/deploy@main
@@ -236,7 +409,31 @@ jobs:
236409 platforms: linux/amd64,linux/arm64
237410 ```
238411
239-## Deployment Summary
412+### Action Reference
413+
414+#### Inputs
415+
416+| Input | Required | Default | Description |
417+|-------|----------|---------|-------------|
418+| `registry` | Yes | - | Gordon registry URL |
419+| `username` | Yes | - | Registry username |
420+| `password` | Yes | - | Registry token |
421+| `image` | No | repo name | Image name |
422+| `tag` | No | git tag/SHA | Image tag |
423+| `dockerfile` | No | `./Dockerfile` | Dockerfile path |
424+| `context` | No | `.` | Build context |
425+| `push-latest` | No | `false` | Also push :latest tag |
426+| `platforms` | No | `linux/amd64` | Target platforms |
427+| `build-args` | No | - | Build arguments |
428+
429+#### Outputs
430+
431+| Output | Description |
432+|--------|-------------|
433+| `image` | Full image name |
434+| `tag` | Image tag |
435+
436+#### Deployment Summary
240437
241438 Add a deployment summary to your workflow:
242439
@@ -277,6 +474,9 @@ Error: unauthorized: authentication required
277474
278475 ## Related
279476
280-- [Authentication](../config/auth.md)
477+- [GitLab CI](./gitlab-ci.md)
478+- [Generic CI](./generic-ci.md)
281479 - [Deployment Overview](./index.md)
480+- [Authentication](../config/auth.md)
481+- [Push Command](../cli/push.md)
282482 - [Rollback](./rollback.md)
@@ -1,21 +1,26 @@
1 # GitHub Actions Deployment1 # GitHub Actions Deployment
2 2
3-Automated deployment with GitHub Actions using Gordon's official action.3+Two approaches for deploying with GitHub Actions: the `gordon push` CLI (recommended) or a Docker-based workflow using Gordon's official action.
4 4
5 ## Prerequisites5 ## Prerequisites
6 6
7 1. Gordon server running with registry authentication enabled7 1. Gordon server running with registry authentication enabled
8-2. Generated deployment token8+2. Deployment token generated with the required scopes
9 3. GitHub repository secrets configured9 3. GitHub repository secrets configured
10 10
11-## Quick Setup11+## Recommended: gordon push
12 12
13-### 1. Generate Deployment Token13+Use the `gordon push` CLI for a lightweight, single-step build and deploy.
14+
15+### 1. Generate Token
14 16
15 On your Gordon server:17 On your Gordon server:
16 18
17 ```bash19 ```bash
18-gordon auth token generate --subject github-actions --scopes push,pull --expiry 020+gordon auth token generate \
21+ --subject github-actions \
22+ --scopes "push,pull,admin:routes:read,admin:config:write" \
23+ --expiry 0
19 ```24 ```
20 25
21 Save the token output.26 Save the token output.
@@ -26,70 +31,238 @@ In your repository: Settings → Secrets → Actions → New repository secret
26 31
27 | Secret | Value |32 | Secret | Value |
28 |--------|-------|33 |--------|-------|
29-| `GORDON_REGISTRY` | `registry.mydomain.com` |
30-| `GORDON_USERNAME` | `github-actions` |
31 | `GORDON_TOKEN` | The generated token |34 | `GORDON_TOKEN` | The generated token |
35+| `GORDON_REMOTE` | `https://gordon.example.com` |
32 36
33-### 3. Create Workflow37+### 3. Setup Gordon CLI
34 38
35-Create `.github/workflows/deploy.yml`:39+Add this step to your workflow to install the Gordon binary:
40+
41+```yaml
42+- name: Setup Gordon
43+ uses: bnema/gordon/.github/actions/setup-gordon@main
44+ # with:
45+ # version: latest # default, or pin to e.g. v2.0.0
46+```
47+
48+### 4. Workflow Examples
49+
50+#### Deploy on Tag Push
51+
52+Gordon auto-detects the version from `$GITHUB_REF` (e.g., `refs/tags/v1.2.0` → `v1.2.0`).
36 53
37 ```yaml54 ```yaml
38 name: Deploy to Gordon55 name: Deploy to Gordon
39 56
40 on:57 on:
41 push:58 push:
42- tags:59+ tags: ['v*']
43- - 'v*'
44 60
45 jobs:61 jobs:
46 deploy:62 deploy:
47 runs-on: ubuntu-latest63 runs-on: ubuntu-latest
48 steps:64 steps:
49- - uses: actions/checkout@v665+ - uses: actions/checkout@v4
66+
67+ - name: Setup Gordon
68+ uses: bnema/gordon/.github/actions/setup-gordon@main
69+
70+ - name: Build and Deploy
71+ env:
72+ GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
73+ run: |
74+ gordon push --build \
75+ --remote ${{ secrets.GORDON_REMOTE }} \
76+ --no-confirm
77+```
50 78
51- - name: Deploy to Gordon79+#### Continuous Deploy on Main
52- uses: bnema/gordon/.github/actions/deploy@main80+
81+Deploy on every push to main. The version defaults to `latest` or the output of `git describe`.
82+
83+```yaml
84+name: Deploy to Gordon
85+
86+on:
87+ push:
88+ branches:
89+ - main
90+
91+jobs:
92+ deploy:
93+ runs-on: ubuntu-latest
94+ steps:
95+ - uses: actions/checkout@v4
53 with:96 with:
54- registry: ${{ secrets.GORDON_REGISTRY }}97+ fetch-depth: 0
55- username: ${{ secrets.GORDON_USERNAME }}98+
56- password: ${{ secrets.GORDON_TOKEN }}99+ - name: Setup Gordon
100+ uses: bnema/gordon/.github/actions/setup-gordon@main
101+
102+ - name: Build and Deploy
103+ env:
104+ GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
105+ run: |
106+ gordon push --build \
107+ --remote ${{ secrets.GORDON_REMOTE }} \
108+ --tag latest \
109+ --no-confirm
57 ```110 ```
58 111
59-### 4. Deploy112+#### Manual Dispatch
60 113
61-```bash114+Allow manual deployments with an optional tag input:
62-git tag v1.0.0115+
63-git push origin v1.0.0116+```yaml
117+name: Deploy to Gordon
118+
119+on:
120+ workflow_dispatch:
121+ inputs:
122+ tag:
123+ description: 'Tag to deploy (leave empty to use git describe)'
124+ required: false
125+
126+jobs:
127+ deploy:
128+ runs-on: ubuntu-latest
129+ steps:
130+ - uses: actions/checkout@v4
131+ with:
132+ ref: ${{ github.event.inputs.tag || github.ref }}
133+ fetch-depth: 0
134+
135+ - name: Setup Gordon
136+ uses: bnema/gordon/.github/actions/setup-gordon@main
137+
138+ - name: Build and Deploy
139+ env:
140+ GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
141+ run: |
142+ gordon push --build \
143+ --remote ${{ secrets.GORDON_REMOTE }} \
144+ ${{ github.event.inputs.tag && format('--tag {0}', github.event.inputs.tag) || '' }} \
145+ --no-confirm
146+```
147+
148+#### Monorepo
149+
150+Deploy multiple services with separate `gordon push` calls:
151+
152+```yaml
153+name: Deploy Services
154+
155+on:
156+ push:
157+ tags: ['v*']
158+
159+jobs:
160+ deploy:
161+ runs-on: ubuntu-latest
162+ steps:
163+ - uses: actions/checkout@v4
164+
165+ - name: Setup Gordon
166+ uses: bnema/gordon/.github/actions/setup-gordon@main
167+
168+ - name: Deploy API
169+ env:
170+ GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
171+ run: |
172+ gordon push --build \
173+ --remote ${{ secrets.GORDON_REMOTE }} \
174+ --file ./services/api/Dockerfile \
175+ myapp-api \
176+ --no-confirm
177+
178+ - name: Deploy Web
179+ env:
180+ GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
181+ run: |
182+ gordon push --build \
183+ --remote ${{ secrets.GORDON_REMOTE }} \
184+ --file ./services/web/Dockerfile \
185+ myapp-web \
186+ --no-confirm
64 ```187 ```
65 188
66-## Action Reference189+#### With Build Args
190+
191+Pass build arguments to the Docker build:
192+
193+```yaml
194+- name: Build and Deploy
195+ env:
196+ GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
197+ run: |
198+ gordon push --build \
199+ --remote ${{ secrets.GORDON_REMOTE }} \
200+ --build-arg NODE_ENV=production \
201+ --build-arg API_URL=https://api.example.com \
202+ --build-arg BUILD_DATE=${{ github.event.head_commit.timestamp }} \
203+ --no-confirm
204+```
67 205
68-### Inputs206+### setup-gordon Action Reference
69 207
70 | Input | Required | Default | Description |208 | Input | Required | Default | Description |
71 |-------|----------|---------|-------------|209 |-------|----------|---------|-------------|
72-| `registry` | Yes | - | Gordon registry URL |210+| `version` | No | `latest` | Gordon version to install (`v2.0.0` or `latest`) |
73-| `username` | Yes | - | Registry username |
74-| `password` | Yes | - | Registry token |
75-| `image` | No | repo name | Image name |
76-| `tag` | No | git tag/SHA | Image tag |
77-| `dockerfile` | No | `./Dockerfile` | Dockerfile path |
78-| `context` | No | `.` | Build context |
79-| `push-latest` | No | `false` | Also push :latest tag |
80-| `platforms` | No | `linux/amd64` | Target platforms |
81-| `build-args` | No | - | Build arguments |
82-
83-### Outputs
84 211
85 | Output | Description |212 | Output | Description |
86 |--------|-------------|213 |--------|-------------|
87-| `image` | Full image name |214+| `version` | The version actually installed |
88-| `tag` | Image tag |215+| `path` | Path to the installed binary |
216+
217+## Alternative: Docker-based Workflow
89 218
90-## Workflow Examples219+For environments where installing the Gordon binary is not desired, use Docker directly to build and push to the Gordon registry. Gordon auto-deploys when it receives the image — no explicit deploy step is needed.
91 220
92-### Deploy on Tag Push221+### Setup
222+
223+Add these secrets to your repository:
224+
225+| Secret | Value |
226+|--------|-------|
227+| `GORDON_REGISTRY` | `registry.gordon.example.com` |
228+| `GORDON_USERNAME` | `github-actions` |
229+| `GORDON_TOKEN` | The generated token |
230+
231+### Workflow
232+
233+```yaml
234+name: Deploy to Gordon
235+
236+on:
237+ push:
238+ tags: ['v*']
239+
240+jobs:
241+ deploy:
242+ runs-on: ubuntu-latest
243+ steps:
244+ - uses: actions/checkout@v4
245+
246+ - name: Login to Gordon Registry
247+ run: echo "${{ secrets.GORDON_TOKEN }}" | docker login ${{ secrets.GORDON_REGISTRY }} -u ${{ secrets.GORDON_USERNAME }} --password-stdin
248+
249+ - name: Build and Push
250+ run: |
251+ docker build -t ${{ secrets.GORDON_REGISTRY }}/myapp:${{ github.ref_name }} .
252+ docker push ${{ secrets.GORDON_REGISTRY }}/myapp:${{ github.ref_name }}
253+```
254+
255+## Alternative: Gordon GitHub Action
256+
257+Use the official `bnema/gordon` action for a fully declarative workflow. This action handles login, build, and push in a single step.
258+
259+### Setup
260+
261+Requires the same 3 secrets as the Docker-based workflow: `GORDON_REGISTRY`, `GORDON_USERNAME`, `GORDON_TOKEN`.
262+
263+### Workflow Examples
264+
265+#### Deploy on Tag Push
93 266
94 ```yaml267 ```yaml
95 name: Deploy to Gordon268 name: Deploy to Gordon
@@ -103,7 +276,7 @@ jobs:
103 deploy:276 deploy:
104 runs-on: ubuntu-latest277 runs-on: ubuntu-latest
105 steps:278 steps:
106- - uses: actions/checkout@v6279+ - uses: actions/checkout@v4
107 280
108 - name: Deploy to Gordon281 - name: Deploy to Gordon
109 uses: bnema/gordon/.github/actions/deploy@main282 uses: bnema/gordon/.github/actions/deploy@main
@@ -113,7 +286,7 @@ jobs:
113 password: ${{ secrets.GORDON_TOKEN }}286 password: ${{ secrets.GORDON_TOKEN }}
114 ```287 ```
115 288
116-### Continuous Deployment289+#### Continuous Deployment
117 290
118 Deploy on every push to main:291 Deploy on every push to main:
119 292
@@ -129,7 +302,7 @@ jobs:
129 deploy:302 deploy:
130 runs-on: ubuntu-latest303 runs-on: ubuntu-latest
131 steps:304 steps:
132- - uses: actions/checkout@v6305+ - uses: actions/checkout@v4
133 306
134 - name: Deploy to Gordon307 - name: Deploy to Gordon
135 uses: bnema/gordon/.github/actions/deploy@main308 uses: bnema/gordon/.github/actions/deploy@main
@@ -140,9 +313,9 @@ jobs:
140 push-latest: 'true'313 push-latest: 'true'
141 ```314 ```
142 315
143-### Manual Deployment316+#### Manual Deployment
144 317
145-Allow manual deployments with custom tag:318+Allow manual deployments with a custom tag:
146 319
147 ```yaml320 ```yaml
148 name: Deploy to Gordon321 name: Deploy to Gordon
@@ -158,7 +331,7 @@ jobs:
158 deploy:331 deploy:
159 runs-on: ubuntu-latest332 runs-on: ubuntu-latest
160 steps:333 steps:
161- - uses: actions/checkout@v6334+ - uses: actions/checkout@v4
162 with:335 with:
163 ref: ${{ github.event.inputs.tag || github.ref }}336 ref: ${{ github.event.inputs.tag || github.ref }}
164 337
@@ -171,7 +344,7 @@ jobs:
171 tag: ${{ github.event.inputs.tag }}344 tag: ${{ github.event.inputs.tag }}
172 ```345 ```
173 346
174-### Monorepo Deployment347+#### Monorepo Deployment
175 348
176 Deploy multiple services:349 Deploy multiple services:
177 350
@@ -187,7 +360,7 @@ jobs:
187 deploy-api:360 deploy-api:
188 runs-on: ubuntu-latest361 runs-on: ubuntu-latest
189 steps:362 steps:
190- - uses: actions/checkout@v6363+ - uses: actions/checkout@v4
191 - uses: bnema/gordon/.github/actions/deploy@main364 - uses: bnema/gordon/.github/actions/deploy@main
192 with:365 with:
193 registry: ${{ secrets.GORDON_REGISTRY }}366 registry: ${{ secrets.GORDON_REGISTRY }}
@@ -200,7 +373,7 @@ jobs:
200 deploy-web:373 deploy-web:
201 runs-on: ubuntu-latest374 runs-on: ubuntu-latest
202 steps:375 steps:
203- - uses: actions/checkout@v6376+ - uses: actions/checkout@v4
204 - uses: bnema/gordon/.github/actions/deploy@main377 - uses: bnema/gordon/.github/actions/deploy@main
205 with:378 with:
206 registry: ${{ secrets.GORDON_REGISTRY }}379 registry: ${{ secrets.GORDON_REGISTRY }}
@@ -211,7 +384,7 @@ jobs:
211 context: ./services/web384 context: ./services/web
212 ```385 ```
213 386
214-### With Build Arguments387+#### With Build Arguments
215 388
216 ```yaml389 ```yaml
217 - uses: bnema/gordon/.github/actions/deploy@main390 - uses: bnema/gordon/.github/actions/deploy@main
@@ -225,7 +398,7 @@ jobs:
225 BUILD_DATE=${{ github.event.head_commit.timestamp }}398 BUILD_DATE=${{ github.event.head_commit.timestamp }}
226 ```399 ```
227 400
228-### Multi-Platform Build401+#### Multi-Platform Build
229 402
230 ```yaml403 ```yaml
231 - uses: bnema/gordon/.github/actions/deploy@main404 - uses: bnema/gordon/.github/actions/deploy@main
@@ -236,7 +409,31 @@ jobs:
236 platforms: linux/amd64,linux/arm64409 platforms: linux/amd64,linux/arm64
237 ```410 ```
238 411
239-## Deployment Summary412+### Action Reference
413+
414+#### Inputs
415+
416+| Input | Required | Default | Description |
417+|-------|----------|---------|-------------|
418+| `registry` | Yes | - | Gordon registry URL |
419+| `username` | Yes | - | Registry username |
420+| `password` | Yes | - | Registry token |
421+| `image` | No | repo name | Image name |
422+| `tag` | No | git tag/SHA | Image tag |
423+| `dockerfile` | No | `./Dockerfile` | Dockerfile path |
424+| `context` | No | `.` | Build context |
425+| `push-latest` | No | `false` | Also push :latest tag |
426+| `platforms` | No | `linux/amd64` | Target platforms |
427+| `build-args` | No | - | Build arguments |
428+
429+#### Outputs
430+
431+| Output | Description |
432+|--------|-------------|
433+| `image` | Full image name |
434+| `tag` | Image tag |
435+
436+#### Deployment Summary
240 437
241 Add a deployment summary to your workflow:438 Add a deployment summary to your workflow:
242 439
@@ -277,6 +474,9 @@ Error: unauthorized: authentication required
277 474
278 ## Related475 ## Related
279 476
280-- [Authentication](../config/auth.md)477+- [GitLab CI](./gitlab-ci.md)
478+- [Generic CI](./generic-ci.md)
281 - [Deployment Overview](./index.md)479 - [Deployment Overview](./index.md)
480+- [Authentication](../config/auth.md)
481+- [Push Command](../cli/push.md)
282 - [Rollback](./rollback.md)482 - [Rollback](./rollback.md)
A docs/deployment/gitlab-ci.md +211 -0
@@ -0,0 +1,211 @@
1+# GitLab CI/CD Deployment
2+
3+Automated deployment with GitLab CI/CD using Gordon's push command.
4+
5+## Prerequisites
6+
7+1. Gordon server running with registry authentication enabled
8+2. Generated deployment token
9+3. GitLab CI/CD variables configured
10+
11+## Quick Setup
12+
13+### 1. Generate Deployment Token
14+
15+On your Gordon server:
16+
17+```bash
18+gordon auth token generate \
19+ --subject gitlab-ci \
20+ --scopes "push,pull,admin:routes:read,admin:config:write" \
21+ --expiry 0
22+```
23+
24+### 2. Add CI/CD Variables
25+
26+In your project: Settings > CI/CD > Variables
27+
28+| Variable | Value | Protected | Masked |
29+|----------|-------|-----------|--------|
30+| `GORDON_TOKEN` | The generated token | Yes | Yes |
31+| `GORDON_REMOTE` | `https://gordon.example.com` | Yes | No |
32+
33+### 3. Create Pipeline
34+
35+Create `.gitlab-ci.yml`:
36+
37+```yaml
38+deploy:
39+ stage: deploy
40+ image: docker:24
41+ services:
42+ - docker:24-dind
43+ before_script:
44+ - apk add --no-cache curl
45+ - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
46+ - chmod +x /usr/local/bin/gordon
47+ script:
48+ - gordon push --build
49+ --remote "$GORDON_REMOTE"
50+ --token "$GORDON_TOKEN"
51+ --no-confirm
52+ only:
53+ - tags
54+```
55+
56+Gordon auto-detects the version from `$CI_COMMIT_TAG`.
57+
58+## Pipeline Examples
59+
60+### Deploy on Tag Push
61+
62+```yaml
63+stages:
64+ - deploy
65+
66+deploy:
67+ stage: deploy
68+ image: docker:24
69+ services:
70+ - docker:24-dind
71+ before_script:
72+ - apk add --no-cache curl
73+ - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
74+ - chmod +x /usr/local/bin/gordon
75+ script:
76+ - gordon push --build
77+ --remote "$GORDON_REMOTE"
78+ --token "$GORDON_TOKEN"
79+ --no-confirm
80+ rules:
81+ - if: $CI_COMMIT_TAG
82+```
83+
84+### Continuous Deployment
85+
86+Deploy on every push to main:
87+
88+```yaml
89+deploy:
90+ stage: deploy
91+ image: docker:24
92+ services:
93+ - docker:24-dind
94+ before_script:
95+ - apk add --no-cache curl
96+ - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
97+ - chmod +x /usr/local/bin/gordon
98+ script:
99+ - gordon push --build
100+ --remote "$GORDON_REMOTE"
101+ --token "$GORDON_TOKEN"
102+ --no-confirm
103+ rules:
104+ - if: $CI_COMMIT_BRANCH == "main"
105+```
106+
107+### Manual Deployment
108+
109+```yaml
110+deploy:
111+ stage: deploy
112+ image: docker:24
113+ services:
114+ - docker:24-dind
115+ before_script:
116+ - apk add --no-cache curl
117+ - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
118+ - chmod +x /usr/local/bin/gordon
119+ script:
120+ - gordon push --build
121+ --remote "$GORDON_REMOTE"
122+ --token "$GORDON_TOKEN"
123+ --no-confirm
124+ when: manual
125+```
126+
127+### With Build Arguments
128+
129+```yaml
130+deploy:
131+ stage: deploy
132+ image: docker:24
133+ services:
134+ - docker:24-dind
135+ before_script:
136+ - apk add --no-cache curl
137+ - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
138+ - chmod +x /usr/local/bin/gordon
139+ script:
140+ - gordon push --build
141+ --remote "$GORDON_REMOTE"
142+ --token "$GORDON_TOKEN"
143+ --build-arg NODE_ENV=production
144+ --build-arg API_URL=https://api.example.com
145+ --no-confirm
146+ rules:
147+ - if: $CI_COMMIT_TAG
148+```
149+
150+## Alternative: Docker-based Pipeline
151+
152+For environments where installing the Gordon binary is not desired:
153+
154+```yaml
155+deploy:
156+ stage: deploy
157+ image: docker:24
158+ services:
159+ - docker:24-dind
160+ script:
161+ - echo "$GORDON_TOKEN" | docker login "$GORDON_REGISTRY" -u "$GORDON_USERNAME" --password-stdin
162+ - docker build -t "$GORDON_REGISTRY/myapp:$CI_COMMIT_TAG" .
163+ - docker push "$GORDON_REGISTRY/myapp:$CI_COMMIT_TAG"
164+ rules:
165+ - if: $CI_COMMIT_TAG
166+```
167+
168+This requires additional CI/CD variables: `GORDON_REGISTRY` and `GORDON_USERNAME`.
169+Gordon auto-deploys when it receives the image.
170+
171+## Version Detection
172+
173+Gordon automatically detects the version tag from GitLab CI environment variables:
174+
175+| Variable | Example | Result |
176+|----------|---------|--------|
177+| `$CI_COMMIT_TAG` | `v1.2.0` | Tag `v1.2.0` |
178+| No tag | - | Falls back to `git describe` or `latest` |
179+
180+## Troubleshooting
181+
182+### Authentication Failed
183+
184+```
185+Error: failed to verify token
186+```
187+
188+Verify `GORDON_TOKEN` is correctly set and the token has not been revoked.
189+
190+### Docker-in-Docker Issues
191+
192+If using `docker:dind`, ensure the service is properly configured:
193+
194+```yaml
195+services:
196+ - docker:24-dind
197+variables:
198+ DOCKER_TLS_CERTDIR: "/certs"
199+```
200+
201+### Build Context
202+
203+GitLab CI clones your repository automatically. The build context defaults to the repository root.
204+
205+## Related
206+
207+- [GitHub Actions](./github-actions.md)
208+- [Generic CI](./generic-ci.md)
209+- [Deployment Overview](./index.md)
210+- [Authentication](../config/auth.md)
211+- [Push Command](../cli/push.md)
@@ -0,0 +1,211 @@
1+# GitLab CI/CD Deployment
2+
3+Automated deployment with GitLab CI/CD using Gordon's push command.
4+
5+## Prerequisites
6+
7+1. Gordon server running with registry authentication enabled
8+2. Generated deployment token
9+3. GitLab CI/CD variables configured
10+
11+## Quick Setup
12+
13+### 1. Generate Deployment Token
14+
15+On your Gordon server:
16+
17+```bash
18+gordon auth token generate \
19+ --subject gitlab-ci \
20+ --scopes "push,pull,admin:routes:read,admin:config:write" \
21+ --expiry 0
22+```
23+
24+### 2. Add CI/CD Variables
25+
26+In your project: Settings > CI/CD > Variables
27+
28+| Variable | Value | Protected | Masked |
29+|----------|-------|-----------|--------|
30+| `GORDON_TOKEN` | The generated token | Yes | Yes |
31+| `GORDON_REMOTE` | `https://gordon.example.com` | Yes | No |
32+
33+### 3. Create Pipeline
34+
35+Create `.gitlab-ci.yml`:
36+
37+```yaml
38+deploy:
39+ stage: deploy
40+ image: docker:24
41+ services:
42+ - docker:24-dind
43+ before_script:
44+ - apk add --no-cache curl
45+ - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
46+ - chmod +x /usr/local/bin/gordon
47+ script:
48+ - gordon push --build
49+ --remote "$GORDON_REMOTE"
50+ --token "$GORDON_TOKEN"
51+ --no-confirm
52+ only:
53+ - tags
54+```
55+
56+Gordon auto-detects the version from `$CI_COMMIT_TAG`.
57+
58+## Pipeline Examples
59+
60+### Deploy on Tag Push
61+
62+```yaml
63+stages:
64+ - deploy
65+
66+deploy:
67+ stage: deploy
68+ image: docker:24
69+ services:
70+ - docker:24-dind
71+ before_script:
72+ - apk add --no-cache curl
73+ - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
74+ - chmod +x /usr/local/bin/gordon
75+ script:
76+ - gordon push --build
77+ --remote "$GORDON_REMOTE"
78+ --token "$GORDON_TOKEN"
79+ --no-confirm
80+ rules:
81+ - if: $CI_COMMIT_TAG
82+```
83+
84+### Continuous Deployment
85+
86+Deploy on every push to main:
87+
88+```yaml
89+deploy:
90+ stage: deploy
91+ image: docker:24
92+ services:
93+ - docker:24-dind
94+ before_script:
95+ - apk add --no-cache curl
96+ - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
97+ - chmod +x /usr/local/bin/gordon
98+ script:
99+ - gordon push --build
100+ --remote "$GORDON_REMOTE"
101+ --token "$GORDON_TOKEN"
102+ --no-confirm
103+ rules:
104+ - if: $CI_COMMIT_BRANCH == "main"
105+```
106+
107+### Manual Deployment
108+
109+```yaml
110+deploy:
111+ stage: deploy
112+ image: docker:24
113+ services:
114+ - docker:24-dind
115+ before_script:
116+ - apk add --no-cache curl
117+ - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
118+ - chmod +x /usr/local/bin/gordon
119+ script:
120+ - gordon push --build
121+ --remote "$GORDON_REMOTE"
122+ --token "$GORDON_TOKEN"
123+ --no-confirm
124+ when: manual
125+```
126+
127+### With Build Arguments
128+
129+```yaml
130+deploy:
131+ stage: deploy
132+ image: docker:24
133+ services:
134+ - docker:24-dind
135+ before_script:
136+ - apk add --no-cache curl
137+ - curl -fsSL https://github.com/bnema/gordon/releases/latest/download/gordon_linux_amd64 -o /usr/local/bin/gordon
138+ - chmod +x /usr/local/bin/gordon
139+ script:
140+ - gordon push --build
141+ --remote "$GORDON_REMOTE"
142+ --token "$GORDON_TOKEN"
143+ --build-arg NODE_ENV=production
144+ --build-arg API_URL=https://api.example.com
145+ --no-confirm
146+ rules:
147+ - if: $CI_COMMIT_TAG
148+```
149+
150+## Alternative: Docker-based Pipeline
151+
152+For environments where installing the Gordon binary is not desired:
153+
154+```yaml
155+deploy:
156+ stage: deploy
157+ image: docker:24
158+ services:
159+ - docker:24-dind
160+ script:
161+ - echo "$GORDON_TOKEN" | docker login "$GORDON_REGISTRY" -u "$GORDON_USERNAME" --password-stdin
162+ - docker build -t "$GORDON_REGISTRY/myapp:$CI_COMMIT_TAG" .
163+ - docker push "$GORDON_REGISTRY/myapp:$CI_COMMIT_TAG"
164+ rules:
165+ - if: $CI_COMMIT_TAG
166+```
167+
168+This requires additional CI/CD variables: `GORDON_REGISTRY` and `GORDON_USERNAME`.
169+Gordon auto-deploys when it receives the image.
170+
171+## Version Detection
172+
173+Gordon automatically detects the version tag from GitLab CI environment variables:
174+
175+| Variable | Example | Result |
176+|----------|---------|--------|
177+| `$CI_COMMIT_TAG` | `v1.2.0` | Tag `v1.2.0` |
178+| No tag | - | Falls back to `git describe` or `latest` |
179+
180+## Troubleshooting
181+
182+### Authentication Failed
183+
184+```
185+Error: failed to verify token
186+```
187+
188+Verify `GORDON_TOKEN` is correctly set and the token has not been revoked.
189+
190+### Docker-in-Docker Issues
191+
192+If using `docker:dind`, ensure the service is properly configured:
193+
194+```yaml
195+services:
196+ - docker:24-dind
197+variables:
198+ DOCKER_TLS_CERTDIR: "/certs"
199+```
200+
201+### Build Context
202+
203+GitLab CI clones your repository automatically. The build context defaults to the repository root.
204+
205+## Related
206+
207+- [GitHub Actions](./github-actions.md)
208+- [Generic CI](./generic-ci.md)
209+- [Deployment Overview](./index.md)
210+- [Authentication](../config/auth.md)
211+- [Push Command](../cli/push.md)
M docs/deployment/index.md +118 -116
@@ -1,41 +1,120 @@
11 # Deployment Overview
22
3-Strategies for deploying applications with Gordon.
3+Gordon deploys containers when you push images to its built-in registry. Three deployment methods are available depending on your workflow and infrastructure.
44
5-## Deployment Methods
5+## Recommended: gordon push
66
7-| Method | Use Case |
8-|--------|----------|
9-| Manual Push | Development, one-off deploys |
10-| GitHub Actions | Automated CI/CD |
11-| GitLab CI | Automated CI/CD |
12-| Generic CI | Any CI/CD system |
7+`gordon push --build --remote` is the simplest way to build, push, and deploy from CI/CD pipelines.
138
14-## Basic Deployment Workflow
15-
16-### 1. Build Locally
9+- Single command: build + push + deploy
10+- Single secret (`GORDON_TOKEN`): auto-exchanges for a short-lived registry token
11+- Auto-detects version from CI environment (`$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`)
12+- Chunked uploads (50MB chunks) — works behind Cloudflare and restrictive proxies
1713
1814 ```bash
19-docker build -t myapp .
15+gordon push --build --remote https://gordon.example.com --no-confirm
2016 ```
2117
22-### 2. Tag for Registry
18+## All Deployment Methods
19+
20+| Method | Best For | Secrets Needed | Registry Access | Deploy Control |
21+|--------|----------|----------------|-----------------|----------------|
22+| `gordon push` (Recommended) | CI/CD pipelines | 1 (`GORDON_TOKEN`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) |
23+| `docker push` | Simple setups, existing Docker workflows | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Automatic (event-based) |
24+| Docker labels + auto-route | GitOps, zero-config deploys | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Automatic (label-driven) |
25+
26+### Method 1: gordon push (Recommended)
27+
28+The Gordon CLI handles authentication, image building, and deployment in a single step.
29+
30+- Single token handles everything: admin API access + registry auth via automatic token exchange
31+- Version tag auto-detected from `$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`
32+- Use `--no-deploy` for push-only workflows (useful for staging images without triggering deployment)
33+- Requires the Gordon binary on the CI runner
2334
2435 ```bash
25-docker tag myapp registry.mydomain.com/myapp:latest
36+# Build, push, and deploy
37+gordon push --build --remote https://gordon.example.com --no-confirm
38+
39+# Push only, no deploy
40+gordon push --build --remote https://gordon.example.com --no-deploy --no-confirm
2641 ```
2742
28-### 3. Push to Deploy
43+### Method 2: docker push
44+
45+Standard Docker workflow — no Gordon binary required on the runner.
46+
47+- Use `docker login`, `docker build`, and `docker push` as usual
48+- Gordon auto-deploys when it receives the image (event-based, no explicit trigger needed)
49+- No Gordon binary needed on the CI runner
50+- Registry endpoint is `gordon.example.com` (not a separate registry host)
2951
3052 ```bash
31-docker push registry.mydomain.com/myapp:latest
53+echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin gordon.example.com
54+docker build -t gordon.example.com/myapp:v1.2.0 .
55+docker push gordon.example.com/myapp:v1.2.0
3256 ```
3357
34-Gordon automatically:
35-1. Receives the image
36-2. Deploys a new container
37-3. Routes traffic to it
38-4. Stops the old container
58+### Method 3: Docker labels + auto-route
59+
60+Add a `gordon.domain` label to your image and Gordon creates the route automatically on push.
61+
62+- Add `gordon.domain=app.example.com` label to your Dockerfile
63+- Push image — Gordon creates or updates the route without manual config
64+- Gated by the `auto_route_allowed_domains` allowlist in `gordon.toml`
65+- Best for GitOps workflows where routes are defined alongside the application
66+
67+```dockerfile
68+LABEL gordon.domain="app.example.com"
69+```
70+
71+See [Auto-Route](../config/auto-route.md) for configuration details.
72+
73+## Registry Access
74+
75+Gordon's registry is served through the main gordon domain over HTTPS on port 443. The internal registry port (5000) is never exposed externally. All push methods use `https://gordon.example.com/v2/...`.
76+
77+### Network Topologies
78+
79+| Setup | Configuration | Use Case |
80+|-------|--------------|----------|
81+| Public (default) | `auth.enabled = true` | Hosted CI runners (GitHub Actions, GitLab CI) |
82+| Tailscale only | `registry_allowed_ips = ["100.64.0.0/10"]` | Self-hosted runners in Tailnet |
83+| Localhost only | `auth.enabled = false` | Local development, single-machine deploys |
84+
85+## Token Setup
86+
87+See the examples below for the right scopes for each workflow.
88+
89+```bash
90+# Minimum scope for gordon push — route lookup + registry push
91+# Server auto-deploys when it receives the image
92+gordon auth token generate \
93+ --subject ci-bot \
94+ --scopes "push,pull,admin:routes:read" \
95+ --expiry 90d
96+
97+# With explicit CLI-managed deploy (adds config:write for deploy control)
98+gordon auth token generate \
99+ --subject ci-bot \
100+ --scopes "push,pull,admin:routes:read,admin:config:write" \
101+ --expiry 90d
102+
103+# Scoped to a specific repository
104+gordon auth token generate \
105+ --subject ci-bot \
106+ --repo myapp \
107+ --scopes "push,pull,admin:routes:read" \
108+ --expiry 90d
109+
110+# For docker push — registry scopes only
111+gordon auth token generate \
112+ --subject ci-bot \
113+ --scopes "push,pull" \
114+ --expiry 90d
115+```
116+
117+Set the generated token as `GORDON_TOKEN` in your CI environment.
39118
40119 ## Version Strategies
41120
@@ -44,54 +123,48 @@ Gordon automatically:
44123 Always deploy the most recent build:
45124
46125 ```bash
47-docker tag myapp registry.mydomain.com/myapp:latest
48-docker push registry.mydomain.com/myapp:latest
126+docker tag myapp gordon.example.com/myapp:latest
127+docker push gordon.example.com/myapp:latest
49128 ```
50129
51-Configure route:
52130 ```toml
53131 [routes]
54-"app.mydomain.com" = "myapp:latest"
132+"app.example.com" = "myapp:latest"
55133 ```
56134
57135 ### Semantic Versioning
58136
59-Pin to specific versions:
137+Pin routes to specific versions and update config to roll forward:
60138
61139 ```bash
62-docker tag myapp registry.mydomain.com/myapp:v2.1.0
63-docker push registry.mydomain.com/myapp:v2.1.0
140+docker tag myapp gordon.example.com/myapp:v2.1.0
141+docker push gordon.example.com/myapp:v2.1.0
64142 ```
65143
66-Configure route:
67144 ```toml
68145 [routes]
69-"app.mydomain.com" = "myapp:v2.1.0"
146+"app.example.com" = "myapp:v2.1.0"
70147 ```
71148
72-Update config to deploy new version:
73-```toml
74-[routes]
75-"app.mydomain.com" = "myapp:v2.2.0"
76-```
149+To deploy a new version, update the tag in `gordon.toml` and push the new image.
77150
78151 ### Git SHA Tags
79152
80-Tag with commit hash for traceability:
153+Tag with commit hash for full traceability:
81154
82155 ```bash
83156 VERSION=$(git rev-parse --short HEAD)
84-docker tag myapp registry.mydomain.com/myapp:$VERSION
85-docker push registry.mydomain.com/myapp:$VERSION
157+docker tag myapp gordon.example.com/myapp:$VERSION
158+docker push gordon.example.com/myapp:$VERSION
86159 ```
87160
88161 ## Zero-Downtime Deployment
89162
90163 Gordon performs zero-downtime deployments by default:
91164
92-1. **New container starts** while old is still running
93-2. **Health check** waits for new container readiness
94-3. **Traffic switches** to new container
165+1. **New container starts** while the old one is still running
166+2. **Health check** waits for the new container to become ready
167+3. **Traffic switches** to the new container
95168 4. **Old container stops** after traffic moves
96169
97170 ```
@@ -103,83 +176,12 @@ New Container: [══════════════════
103176 ↑ start ↑ traffic routed
104177 ```
105178
106-## Environment-Specific Deployments
107-
108-### Development
109-
110-```toml
111-[routes]
112-"dev.mydomain.com" = "myapp:latest"
113-```
114-
115-```bash
116-docker push registry.mydomain.com/myapp:latest
117-```
118-
119-### Staging
120-
121-```toml
122-[routes]
123-"staging.mydomain.com" = "myapp:staging"
124-```
125-
126-```bash
127-docker tag myapp registry.mydomain.com/myapp:staging
128-docker push registry.mydomain.com/myapp:staging
129-```
130-
131-### Production
132-
133-```toml
134-[routes]
135-"app.mydomain.com" = "myapp:v2.1.0"
136-```
137-
138-```bash
139-docker tag myapp registry.mydomain.com/myapp:v2.1.0
140-docker push registry.mydomain.com/myapp:v2.1.0
141-# Then update gordon.toml and reload
142-```
143-
144-## Deployment Checklist
145-
146-Before deploying:
147-
148-- [ ] Build passes locally
149-- [ ] Tests pass
150-- [ ] Environment file configured (`~/.gordon/env/app_mydomain_com.env`)
151-- [ ] DNS pointing to Gordon server
152-- [ ] Route configured in `gordon.toml`
153-
154-After deploying:
155-
156-- [ ] Container starts successfully
157-- [ ] Application responds to requests
158-- [ ] Logs show no errors
159-- [ ] Health checks pass
160-
161-## Monitoring Deployments
162-
163-### Watch Logs
164-
165-```bash
166-gordon logs -f
167-```
168-
169-### Check Container Status
170-
171-```bash
172-docker ps | grep gordon
173-```
174-
175-### Verify Routing
176-
177-```bash
178-curl -I https://app.mydomain.com
179-```
180-
181179 ## Related
182180
183181 - [GitHub Actions](./github-actions.md)
182+- [GitLab CI](./gitlab-ci.md)
183+- [Generic CI](./generic-ci.md)
184184 - [Rollback](./rollback.md)
185185 - [Routes Configuration](../config/routes.md)
186+- [Authentication](../config/auth.md)
187+- [Auto-Route](../config/auto-route.md)
@@ -1,41 +1,120 @@
1 # Deployment Overview1 # Deployment Overview
2 2
3-Strategies for deploying applications with Gordon.3+Gordon deploys containers when you push images to its built-in registry. Three deployment methods are available depending on your workflow and infrastructure.
4 4
5-## Deployment Methods5+## Recommended: gordon push
6 6
7-| Method | Use Case |7+`gordon push --build --remote` is the simplest way to build, push, and deploy from CI/CD pipelines.
8-|--------|----------|
9-| Manual Push | Development, one-off deploys |
10-| GitHub Actions | Automated CI/CD |
11-| GitLab CI | Automated CI/CD |
12-| Generic CI | Any CI/CD system |
13 8
14-## Basic Deployment Workflow9+- Single command: build + push + deploy
15-10+- Single secret (`GORDON_TOKEN`): auto-exchanges for a short-lived registry token
16-### 1. Build Locally11+- Auto-detects version from CI environment (`$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`)
12+- Chunked uploads (50MB chunks) — works behind Cloudflare and restrictive proxies
17 13
18 ```bash14 ```bash
19-docker build -t myapp .15+gordon push --build --remote https://gordon.example.com --no-confirm
20 ```16 ```
21 17
22-### 2. Tag for Registry18+## All Deployment Methods
19+
20+| Method | Best For | Secrets Needed | Registry Access | Deploy Control |
21+|--------|----------|----------------|-----------------|----------------|
22+| `gordon push` (Recommended) | CI/CD pipelines | 1 (`GORDON_TOKEN`) | Via gordon domain (HTTPS) | Explicit (CLI-triggered) |
23+| `docker push` | Simple setups, existing Docker workflows | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Automatic (event-based) |
24+| Docker labels + auto-route | GitOps, zero-config deploys | 2 (`username` + `token`) | Via gordon domain (HTTPS) | Automatic (label-driven) |
25+
26+### Method 1: gordon push (Recommended)
27+
28+The Gordon CLI handles authentication, image building, and deployment in a single step.
29+
30+- Single token handles everything: admin API access + registry auth via automatic token exchange
31+- Version tag auto-detected from `$GITHUB_REF`, `$CI_COMMIT_TAG`, `$BUILD_SOURCEBRANCH`, or `git describe`
32+- Use `--no-deploy` for push-only workflows (useful for staging images without triggering deployment)
33+- Requires the Gordon binary on the CI runner
23 34
24 ```bash35 ```bash
25-docker tag myapp registry.mydomain.com/myapp:latest36+# Build, push, and deploy
37+gordon push --build --remote https://gordon.example.com --no-confirm
38+
39+# Push only, no deploy
40+gordon push --build --remote https://gordon.example.com --no-deploy --no-confirm
26 ```41 ```
27 42
28-### 3. Push to Deploy43+### Method 2: docker push
44+
45+Standard Docker workflow — no Gordon binary required on the runner.
46+
47+- Use `docker login`, `docker build`, and `docker push` as usual
48+- Gordon auto-deploys when it receives the image (event-based, no explicit trigger needed)
49+- No Gordon binary needed on the CI runner
50+- Registry endpoint is `gordon.example.com` (not a separate registry host)
29 51
30 ```bash52 ```bash
31-docker push registry.mydomain.com/myapp:latest53+echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin gordon.example.com
54+docker build -t gordon.example.com/myapp:v1.2.0 .
55+docker push gordon.example.com/myapp:v1.2.0
32 ```56 ```
33 57
34-Gordon automatically:58+### Method 3: Docker labels + auto-route
35-1. Receives the image59+
36-2. Deploys a new container60+Add a `gordon.domain` label to your image and Gordon creates the route automatically on push.
37-3. Routes traffic to it61+
38-4. Stops the old container62+- Add `gordon.domain=app.example.com` label to your Dockerfile
63+- Push image — Gordon creates or updates the route without manual config
64+- Gated by the `auto_route_allowed_domains` allowlist in `gordon.toml`
65+- Best for GitOps workflows where routes are defined alongside the application
66+
67+```dockerfile
68+LABEL gordon.domain="app.example.com"
69+```
70+
71+See [Auto-Route](../config/auto-route.md) for configuration details.
72+
73+## Registry Access
74+
75+Gordon's registry is served through the main gordon domain over HTTPS on port 443. The internal registry port (5000) is never exposed externally. All push methods use `https://gordon.example.com/v2/...`.
76+
77+### Network Topologies
78+
79+| Setup | Configuration | Use Case |
80+|-------|--------------|----------|
81+| Public (default) | `auth.enabled = true` | Hosted CI runners (GitHub Actions, GitLab CI) |
82+| Tailscale only | `registry_allowed_ips = ["100.64.0.0/10"]` | Self-hosted runners in Tailnet |
83+| Localhost only | `auth.enabled = false` | Local development, single-machine deploys |
84+
85+## Token Setup
86+
87+See the examples below for the right scopes for each workflow.
88+
89+```bash
90+# Minimum scope for gordon push — route lookup + registry push
91+# Server auto-deploys when it receives the image
92+gordon auth token generate \
93+ --subject ci-bot \
94+ --scopes "push,pull,admin:routes:read" \
95+ --expiry 90d
96+
97+# With explicit CLI-managed deploy (adds config:write for deploy control)
98+gordon auth token generate \
99+ --subject ci-bot \
100+ --scopes "push,pull,admin:routes:read,admin:config:write" \
101+ --expiry 90d
102+
103+# Scoped to a specific repository
104+gordon auth token generate \
105+ --subject ci-bot \
106+ --repo myapp \
107+ --scopes "push,pull,admin:routes:read" \
108+ --expiry 90d
109+
110+# For docker push — registry scopes only
111+gordon auth token generate \
112+ --subject ci-bot \
113+ --scopes "push,pull" \
114+ --expiry 90d
115+```
116+
117+Set the generated token as `GORDON_TOKEN` in your CI environment.
39 118
40 ## Version Strategies119 ## Version Strategies
41 120
@@ -44,54 +123,48 @@ Gordon automatically:
44 Always deploy the most recent build:123 Always deploy the most recent build:
45 124
46 ```bash125 ```bash
47-docker tag myapp registry.mydomain.com/myapp:latest126+docker tag myapp gordon.example.com/myapp:latest
48-docker push registry.mydomain.com/myapp:latest127+docker push gordon.example.com/myapp:latest
49 ```128 ```
50 129
51-Configure route:
52 ```toml130 ```toml
53 [routes]131 [routes]
54-"app.mydomain.com" = "myapp:latest"132+"app.example.com" = "myapp:latest"
55 ```133 ```
56 134
57 ### Semantic Versioning135 ### Semantic Versioning
58 136
59-Pin to specific versions:137+Pin routes to specific versions and update config to roll forward:
60 138
61 ```bash139 ```bash
62-docker tag myapp registry.mydomain.com/myapp:v2.1.0140+docker tag myapp gordon.example.com/myapp:v2.1.0
63-docker push registry.mydomain.com/myapp:v2.1.0141+docker push gordon.example.com/myapp:v2.1.0
64 ```142 ```
65 143
66-Configure route:
67 ```toml144 ```toml
68 [routes]145 [routes]
69-"app.mydomain.com" = "myapp:v2.1.0"146+"app.example.com" = "myapp:v2.1.0"
70 ```147 ```
71 148
72-Update config to deploy new version:149+To deploy a new version, update the tag in `gordon.toml` and push the new image.
73-```toml
74-[routes]
75-"app.mydomain.com" = "myapp:v2.2.0"
76-```
77 150
78 ### Git SHA Tags151 ### Git SHA Tags
79 152
80-Tag with commit hash for traceability:153+Tag with commit hash for full traceability:
81 154
82 ```bash155 ```bash
83 VERSION=$(git rev-parse --short HEAD)156 VERSION=$(git rev-parse --short HEAD)
84-docker tag myapp registry.mydomain.com/myapp:$VERSION157+docker tag myapp gordon.example.com/myapp:$VERSION
85-docker push registry.mydomain.com/myapp:$VERSION158+docker push gordon.example.com/myapp:$VERSION
86 ```159 ```
87 160
88 ## Zero-Downtime Deployment161 ## Zero-Downtime Deployment
89 162
90 Gordon performs zero-downtime deployments by default:163 Gordon performs zero-downtime deployments by default:
91 164
92-1. **New container starts** while old is still running165+1. **New container starts** while the old one is still running
93-2. **Health check** waits for new container readiness166+2. **Health check** waits for the new container to become ready
94-3. **Traffic switches** to new container167+3. **Traffic switches** to the new container
95 4. **Old container stops** after traffic moves168 4. **Old container stops** after traffic moves
96 169
97 ```170 ```
@@ -103,83 +176,12 @@ New Container: [══════════════════
103 ↑ start ↑ traffic routed176 ↑ start ↑ traffic routed
104 ```177 ```
105 178
106-## Environment-Specific Deployments
107-
108-### Development
109-
110-```toml
111-[routes]
112-"dev.mydomain.com" = "myapp:latest"
113-```
114-
115-```bash
116-docker push registry.mydomain.com/myapp:latest
117-```
118-
119-### Staging
120-
121-```toml
122-[routes]
123-"staging.mydomain.com" = "myapp:staging"
124-```
125-
126-```bash
127-docker tag myapp registry.mydomain.com/myapp:staging
128-docker push registry.mydomain.com/myapp:staging
129-```
130-
131-### Production
132-
133-```toml
134-[routes]
135-"app.mydomain.com" = "myapp:v2.1.0"
136-```
137-
138-```bash
139-docker tag myapp registry.mydomain.com/myapp:v2.1.0
140-docker push registry.mydomain.com/myapp:v2.1.0
141-# Then update gordon.toml and reload
142-```
143-
144-## Deployment Checklist
145-
146-Before deploying:
147-
148-- [ ] Build passes locally
149-- [ ] Tests pass
150-- [ ] Environment file configured (`~/.gordon/env/app_mydomain_com.env`)
151-- [ ] DNS pointing to Gordon server
152-- [ ] Route configured in `gordon.toml`
153-
154-After deploying:
155-
156-- [ ] Container starts successfully
157-- [ ] Application responds to requests
158-- [ ] Logs show no errors
159-- [ ] Health checks pass
160-
161-## Monitoring Deployments
162-
163-### Watch Logs
164-
165-```bash
166-gordon logs -f
167-```
168-
169-### Check Container Status
170-
171-```bash
172-docker ps | grep gordon
173-```
174-
175-### Verify Routing
176-
177-```bash
178-curl -I https://app.mydomain.com
179-```
180-
181 ## Related179 ## Related
182 180
183 - [GitHub Actions](./github-actions.md)181 - [GitHub Actions](./github-actions.md)
182+- [GitLab CI](./gitlab-ci.md)
183+- [Generic CI](./generic-ci.md)
184 - [Rollback](./rollback.md)184 - [Rollback](./rollback.md)
185 - [Routes Configuration](../config/routes.md)185 - [Routes Configuration](../config/routes.md)
186+- [Authentication](../config/auth.md)
187+- [Auto-Route](../config/auto-route.md)
M docs/index.md +3 -1
@@ -60,8 +60,10 @@ Gordon runs on your VPS and provides:
6060
6161 ### Deployment
6262
63-- [Deployment Overview](./deployment/index.md) - Deployment strategies
63+- [Deployment Overview](./deployment/index.md) - Deployment strategies and methods
6464 - [GitHub Actions](./deployment/github-actions.md) - CI/CD with GitHub
65+- [GitLab CI](./deployment/gitlab-ci.md) - CI/CD with GitLab
66+- [Generic CI](./deployment/generic-ci.md) - Jenkins, CircleCI, Drone, and others
6567 - [Rollback](./deployment/rollback.md) - Version management and rollback
6668
6769 ### Reference
@@ -60,8 +60,10 @@ Gordon runs on your VPS and provides:
60 60
61 ### Deployment61 ### Deployment
62 62
63-- [Deployment Overview](./deployment/index.md) - Deployment strategies63+- [Deployment Overview](./deployment/index.md) - Deployment strategies and methods
64 - [GitHub Actions](./deployment/github-actions.md) - CI/CD with GitHub64 - [GitHub Actions](./deployment/github-actions.md) - CI/CD with GitHub
65+- [GitLab CI](./deployment/gitlab-ci.md) - CI/CD with GitLab
66+- [Generic CI](./deployment/generic-ci.md) - Jenkins, CircleCI, Drone, and others
65 - [Rollback](./deployment/rollback.md) - Version management and rollback67 - [Rollback](./deployment/rollback.md) - Version management and rollback
66 68
67 ### Reference69 ### Reference
M internal/adapters/in/cli/auth.go +26 -11
@@ -74,6 +74,7 @@ func newTokenGenerateCmd() *cobra.Command {
7474 scopes string
7575 expiry string
7676 configPath string
77+ repo string
7778 )
7879
7980 cmd := &cobra.Command{
@@ -101,10 +102,15 @@ Admin scopes (for remote CLI access):
101102 admin:routes:read Read-only routes access
102103 admin:status:read Read-only status access
103104
105+Repository scoping:
106+ --repo myapp Scope to specific repository
107+ --repo "*" All repositories (default)
108+
104109 Combined examples:
105- --scopes "push,pull" Registry only (default)
106- --scopes "admin:*:*" Admin CLI only
107- --scopes "push,pull,admin:*:*" Registry + full admin
110+ --scopes "push,pull" All repos, registry only (default)
111+ --scopes "push" --repo myapp Push to myapp only
112+ --scopes "push,pull,admin:routes:read" Minimum CI scope (all repos)
113+ --scopes "push,admin:routes:read" --repo app Minimum CI scope (specific repo)
108114
109115 EXPIRY:
110116
@@ -115,7 +121,7 @@ Supports human-friendly durations:
115121 Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d
116122 Use --expiry=0 for a token that never expires (useful for CI).`,
117123 RunE: func(cmd *cobra.Command, args []string) error {
118- return runTokenGenerate(subject, scopes, expiry, configPath)
124+ return runTokenGenerate(subject, scopes, expiry, configPath, repo)
119125 },
120126 }
121127
@@ -123,6 +129,7 @@ Use --expiry=0 for a token that never expires (useful for CI).`,
123129 cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")
124130 cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)")
125131 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
132+ cmd.Flags().StringVar(&repo, "repo", "*", "Repository to scope the token to (default: * for all repositories)")
126133
127134 _ = cmd.MarkFlagRequired("subject")
128135
@@ -434,7 +441,12 @@ The hash can be stored in your secrets backend and referenced in the config:
434441 // runTokenGenerate generates a new authentication token.
435442 // parseAndConvertScopes parses a comma-separated scope string and converts
436443 // simple scopes (push, pull, *) to Docker v2 format (repository:*:action).
437-func parseAndConvertScopes(scopesStr 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+
438450 rawScopes := strings.Split(scopesStr, ",")
439451 for i := range rawScopes {
440452 rawScopes[i] = strings.TrimSpace(rawScopes[i])
@@ -450,10 +462,10 @@ func parseAndConvertScopes(scopesStr string) []string {
450462 }
451463
452464 if !hasSimpleScope {
453- return rawScopes
465+ return rawScopes, nil
454466 }
455467
456- // Convert simple scopes to v2 format: repository:*:push,pull
468+ // Convert simple scopes to v2 format: repository:<repo>:push,pull
457469 var scopes []string
458470 var actions []string
459471 for _, s := range rawScopes {
@@ -465,12 +477,12 @@ func parseAndConvertScopes(scopesStr string) []string {
465477 }
466478 }
467479 if len(actions) > 0 {
468- scopes = append(scopes, "repository:*:"+strings.Join(actions, ","))
480+ scopes = append(scopes, "repository:"+repo+":"+strings.Join(actions, ","))
469481 }
470- return scopes
482+ return scopes, nil
471483 }
472484
473-func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
485+func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) error {
474486 cfg, err := loadAuthConfig(configPath)
475487 if err != nil {
476488 return err
@@ -485,7 +497,10 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
485497 }
486498 }
487499
488- scopes := parseAndConvertScopes(scopesStr)
500+ scopes, err := parseAndConvertScopes(scopesStr, repo)
501+ if err != nil {
502+ return fmt.Errorf("invalid scopes: %w", err)
503+ }
489504
490505 // Create auth service
491506 log := zerowrap.New(zerowrap.Config{Level: "warn"})
@@ -74,6 +74,7 @@ func newTokenGenerateCmd() *cobra.Command {
74 scopes string74 scopes string
75 expiry string75 expiry string
76 configPath string76 configPath string
77+ repo string
77 )78 )
78 79
79 cmd := &cobra.Command{80 cmd := &cobra.Command{
@@ -101,10 +102,15 @@ Admin scopes (for remote CLI access):
101 admin:routes:read Read-only routes access102 admin:routes:read Read-only routes access
102 admin:status:read Read-only status access103 admin:status:read Read-only status access
103 104
105+Repository scoping:
106+ --repo myapp Scope to specific repository
107+ --repo "*" All repositories (default)
108+
104 Combined examples:109 Combined examples:
105- --scopes "push,pull" Registry only (default)110+ --scopes "push,pull" All repos, registry only (default)
106- --scopes "admin:*:*" Admin CLI only111+ --scopes "push" --repo myapp Push to myapp only
107- --scopes "push,pull,admin:*:*" Registry + full admin112+ --scopes "push,pull,admin:routes:read" Minimum CI scope (all repos)
113+ --scopes "push,admin:routes:read" --repo app Minimum CI scope (specific repo)
108 114
109 EXPIRY:115 EXPIRY:
110 116
@@ -115,7 +121,7 @@ Supports human-friendly durations:
115 Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d121 Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d
116 Use --expiry=0 for a token that never expires (useful for CI).`,122 Use --expiry=0 for a token that never expires (useful for CI).`,
117 RunE: func(cmd *cobra.Command, args []string) error {123 RunE: func(cmd *cobra.Command, args []string) error {
118- return runTokenGenerate(subject, scopes, expiry, configPath)124+ return runTokenGenerate(subject, scopes, expiry, configPath, repo)
119 },125 },
120 }126 }
121 127
@@ -123,6 +129,7 @@ Use --expiry=0 for a token that never expires (useful for CI).`,
123 cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")129 cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")
124 cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)")130 cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)")
125 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")131 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
132+ cmd.Flags().StringVar(&repo, "repo", "*", "Repository to scope the token to (default: * for all repositories)")
126 133
127 _ = cmd.MarkFlagRequired("subject")134 _ = cmd.MarkFlagRequired("subject")
128 135
@@ -434,7 +441,12 @@ The hash can be stored in your secrets backend and referenced in the config:
434 // runTokenGenerate generates a new authentication token.441 // runTokenGenerate generates a new authentication token.
435 // parseAndConvertScopes parses a comma-separated scope string and converts442 // parseAndConvertScopes parses a comma-separated scope string and converts
436 // simple scopes (push, pull, *) to Docker v2 format (repository:*:action).443 // simple scopes (push, pull, *) to Docker v2 format (repository:*:action).
437-func parseAndConvertScopes(scopesStr 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+
438 rawScopes := strings.Split(scopesStr, ",")450 rawScopes := strings.Split(scopesStr, ",")
439 for i := range rawScopes {451 for i := range rawScopes {
440 rawScopes[i] = strings.TrimSpace(rawScopes[i])452 rawScopes[i] = strings.TrimSpace(rawScopes[i])
@@ -450,10 +462,10 @@ func parseAndConvertScopes(scopesStr string) []string {
450 }462 }
451 463
452 if !hasSimpleScope {464 if !hasSimpleScope {
453- return rawScopes465+ return rawScopes, nil
454 }466 }
455 467
456- // Convert simple scopes to v2 format: repository:*:push,pull468+ // Convert simple scopes to v2 format: repository:<repo>:push,pull
457 var scopes []string469 var scopes []string
458 var actions []string470 var actions []string
459 for _, s := range rawScopes {471 for _, s := range rawScopes {
@@ -465,12 +477,12 @@ func parseAndConvertScopes(scopesStr string) []string {
465 }477 }
466 }478 }
467 if len(actions) > 0 {479 if len(actions) > 0 {
468- scopes = append(scopes, "repository:*:"+strings.Join(actions, ","))480+ scopes = append(scopes, "repository:"+repo+":"+strings.Join(actions, ","))
469 }481 }
470- return scopes482+ return scopes, nil
471 }483 }
472 484
473-func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {485+func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) error {
474 cfg, err := loadAuthConfig(configPath)486 cfg, err := loadAuthConfig(configPath)
475 if err != nil {487 if err != nil {
476 return err488 return err
@@ -485,7 +497,10 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
485 }497 }
486 }498 }
487 499
488- scopes := parseAndConvertScopes(scopesStr)500+ scopes, err := parseAndConvertScopes(scopesStr, repo)
501+ if err != nil {
502+ return fmt.Errorf("invalid scopes: %w", err)
503+ }
489 504
490 // Create auth service505 // Create auth service
491 log := zerowrap.New(zerowrap.Config{Level: "warn"})506 log := zerowrap.New(zerowrap.Config{Level: "warn"})
A internal/adapters/in/cli/auth_scopes_test.go +49 -0
@@ -0,0 +1,49 @@
1+package cli
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestParseAndConvertScopes_DefaultWildcard(t *testing.T) {
11+ got, err := parseAndConvertScopes("push,pull", "*")
12+ require.NoError(t, err)
13+ assert.Equal(t, []string{"repository:*:push,pull"}, got)
14+}
15+
16+func TestParseAndConvertScopes_SpecificRepo(t *testing.T) {
17+ got, err := parseAndConvertScopes("push,pull", "myapp")
18+ require.NoError(t, err)
19+ assert.Equal(t, []string{"repository:myapp:push,pull"}, got)
20+}
21+
22+func TestParseAndConvertScopes_SpecificRepoPushOnly(t *testing.T) {
23+ got, err := parseAndConvertScopes("push", "myapp")
24+ require.NoError(t, err)
25+ assert.Equal(t, []string{"repository:myapp:push"}, got)
26+}
27+
28+func TestParseAndConvertScopes_MixedWithAdminScopes(t *testing.T) {
29+ got, err := parseAndConvertScopes("push,pull,admin:routes:read", "myapp")
30+ require.NoError(t, err)
31+ assert.ElementsMatch(t, []string{"repository:myapp:push,pull", "admin:routes:read"}, got)
32+}
33+
34+func TestParseAndConvertScopes_AlreadyV2Format_RepoIgnored(t *testing.T) {
35+ // When scopes are already in v2 format, --repo is ignored (scopes returned as-is)
36+ got, err := parseAndConvertScopes("repository:specific:push", "*")
37+ require.NoError(t, err)
38+ assert.Equal(t, []string{"repository:specific:push"}, got)
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+}
@@ -0,0 +1,49 @@
1+package cli
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestParseAndConvertScopes_DefaultWildcard(t *testing.T) {
11+ got, err := parseAndConvertScopes("push,pull", "*")
12+ require.NoError(t, err)
13+ assert.Equal(t, []string{"repository:*:push,pull"}, got)
14+}
15+
16+func TestParseAndConvertScopes_SpecificRepo(t *testing.T) {
17+ got, err := parseAndConvertScopes("push,pull", "myapp")
18+ require.NoError(t, err)
19+ assert.Equal(t, []string{"repository:myapp:push,pull"}, got)
20+}
21+
22+func TestParseAndConvertScopes_SpecificRepoPushOnly(t *testing.T) {
23+ got, err := parseAndConvertScopes("push", "myapp")
24+ require.NoError(t, err)
25+ assert.Equal(t, []string{"repository:myapp:push"}, got)
26+}
27+
28+func TestParseAndConvertScopes_MixedWithAdminScopes(t *testing.T) {
29+ got, err := parseAndConvertScopes("push,pull,admin:routes:read", "myapp")
30+ require.NoError(t, err)
31+ assert.ElementsMatch(t, []string{"repository:myapp:push,pull", "admin:routes:read"}, got)
32+}
33+
34+func TestParseAndConvertScopes_AlreadyV2Format_RepoIgnored(t *testing.T) {
35+ // When scopes are already in v2 format, --repo is ignored (scopes returned as-is)
36+ got, err := parseAndConvertScopes("repository:specific:push", "*")
37+ require.NoError(t, err)
38+ assert.Equal(t, []string{"repository:specific:push"}, got)
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 +27 -9
@@ -3,7 +3,9 @@ package cli
33 import (
44 "bufio"
55 "context"
6+ "errors"
67 "fmt"
8+ "net/http"
79 "os"
810 "os/exec"
911 "path/filepath"
@@ -200,14 +202,7 @@ func runPush(ctx context.Context, req pushRequest) error {
200202 }
201203 fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain))
202204
203- // Signal the server to suppress event-based deploys for this image.
204- // The CLI will trigger an explicit deploy after push completes.
205- if !req.NoDeploy {
206- if err := handle.plane.DeployIntent(ctx, imageName); err != nil {
207- // Non-fatal: worst case we get a redundant deploy via event
208- fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err)
209- }
210- }
205+ skipExplicitDeploy := shouldSkipDeploy(ctx, handle.plane, imageName, req.NoDeploy)
211206
212207 build := buildConfig{
213208 Enabled: req.Build.Enabled,
@@ -228,13 +223,36 @@ func runPush(ctx context.Context, req pushRequest) error {
228223
229224 fmt.Println(styles.RenderSuccess("Push complete"))
230225
231- if !req.NoDeploy {
226+ if !skipExplicitDeploy {
232227 return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm)
233228 }
234229
235230 return nil
236231 }
237232
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+ // 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.
238+ if err := plane.DeployIntent(ctx, imageName); err != nil {
239+ if isInsufficientScope(err) {
240+ fmt.Fprintln(os.Stderr, "info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive")
241+ return true
242+ }
243+ // Non-fatal: worst case we get a redundant deploy via event
244+ fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err)
245+ }
246+ return noDeploy
247+}
248+
249+// isInsufficientScope returns true if the error is an HTTP 403 Forbidden response,
250+// meaning the token lacks the required admin scope.
251+func isInsufficientScope(err error) bool {
252+ var httpErr *remote.HTTPError
253+ return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusForbidden
254+}
255+
238256 // resolveFromDomain resolves image info from a domain name (legacy mode).
239257 func resolveFromDomain(ctx context.Context, cp ControlPlane, pushDomain string) (registry, imageName, resolvedDomain string, err error) {
240258 route, err := cp.GetRoute(ctx, pushDomain)
@@ -3,7 +3,9 @@ package cli
3 import (3 import (
4 "bufio"4 "bufio"
5 "context"5 "context"
6+ "errors"
6 "fmt"7 "fmt"
8+ "net/http"
7 "os"9 "os"
8 "os/exec"10 "os/exec"
9 "path/filepath"11 "path/filepath"
@@ -200,14 +202,7 @@ func runPush(ctx context.Context, req pushRequest) error {
200 }202 }
201 fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain))203 fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain))
202 204
203- // Signal the server to suppress event-based deploys for this image.205+ skipExplicitDeploy := shouldSkipDeploy(ctx, handle.plane, imageName, req.NoDeploy)
204- // The CLI will trigger an explicit deploy after push completes.
205- if !req.NoDeploy {
206- if err := handle.plane.DeployIntent(ctx, imageName); err != nil {
207- // Non-fatal: worst case we get a redundant deploy via event
208- fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err)
209- }
210- }
211 206
212 build := buildConfig{207 build := buildConfig{
213 Enabled: req.Build.Enabled,208 Enabled: req.Build.Enabled,
@@ -228,13 +223,36 @@ func runPush(ctx context.Context, req pushRequest) error {
228 223
229 fmt.Println(styles.RenderSuccess("Push complete"))224 fmt.Println(styles.RenderSuccess("Push complete"))
230 225
231- if !req.NoDeploy {226+ if !skipExplicitDeploy {
232 return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm)227 return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm)
233 }228 }
234 229
235 return nil230 return nil
236 }231 }
237 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+ // 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.
238+ if err := plane.DeployIntent(ctx, imageName); err != nil {
239+ if isInsufficientScope(err) {
240+ fmt.Fprintln(os.Stderr, "info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive")
241+ return true
242+ }
243+ // Non-fatal: worst case we get a redundant deploy via event
244+ fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err)
245+ }
246+ return noDeploy
247+}
248+
249+// isInsufficientScope returns true if the error is an HTTP 403 Forbidden response,
250+// meaning the token lacks the required admin scope.
251+func isInsufficientScope(err error) bool {
252+ var httpErr *remote.HTTPError
253+ return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusForbidden
254+}
255+
238 // resolveFromDomain resolves image info from a domain name (legacy mode).256 // resolveFromDomain resolves image info from a domain name (legacy mode).
239 func resolveFromDomain(ctx context.Context, cp ControlPlane, pushDomain string) (registry, imageName, resolvedDomain string, err error) {257 func resolveFromDomain(ctx context.Context, cp ControlPlane, pushDomain string) (registry, imageName, resolvedDomain string, err error) {
240 route, err := cp.GetRoute(ctx, pushDomain)258 route, err := cp.GetRoute(ctx, pushDomain)
M internal/adapters/in/cli/push_image_ops.go +90 -7
@@ -2,10 +2,13 @@ package cli
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "io"
8+ "net/http"
79 "os"
810 "os/exec"
11+ "strings"
912
1013 cerrdefs "github.com/containerd/errdefs"
1114 "github.com/docker/docker/client"
@@ -30,9 +33,10 @@ type pushImageOps interface {
3033
3134 // dockerImageOps implements pushImageOps using the Docker SDK and registrypush.
3235 type dockerImageOps struct {
33- cli client.APIClient
34- insecureTLS bool
35- progress io.Writer
36+ cli client.APIClient
37+ insecureTLS bool
38+ progress io.Writer
39+ remoteClient *remote.Client // nil when local
3640 }
3741
3842 // newDockerImageOps creates a dockerImageOps from the Docker environment.
@@ -50,8 +54,20 @@ func newDockerImageOps(insecureTLS bool, progress io.Writer) (*dockerImageOps, e
5054
5155 // newImageOpsFromFlags resolves TLS settings from CLI flags/config and creates ops.
5256 func newImageOpsFromFlags() (pushImageOps, error) {
53- _, _, insecureTLS, _ := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag)
54- return newDockerImageOps(insecureTLS, os.Stderr)
57+ remoteURL, token, insecureTLS, _ := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag)
58+ ops, err := newDockerImageOps(insecureTLS, os.Stderr)
59+ if err != nil {
60+ return nil, err
61+ }
62+ if remoteURL != "" && token != "" {
63+ var clientOpts []remote.ClientOption
64+ clientOpts = append(clientOpts, remote.WithToken(token))
65+ if insecureTLS {
66+ clientOpts = append(clientOpts, remote.WithInsecureTLS(true))
67+ }
68+ ops.remoteClient = remote.NewClient(remoteURL, clientOpts...)
69+ }
70+ return ops, nil
5571 }
5672
5773 func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) error {
@@ -62,13 +78,80 @@ func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) e
6278 }
6379
6480 func (d *dockerImageOps) Push(ctx context.Context, ref string) error {
65- pusher := registrypush.New(
81+ opts := []registrypush.Option{
6682 registrypush.WithProgress(d.progress),
6783 registrypush.WithInsecureTLS(d.insecureTLS),
68- )
84+ }
85+
86+ if d.remoteClient != nil {
87+ authHeader, err := d.exchangeRegistryAuth(ctx)
88+ if err != nil {
89+ return fmt.Errorf("failed to obtain registry credentials: %w", err)
90+ }
91+ opts = append(opts, registrypush.WithAuth(authHeader))
92+
93+ pusher := registrypush.New(opts...)
94+ err = pusher.Push(ctx, ref)
95+ if err != nil && isAuthError(err) {
96+ // Token may have expired mid-push; refresh once and retry.
97+ authHeader, refreshErr := d.exchangeRegistryAuth(ctx)
98+ if refreshErr != nil {
99+ return fmt.Errorf("failed to refresh registry credentials after auth error: %w (original: %v)", refreshErr, err)
100+ }
101+ retryOpts := []registrypush.Option{
102+ registrypush.WithProgress(d.progress),
103+ registrypush.WithInsecureTLS(d.insecureTLS),
104+ registrypush.WithAuth(authHeader),
105+ }
106+ pusher = registrypush.New(retryOpts...)
107+ return pusher.Push(ctx, ref)
108+ }
109+ return err
110+ }
111+
112+ pusher := registrypush.New(opts...)
69113 return pusher.Push(ctx, ref)
70114 }
71115
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.
119+func isAuthError(err error) bool {
120+ if err == nil {
121+ return false
122+ }
123+ var httpErr *remote.HTTPError
124+ if errors.As(err, &httpErr) {
125+ return httpErr.StatusCode == http.StatusUnauthorized
126+ }
127+ msg := err.Error()
128+ return strings.Contains(msg, "status 401") || strings.Contains(msg, "401 Unauthorized")
129+}
130+
131+// exchangeRegistryAuth exchanges the long-lived Gordon token for a short-lived
132+// registry access token. Returns the Authorization header value (e.g. "Bearer <token>").
133+func (d *dockerImageOps) exchangeRegistryAuth(ctx context.Context) (string, error) {
134+ // Step 1: Get subject from server (validates token is still active)
135+ verifyResp, err := d.remoteClient.VerifyAuth(ctx)
136+ if err != nil {
137+ return "", fmt.Errorf("failed to verify token: %w", err)
138+ }
139+ if !verifyResp.Valid {
140+ return "", fmt.Errorf("token is not valid")
141+ }
142+ if verifyResp.Subject == "" {
143+ return "", fmt.Errorf("empty subject in verified token")
144+ }
145+
146+ // Step 2: Exchange long-lived token for short-lived registry token
147+ shortToken, err := d.remoteClient.ExchangeRegistryToken(ctx, verifyResp.Subject)
148+ if err != nil {
149+ return "", fmt.Errorf("failed to exchange registry token: %w", err)
150+ }
151+
152+ return "Bearer " + shortToken, nil
153+}
154+
72155 func (d *dockerImageOps) Exists(ctx context.Context, imageRef string) (bool, error) {
73156 _, err := d.cli.ImageInspect(ctx, imageRef)
74157 if err != nil {
@@ -2,10 +2,13 @@ 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"
11+ "strings"
9 12
10 cerrdefs "github.com/containerd/errdefs"13 cerrdefs "github.com/containerd/errdefs"
11 "github.com/docker/docker/client"14 "github.com/docker/docker/client"
@@ -30,9 +33,10 @@ type pushImageOps interface {
30 33
31 // dockerImageOps implements pushImageOps using the Docker SDK and registrypush.34 // dockerImageOps implements pushImageOps using the Docker SDK and registrypush.
32 type dockerImageOps struct {35 type dockerImageOps struct {
33- cli client.APIClient36+ cli client.APIClient
34- insecureTLS bool37+ insecureTLS bool
35- progress io.Writer38+ progress io.Writer
39+ remoteClient *remote.Client // nil when local
36 }40 }
37 41
38 // newDockerImageOps creates a dockerImageOps from the Docker environment.42 // newDockerImageOps creates a dockerImageOps from the Docker environment.
@@ -50,8 +54,20 @@ func newDockerImageOps(insecureTLS bool, progress io.Writer) (*dockerImageOps, e
50 54
51 // newImageOpsFromFlags resolves TLS settings from CLI flags/config and creates ops.55 // newImageOpsFromFlags resolves TLS settings from CLI flags/config and creates ops.
52 func newImageOpsFromFlags() (pushImageOps, error) {56 func newImageOpsFromFlags() (pushImageOps, error) {
53- _, _, insecureTLS, _ := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag)57+ remoteURL, token, insecureTLS, _ := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag)
54- return newDockerImageOps(insecureTLS, os.Stderr)58+ ops, err := newDockerImageOps(insecureTLS, os.Stderr)
59+ if err != nil {
60+ return nil, err
61+ }
62+ if remoteURL != "" && token != "" {
63+ var clientOpts []remote.ClientOption
64+ clientOpts = append(clientOpts, remote.WithToken(token))
65+ if insecureTLS {
66+ clientOpts = append(clientOpts, remote.WithInsecureTLS(true))
67+ }
68+ ops.remoteClient = remote.NewClient(remoteURL, clientOpts...)
69+ }
70+ return ops, nil
55 }71 }
56 72
57 func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) error {73 func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) error {
@@ -62,13 +78,80 @@ func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) e
62 }78 }
63 79
64 func (d *dockerImageOps) Push(ctx context.Context, ref string) error {80 func (d *dockerImageOps) Push(ctx context.Context, ref string) error {
65- pusher := registrypush.New(81+ opts := []registrypush.Option{
66 registrypush.WithProgress(d.progress),82 registrypush.WithProgress(d.progress),
67 registrypush.WithInsecureTLS(d.insecureTLS),83 registrypush.WithInsecureTLS(d.insecureTLS),
68- )84+ }
85+
86+ if d.remoteClient != nil {
87+ authHeader, err := d.exchangeRegistryAuth(ctx)
88+ if err != nil {
89+ return fmt.Errorf("failed to obtain registry credentials: %w", err)
90+ }
91+ opts = append(opts, registrypush.WithAuth(authHeader))
92+
93+ pusher := registrypush.New(opts...)
94+ err = pusher.Push(ctx, ref)
95+ if err != nil && isAuthError(err) {
96+ // Token may have expired mid-push; refresh once and retry.
97+ authHeader, refreshErr := d.exchangeRegistryAuth(ctx)
98+ if refreshErr != nil {
99+ return fmt.Errorf("failed to refresh registry credentials after auth error: %w (original: %v)", refreshErr, err)
100+ }
101+ retryOpts := []registrypush.Option{
102+ registrypush.WithProgress(d.progress),
103+ registrypush.WithInsecureTLS(d.insecureTLS),
104+ registrypush.WithAuth(authHeader),
105+ }
106+ pusher = registrypush.New(retryOpts...)
107+ return pusher.Push(ctx, ref)
108+ }
109+ return err
110+ }
111+
112+ pusher := registrypush.New(opts...)
69 return pusher.Push(ctx, ref)113 return pusher.Push(ctx, ref)
70 }114 }
71 115
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.
119+func isAuthError(err error) bool {
120+ if err == nil {
121+ return false
122+ }
123+ var httpErr *remote.HTTPError
124+ if errors.As(err, &httpErr) {
125+ return httpErr.StatusCode == http.StatusUnauthorized
126+ }
127+ msg := err.Error()
128+ return strings.Contains(msg, "status 401") || strings.Contains(msg, "401 Unauthorized")
129+}
130+
131+// exchangeRegistryAuth exchanges the long-lived Gordon token for a short-lived
132+// registry access token. Returns the Authorization header value (e.g. "Bearer <token>").
133+func (d *dockerImageOps) exchangeRegistryAuth(ctx context.Context) (string, error) {
134+ // Step 1: Get subject from server (validates token is still active)
135+ verifyResp, err := d.remoteClient.VerifyAuth(ctx)
136+ if err != nil {
137+ return "", fmt.Errorf("failed to verify token: %w", err)
138+ }
139+ if !verifyResp.Valid {
140+ return "", fmt.Errorf("token is not valid")
141+ }
142+ if verifyResp.Subject == "" {
143+ return "", fmt.Errorf("empty subject in verified token")
144+ }
145+
146+ // Step 2: Exchange long-lived token for short-lived registry token
147+ shortToken, err := d.remoteClient.ExchangeRegistryToken(ctx, verifyResp.Subject)
148+ if err != nil {
149+ return "", fmt.Errorf("failed to exchange registry token: %w", err)
150+ }
151+
152+ return "Bearer " + shortToken, nil
153+}
154+
72 func (d *dockerImageOps) Exists(ctx context.Context, imageRef string) (bool, error) {155 func (d *dockerImageOps) Exists(ctx context.Context, imageRef string) (bool, error) {
73 _, err := d.cli.ImageInspect(ctx, imageRef)156 _, err := d.cli.ImageInspect(ctx, imageRef)
74 if err != nil {157 if err != nil {
A internal/adapters/in/cli/push_image_ops_test.go +114 -0
@@ -0,0 +1,114 @@
1+package cli
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/adapters/dto"
14+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
15+)
16+
17+// TestExchangeRegistryAuth_Success verifies the happy path:
18+// VerifyAuth is called, then ExchangeRegistryToken is called with the
19+// subject from VerifyAuth, and the result is "Bearer <short-lived-token>".
20+func TestExchangeRegistryAuth_Success(t *testing.T) {
21+ const subject = "ci-bot"
22+ const shortToken = "short-lived-token-abc123"
23+
24+ verifyHandled := false
25+ tokenHandled := false
26+
27+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28+ switch r.URL.Path {
29+ case "/admin/auth/verify":
30+ verifyHandled = true
31+ w.Header().Set("Content-Type", "application/json")
32+ resp := dto.AuthVerifyResponse{
33+ Valid: true,
34+ Subject: subject,
35+ Scopes: []string{"push", "pull"},
36+ }
37+ json.NewEncoder(w).Encode(resp) //nolint:errcheck
38+ case "/auth/token":
39+ tokenHandled = true
40+ // Verify basic auth was set with subject as username
41+ u, p, ok := r.BasicAuth()
42+ assert.True(t, ok, "expected basic auth")
43+ assert.Equal(t, subject, u, "expected subject as username")
44+ assert.NotEmpty(t, p, "expected token as password")
45+
46+ w.Header().Set("Content-Type", "application/json")
47+ resp := dto.TokenResponse{Token: shortToken}
48+ json.NewEncoder(w).Encode(resp) //nolint:errcheck
49+ default:
50+ http.NotFound(w, r)
51+ }
52+ }))
53+ defer srv.Close()
54+
55+ client := remote.NewClient(srv.URL, remote.WithToken("long-lived-token"))
56+ ops := &dockerImageOps{
57+ remoteClient: client,
58+ }
59+
60+ got, err := ops.exchangeRegistryAuth(context.Background())
61+
62+ require.NoError(t, err)
63+ assert.Equal(t, "Bearer "+shortToken, got)
64+ assert.True(t, verifyHandled, "expected /admin/auth/verify to be called")
65+ assert.True(t, tokenHandled, "expected /auth/token to be called")
66+}
67+
68+// TestExchangeRegistryAuth_VerifyAuthFails verifies that when VerifyAuth fails,
69+// exchangeRegistryAuth returns an error.
70+func TestExchangeRegistryAuth_VerifyAuthFails(t *testing.T) {
71+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
72+ if r.URL.Path == "/admin/auth/verify" {
73+ http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
74+ return
75+ }
76+ http.NotFound(w, r)
77+ }))
78+ defer srv.Close()
79+
80+ client := remote.NewClient(srv.URL, remote.WithToken("bad-token"))
81+ ops := &dockerImageOps{
82+ remoteClient: client,
83+ }
84+
85+ _, err := ops.exchangeRegistryAuth(context.Background())
86+
87+ require.Error(t, err)
88+ assert.Contains(t, err.Error(), "failed to verify token")
89+}
90+
91+// TestExchangeRegistryAuth_TokenNotValid verifies that when VerifyAuth returns
92+// Valid=false, exchangeRegistryAuth returns an error.
93+func TestExchangeRegistryAuth_TokenNotValid(t *testing.T) {
94+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
95+ if r.URL.Path == "/admin/auth/verify" {
96+ w.Header().Set("Content-Type", "application/json")
97+ resp := dto.AuthVerifyResponse{Valid: false}
98+ json.NewEncoder(w).Encode(resp) //nolint:errcheck
99+ return
100+ }
101+ http.NotFound(w, r)
102+ }))
103+ defer srv.Close()
104+
105+ client := remote.NewClient(srv.URL, remote.WithToken("expired-token"))
106+ ops := &dockerImageOps{
107+ remoteClient: client,
108+ }
109+
110+ _, err := ops.exchangeRegistryAuth(context.Background())
111+
112+ require.Error(t, err)
113+ assert.Contains(t, err.Error(), "token is not valid")
114+}
@@ -0,0 +1,114 @@
1+package cli
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/adapters/dto"
14+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
15+)
16+
17+// TestExchangeRegistryAuth_Success verifies the happy path:
18+// VerifyAuth is called, then ExchangeRegistryToken is called with the
19+// subject from VerifyAuth, and the result is "Bearer <short-lived-token>".
20+func TestExchangeRegistryAuth_Success(t *testing.T) {
21+ const subject = "ci-bot"
22+ const shortToken = "short-lived-token-abc123"
23+
24+ verifyHandled := false
25+ tokenHandled := false
26+
27+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28+ switch r.URL.Path {
29+ case "/admin/auth/verify":
30+ verifyHandled = true
31+ w.Header().Set("Content-Type", "application/json")
32+ resp := dto.AuthVerifyResponse{
33+ Valid: true,
34+ Subject: subject,
35+ Scopes: []string{"push", "pull"},
36+ }
37+ json.NewEncoder(w).Encode(resp) //nolint:errcheck
38+ case "/auth/token":
39+ tokenHandled = true
40+ // Verify basic auth was set with subject as username
41+ u, p, ok := r.BasicAuth()
42+ assert.True(t, ok, "expected basic auth")
43+ assert.Equal(t, subject, u, "expected subject as username")
44+ assert.NotEmpty(t, p, "expected token as password")
45+
46+ w.Header().Set("Content-Type", "application/json")
47+ resp := dto.TokenResponse{Token: shortToken}
48+ json.NewEncoder(w).Encode(resp) //nolint:errcheck
49+ default:
50+ http.NotFound(w, r)
51+ }
52+ }))
53+ defer srv.Close()
54+
55+ client := remote.NewClient(srv.URL, remote.WithToken("long-lived-token"))
56+ ops := &dockerImageOps{
57+ remoteClient: client,
58+ }
59+
60+ got, err := ops.exchangeRegistryAuth(context.Background())
61+
62+ require.NoError(t, err)
63+ assert.Equal(t, "Bearer "+shortToken, got)
64+ assert.True(t, verifyHandled, "expected /admin/auth/verify to be called")
65+ assert.True(t, tokenHandled, "expected /auth/token to be called")
66+}
67+
68+// TestExchangeRegistryAuth_VerifyAuthFails verifies that when VerifyAuth fails,
69+// exchangeRegistryAuth returns an error.
70+func TestExchangeRegistryAuth_VerifyAuthFails(t *testing.T) {
71+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
72+ if r.URL.Path == "/admin/auth/verify" {
73+ http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
74+ return
75+ }
76+ http.NotFound(w, r)
77+ }))
78+ defer srv.Close()
79+
80+ client := remote.NewClient(srv.URL, remote.WithToken("bad-token"))
81+ ops := &dockerImageOps{
82+ remoteClient: client,
83+ }
84+
85+ _, err := ops.exchangeRegistryAuth(context.Background())
86+
87+ require.Error(t, err)
88+ assert.Contains(t, err.Error(), "failed to verify token")
89+}
90+
91+// TestExchangeRegistryAuth_TokenNotValid verifies that when VerifyAuth returns
92+// Valid=false, exchangeRegistryAuth returns an error.
93+func TestExchangeRegistryAuth_TokenNotValid(t *testing.T) {
94+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
95+ if r.URL.Path == "/admin/auth/verify" {
96+ w.Header().Set("Content-Type", "application/json")
97+ resp := dto.AuthVerifyResponse{Valid: false}
98+ json.NewEncoder(w).Encode(resp) //nolint:errcheck
99+ return
100+ }
101+ http.NotFound(w, r)
102+ }))
103+ defer srv.Close()
104+
105+ client := remote.NewClient(srv.URL, remote.WithToken("expired-token"))
106+ ops := &dockerImageOps{
107+ remoteClient: client,
108+ }
109+
110+ _, err := ops.exchangeRegistryAuth(context.Background())
111+
112+ require.Error(t, err)
113+ assert.Contains(t, err.Error(), "token is not valid")
114+}
M internal/adapters/in/cli/push_test.go +20 -0
@@ -2,6 +2,7 @@ package cli
22
33 import (
44 "context"
5+ "fmt"
56 "io"
67 "os"
78 "os/exec"
@@ -603,3 +604,22 @@ func TestResolveFromImage_NoRouteSuggestsBootstrap(t *testing.T) {
603604 assert.Contains(t, err.Error(), `no route configured for image "myapp"`)
604605 assert.Contains(t, err.Error(), "gordon bootstrap")
605606 }
607+
608+func TestIsInsufficientScope(t *testing.T) {
609+ tests := []struct {
610+ name string
611+ err error
612+ want bool
613+ }{
614+ {"nil error", nil, false},
615+ {"HTTPError 403", &remote.HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"}, true},
616+ {"wrapped HTTPError 403", fmt.Errorf("deploy intent: %w", &remote.HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "scope"}), true},
617+ {"HTTPError 500", &remote.HTTPError{StatusCode: 500, Status: "500 Internal Server Error", Body: "broke"}, false},
618+ {"plain error", fmt.Errorf("connection refused"), false},
619+ }
620+ for _, tt := range tests {
621+ t.Run(tt.name, func(t *testing.T) {
622+ assert.Equal(t, tt.want, isInsufficientScope(tt.err))
623+ })
624+ }
625+}
@@ -2,6 +2,7 @@ package cli
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 "io"6 "io"
6 "os"7 "os"
7 "os/exec"8 "os/exec"
@@ -603,3 +604,22 @@ func TestResolveFromImage_NoRouteSuggestsBootstrap(t *testing.T) {
603 assert.Contains(t, err.Error(), `no route configured for image "myapp"`)604 assert.Contains(t, err.Error(), `no route configured for image "myapp"`)
604 assert.Contains(t, err.Error(), "gordon bootstrap")605 assert.Contains(t, err.Error(), "gordon bootstrap")
605 }606 }
607+
608+func TestIsInsufficientScope(t *testing.T) {
609+ tests := []struct {
610+ name string
611+ err error
612+ want bool
613+ }{
614+ {"nil error", nil, false},
615+ {"HTTPError 403", &remote.HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"}, true},
616+ {"wrapped HTTPError 403", fmt.Errorf("deploy intent: %w", &remote.HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "scope"}), true},
617+ {"HTTPError 500", &remote.HTTPError{StatusCode: 500, Status: "500 Internal Server Error", Body: "broke"}, false},
618+ {"plain error", fmt.Errorf("connection refused"), false},
619+ }
620+ for _, tt := range tests {
621+ t.Run(tt.name, func(t *testing.T) {
622+ assert.Equal(t, tt.want, isInsufficientScope(tt.err))
623+ })
624+ }
625+}
M internal/adapters/in/cli/remote/auth.go +53 -1
@@ -8,6 +8,8 @@ import (
88 "io"
99 "net/http"
1010 "os"
11+
12+ "github.com/bnema/gordon/internal/adapters/dto"
1113 )
1214
1315 // PasswordRequest represents the request body for POST /auth/password.
@@ -53,7 +55,7 @@ func (c *Client) Authenticate(ctx context.Context, username, password string) (*
5355 defer resp.Body.Close()
5456
5557 if resp.StatusCode >= 400 {
56- body, _ := io.ReadAll(resp.Body)
58+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
5759 var errResp struct {
5860 Error string `json:"error"`
5961 }
@@ -71,6 +73,56 @@ func (c *Client) Authenticate(ctx context.Context, username, password string) (*
7173 return &result, nil
7274 }
7375
76+// ExchangeRegistryToken exchanges the client's long-lived Gordon token for a
77+// short-lived registry access token via the Docker v2 token endpoint.
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).
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.
83+ url := c.baseURL + "/auth/token?scope=repository:*:push,pull&service=gordon-registry"
84+
85+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
86+ if err != nil {
87+ return "", fmt.Errorf("failed to create request: %w", err)
88+ }
89+
90+ req.SetBasicAuth(subject, c.token)
91+ req.Header.Set("Accept", "application/json")
92+
93+ resp, err := c.httpClient.Do(req)
94+ if err != nil {
95+ return "", fmt.Errorf("failed to connect: %w", err)
96+ }
97+ defer resp.Body.Close()
98+
99+ if resp.StatusCode >= 400 {
100+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
101+ var errResp struct {
102+ Error string `json:"error"`
103+ }
104+ if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {
105+ return "", fmt.Errorf("%s: %s", resp.Status, errResp.Error)
106+ }
107+ return "", fmt.Errorf("%s: %s", resp.Status, string(body))
108+ }
109+
110+ var result dto.TokenResponse
111+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
112+ return "", fmt.Errorf("failed to decode response: %w", err)
113+ }
114+
115+ token := result.Token
116+ if token == "" {
117+ token = result.AccessToken
118+ }
119+ if token == "" {
120+ return "", fmt.Errorf("registry token exchange returned empty token")
121+ }
122+
123+ return token, nil
124+}
125+
74126 // UpdateRemoteToken updates the token for a named remote.
75127 func UpdateRemoteToken(name, token string) error {
76128 // Try pass first
@@ -8,6 +8,8 @@ import (
8 "io"8 "io"
9 "net/http"9 "net/http"
10 "os"10 "os"
11+
12+ "github.com/bnema/gordon/internal/adapters/dto"
11 )13 )
12 14
13 // PasswordRequest represents the request body for POST /auth/password.15 // PasswordRequest represents the request body for POST /auth/password.
@@ -53,7 +55,7 @@ func (c *Client) Authenticate(ctx context.Context, username, password string) (*
53 defer resp.Body.Close()55 defer resp.Body.Close()
54 56
55 if resp.StatusCode >= 400 {57 if resp.StatusCode >= 400 {
56- body, _ := io.ReadAll(resp.Body)58+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
57 var errResp struct {59 var errResp struct {
58 Error string `json:"error"`60 Error string `json:"error"`
59 }61 }
@@ -71,6 +73,56 @@ func (c *Client) Authenticate(ctx context.Context, username, password string) (*
71 return &result, nil73 return &result, nil
72 }74 }
73 75
76+// ExchangeRegistryToken exchanges the client's long-lived Gordon token for a
77+// short-lived registry access token via the Docker v2 token endpoint.
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).
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.
83+ url := c.baseURL + "/auth/token?scope=repository:*:push,pull&service=gordon-registry"
84+
85+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
86+ if err != nil {
87+ return "", fmt.Errorf("failed to create request: %w", err)
88+ }
89+
90+ req.SetBasicAuth(subject, c.token)
91+ req.Header.Set("Accept", "application/json")
92+
93+ resp, err := c.httpClient.Do(req)
94+ if err != nil {
95+ return "", fmt.Errorf("failed to connect: %w", err)
96+ }
97+ defer resp.Body.Close()
98+
99+ if resp.StatusCode >= 400 {
100+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
101+ var errResp struct {
102+ Error string `json:"error"`
103+ }
104+ if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {
105+ return "", fmt.Errorf("%s: %s", resp.Status, errResp.Error)
106+ }
107+ return "", fmt.Errorf("%s: %s", resp.Status, string(body))
108+ }
109+
110+ var result dto.TokenResponse
111+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
112+ return "", fmt.Errorf("failed to decode response: %w", err)
113+ }
114+
115+ token := result.Token
116+ if token == "" {
117+ token = result.AccessToken
118+ }
119+ if token == "" {
120+ return "", fmt.Errorf("registry token exchange returned empty token")
121+ }
122+
123+ return token, nil
124+}
125+
74 // UpdateRemoteToken updates the token for a named remote.126 // UpdateRemoteToken updates the token for a named remote.
75 func UpdateRemoteToken(name, token string) error {127 func UpdateRemoteToken(name, token string) error {
76 // Try pass first128 // Try pass first
M internal/adapters/in/cli/remote/client.go +19 -2
@@ -203,14 +203,31 @@ func parseResponse(resp *http.Response, target any) error {
203203 return nil
204204 }
205205
206+// HTTPError represents an HTTP error response with a status code.
207+// Use errors.As to check for specific status codes in error handling.
208+type HTTPError struct {
209+ StatusCode int
210+ Status string
211+ Body string
212+}
213+
214+func (e *HTTPError) Error() string {
215+ return e.Status + ": " + e.Body
216+}
217+
206218 func parseErrorResponse(resp *http.Response, body []byte) error {
219+ msg := string(body)
207220 var errResp struct {
208221 Error string `json:"error"`
209222 }
210223 if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {
211- return fmt.Errorf("%s: %s", resp.Status, errResp.Error)
224+ msg = errResp.Error
225+ }
226+ return &HTTPError{
227+ StatusCode: resp.StatusCode,
228+ Status: resp.Status,
229+ Body: msg,
212230 }
213- return fmt.Errorf("%s: %s", resp.Status, string(body))
214231 }
215232
216233 func isRetryableRequestError(err error) bool {
@@ -203,14 +203,31 @@ func parseResponse(resp *http.Response, target any) error {
203 return nil203 return nil
204 }204 }
205 205
206+// HTTPError represents an HTTP error response with a status code.
207+// Use errors.As to check for specific status codes in error handling.
208+type HTTPError struct {
209+ StatusCode int
210+ Status string
211+ Body string
212+}
213+
214+func (e *HTTPError) Error() string {
215+ return e.Status + ": " + e.Body
216+}
217+
206 func parseErrorResponse(resp *http.Response, body []byte) error {218 func parseErrorResponse(resp *http.Response, body []byte) error {
219+ msg := string(body)
207 var errResp struct {220 var errResp struct {
208 Error string `json:"error"`221 Error string `json:"error"`
209 }222 }
210 if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {223 if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {
211- return fmt.Errorf("%s: %s", resp.Status, errResp.Error)224+ msg = errResp.Error
225+ }
226+ return &HTTPError{
227+ StatusCode: resp.StatusCode,
228+ Status: resp.Status,
229+ Body: msg,
212 }230 }
213- return fmt.Errorf("%s: %s", resp.Status, string(body))
214 }231 }
215 232
216 func isRetryableRequestError(err error) bool {233 func isRetryableRequestError(err error) bool {
M internal/adapters/in/cli/remote/client_test.go +114 -0
@@ -3,6 +3,8 @@ package remote
33 import (
44 "context"
55 "encoding/json"
6+ "errors"
7+ "fmt"
68 "io"
79 "net/http"
810 "net/http/httptest"
@@ -250,3 +252,115 @@ func TestRequestWithRetry_CapsErrorBodySize(t *testing.T) {
250252 require.Error(t, err)
251253 assert.Less(t, len(err.Error()), 2*1024, "retry error body should be capped to ~1KB")
252254 }
255+
256+func TestExchangeRegistryToken_Success(t *testing.T) {
257+ const subject = "testuser"
258+ const gordonToken = "gordon-jwt-token"
259+ const shortToken = "short-lived-123"
260+
261+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
262+ require.Equal(t, "/auth/token", r.URL.Path)
263+ require.Equal(t, http.MethodGet, r.Method)
264+ require.Equal(t, "repository:*:push,pull", r.URL.Query().Get("scope"))
265+ require.Equal(t, "gordon-registry", r.URL.Query().Get("service"))
266+
267+ u, p, ok := r.BasicAuth()
268+ require.True(t, ok, "expected Basic Auth")
269+ assert.Equal(t, subject, u)
270+ assert.Equal(t, gordonToken, p)
271+
272+ w.Header().Set("Content-Type", "application/json")
273+ _, _ = w.Write([]byte(`{"token":"short-lived-123","expires_in":300}`))
274+ }))
275+ defer srv.Close()
276+
277+ client := NewClient(srv.URL, WithToken(gordonToken))
278+ token, err := client.ExchangeRegistryToken(context.Background(), subject)
279+ require.NoError(t, err)
280+ assert.Equal(t, shortToken, token)
281+}
282+
283+func TestExchangeRegistryToken_Unauthorized(t *testing.T) {
284+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
285+ w.WriteHeader(http.StatusUnauthorized)
286+ _, _ = w.Write([]byte(`{"error":"invalid credentials"}`))
287+ }))
288+ defer srv.Close()
289+
290+ client := NewClient(srv.URL, WithToken("bad-token"))
291+ _, err := client.ExchangeRegistryToken(context.Background(), "user")
292+ require.Error(t, err)
293+ assert.Contains(t, err.Error(), "401")
294+}
295+
296+func TestExchangeRegistryToken_EmptyToken(t *testing.T) {
297+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
298+ w.Header().Set("Content-Type", "application/json")
299+ _, _ = w.Write([]byte(`{"token":"","expires_in":300}`))
300+ }))
301+ defer srv.Close()
302+
303+ client := NewClient(srv.URL, WithToken("some-token"))
304+ _, err := client.ExchangeRegistryToken(context.Background(), "user")
305+ require.Error(t, err)
306+ assert.Contains(t, err.Error(), "empty")
307+}
308+
309+func TestHTTPError_ErrorString(t *testing.T) {
310+ err := &HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"}
311+ assert.Equal(t, "403 Forbidden: insufficient scope", err.Error())
312+}
313+
314+func TestHTTPError_Unwrap(t *testing.T) {
315+ httpErr := &HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"}
316+ wrapped := fmt.Errorf("deploy intent: %w", httpErr)
317+
318+ var target *HTTPError
319+ assert.True(t, errors.As(wrapped, &target))
320+ assert.Equal(t, 403, target.StatusCode)
321+}
322+
323+func TestParseErrorResponse_ReturnsHTTPError(t *testing.T) {
324+ resp := &http.Response{
325+ StatusCode: 403,
326+ Status: "403 Forbidden",
327+ Body: io.NopCloser(strings.NewReader("")),
328+ }
329+ err := parseErrorResponse(resp, []byte(`{"error":"insufficient scope"}`))
330+
331+ var httpErr *HTTPError
332+ require.True(t, errors.As(err, &httpErr))
333+ assert.Equal(t, 403, httpErr.StatusCode)
334+ assert.Equal(t, "insufficient scope", httpErr.Body)
335+}
336+
337+func TestParseErrorResponse_NonJSON(t *testing.T) {
338+ resp := &http.Response{
339+ StatusCode: 500,
340+ Status: "500 Internal Server Error",
341+ Body: io.NopCloser(strings.NewReader("")),
342+ }
343+ err := parseErrorResponse(resp, []byte("plain text error"))
344+
345+ var httpErr *HTTPError
346+ require.True(t, errors.As(err, &httpErr))
347+ assert.Equal(t, 500, httpErr.StatusCode)
348+ assert.Equal(t, "plain text error", httpErr.Body)
349+}
350+
351+func TestExchangeRegistryToken_AccessTokenFallback(t *testing.T) {
352+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
353+ w.Header().Set("Content-Type", "application/json")
354+ // Token field empty, but access_token has a value
355+ json.NewEncoder(w).Encode(map[string]any{
356+ "access_token": "fallback-token-456",
357+ "expires_in": 300,
358+ })
359+ }))
360+ defer srv.Close()
361+
362+ client := NewClient(srv.URL, WithToken("long-lived"))
363+ token, err := client.ExchangeRegistryToken(context.Background(), "ci-bot")
364+ require.NoError(t, err)
365+ assert.Equal(t, "fallback-token-456", token)
366+}
@@ -3,6 +3,8 @@ package remote
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6+ "errors"
7+ "fmt"
6 "io"8 "io"
7 "net/http"9 "net/http"
8 "net/http/httptest"10 "net/http/httptest"
@@ -250,3 +252,115 @@ func TestRequestWithRetry_CapsErrorBodySize(t *testing.T) {
250 require.Error(t, err)252 require.Error(t, err)
251 assert.Less(t, len(err.Error()), 2*1024, "retry error body should be capped to ~1KB")253 assert.Less(t, len(err.Error()), 2*1024, "retry error body should be capped to ~1KB")
252 }254 }
255+
256+func TestExchangeRegistryToken_Success(t *testing.T) {
257+ const subject = "testuser"
258+ const gordonToken = "gordon-jwt-token"
259+ const shortToken = "short-lived-123"
260+
261+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
262+ require.Equal(t, "/auth/token", r.URL.Path)
263+ require.Equal(t, http.MethodGet, r.Method)
264+ require.Equal(t, "repository:*:push,pull", r.URL.Query().Get("scope"))
265+ require.Equal(t, "gordon-registry", r.URL.Query().Get("service"))
266+
267+ u, p, ok := r.BasicAuth()
268+ require.True(t, ok, "expected Basic Auth")
269+ assert.Equal(t, subject, u)
270+ assert.Equal(t, gordonToken, p)
271+
272+ w.Header().Set("Content-Type", "application/json")
273+ _, _ = w.Write([]byte(`{"token":"short-lived-123","expires_in":300}`))
274+ }))
275+ defer srv.Close()
276+
277+ client := NewClient(srv.URL, WithToken(gordonToken))
278+ token, err := client.ExchangeRegistryToken(context.Background(), subject)
279+ require.NoError(t, err)
280+ assert.Equal(t, shortToken, token)
281+}
282+
283+func TestExchangeRegistryToken_Unauthorized(t *testing.T) {
284+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
285+ w.WriteHeader(http.StatusUnauthorized)
286+ _, _ = w.Write([]byte(`{"error":"invalid credentials"}`))
287+ }))
288+ defer srv.Close()
289+
290+ client := NewClient(srv.URL, WithToken("bad-token"))
291+ _, err := client.ExchangeRegistryToken(context.Background(), "user")
292+ require.Error(t, err)
293+ assert.Contains(t, err.Error(), "401")
294+}
295+
296+func TestExchangeRegistryToken_EmptyToken(t *testing.T) {
297+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
298+ w.Header().Set("Content-Type", "application/json")
299+ _, _ = w.Write([]byte(`{"token":"","expires_in":300}`))
300+ }))
301+ defer srv.Close()
302+
303+ client := NewClient(srv.URL, WithToken("some-token"))
304+ _, err := client.ExchangeRegistryToken(context.Background(), "user")
305+ require.Error(t, err)
306+ assert.Contains(t, err.Error(), "empty")
307+}
308+
309+func TestHTTPError_ErrorString(t *testing.T) {
310+ err := &HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"}
311+ assert.Equal(t, "403 Forbidden: insufficient scope", err.Error())
312+}
313+
314+func TestHTTPError_Unwrap(t *testing.T) {
315+ httpErr := &HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"}
316+ wrapped := fmt.Errorf("deploy intent: %w", httpErr)
317+
318+ var target *HTTPError
319+ assert.True(t, errors.As(wrapped, &target))
320+ assert.Equal(t, 403, target.StatusCode)
321+}
322+
323+func TestParseErrorResponse_ReturnsHTTPError(t *testing.T) {
324+ resp := &http.Response{
325+ StatusCode: 403,
326+ Status: "403 Forbidden",
327+ Body: io.NopCloser(strings.NewReader("")),
328+ }
329+ err := parseErrorResponse(resp, []byte(`{"error":"insufficient scope"}`))
330+
331+ var httpErr *HTTPError
332+ require.True(t, errors.As(err, &httpErr))
333+ assert.Equal(t, 403, httpErr.StatusCode)
334+ assert.Equal(t, "insufficient scope", httpErr.Body)
335+}
336+
337+func TestParseErrorResponse_NonJSON(t *testing.T) {
338+ resp := &http.Response{
339+ StatusCode: 500,
340+ Status: "500 Internal Server Error",
341+ Body: io.NopCloser(strings.NewReader("")),
342+ }
343+ err := parseErrorResponse(resp, []byte("plain text error"))
344+
345+ var httpErr *HTTPError
346+ require.True(t, errors.As(err, &httpErr))
347+ assert.Equal(t, 500, httpErr.StatusCode)
348+ assert.Equal(t, "plain text error", httpErr.Body)
349+}
350+
351+func TestExchangeRegistryToken_AccessTokenFallback(t *testing.T) {
352+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
353+ w.Header().Set("Content-Type", "application/json")
354+ // Token field empty, but access_token has a value
355+ json.NewEncoder(w).Encode(map[string]any{
356+ "access_token": "fallback-token-456",
357+ "expires_in": 300,
358+ })
359+ }))
360+ defer srv.Close()
361+
362+ client := NewClient(srv.URL, WithToken("long-lived"))
363+ token, err := client.ExchangeRegistryToken(context.Background(), "ci-bot")
364+ require.NoError(t, err)
365+ assert.Equal(t, "fallback-token-456", token)
366+}
M pkg/registrypush/push.go +18 -3
@@ -42,6 +42,7 @@ type Pusher struct {
4242 imageSource func(ctx context.Context, ref string) (v1.Image, func(), error)
4343 cleanupFn func()
4444 progress io.Writer
45+ authHeader string
4546 }
4647
4748 // Option configures a Pusher.
@@ -89,6 +90,14 @@ func WithProgress(w io.Writer) Option {
8990 return func(p *Pusher) { p.progress = w }
9091 }
9192
93+// WithAuth sets an explicit Authorization header value (e.g. "Bearer <token>")
94+// to use for all registry requests. When set, the Docker credential keychain
95+// is not consulted. This is useful when a pre-computed token is available
96+// without requiring a local docker login.
97+func WithAuth(header string) Option {
98+ return func(p *Pusher) { p.authHeader = header }
99+}
100+
92101 // New creates a Pusher with the given options.
93102 func New(opts ...Option) *Pusher {
94103 p := &Pusher{chunkSize: DefaultChunkSize, timeout: defaultHTTPTimeout}
@@ -125,9 +134,15 @@ func (p *Pusher) Push(ctx context.Context, ref string) error {
125134 return fmt.Errorf("image ref %s must include a tag", ref)
126135 }
127136
128- authHeader, err := resolveAuthHeader(parsedRef)
129- if err != nil {
130- return err
137+ var authHeader string
138+ if p.authHeader != "" {
139+ authHeader = p.authHeader
140+ } else {
141+ var resolveErr error
142+ authHeader, resolveErr = resolveAuthHeader(parsedRef)
143+ if resolveErr != nil {
144+ return resolveErr
145+ }
131146 }
132147
133148 img, sourceCleanup, err := p.imageSource(ctx, ref)
@@ -42,6 +42,7 @@ type Pusher struct {
42 imageSource func(ctx context.Context, ref string) (v1.Image, func(), error)42 imageSource func(ctx context.Context, ref string) (v1.Image, func(), error)
43 cleanupFn func()43 cleanupFn func()
44 progress io.Writer44 progress io.Writer
45+ authHeader string
45 }46 }
46 47
47 // Option configures a Pusher.48 // Option configures a Pusher.
@@ -89,6 +90,14 @@ func WithProgress(w io.Writer) Option {
89 return func(p *Pusher) { p.progress = w }90 return func(p *Pusher) { p.progress = w }
90 }91 }
91 92
93+// WithAuth sets an explicit Authorization header value (e.g. "Bearer <token>")
94+// to use for all registry requests. When set, the Docker credential keychain
95+// is not consulted. This is useful when a pre-computed token is available
96+// without requiring a local docker login.
97+func WithAuth(header string) Option {
98+ return func(p *Pusher) { p.authHeader = header }
99+}
100+
92 // New creates a Pusher with the given options.101 // New creates a Pusher with the given options.
93 func New(opts ...Option) *Pusher {102 func New(opts ...Option) *Pusher {
94 p := &Pusher{chunkSize: DefaultChunkSize, timeout: defaultHTTPTimeout}103 p := &Pusher{chunkSize: DefaultChunkSize, timeout: defaultHTTPTimeout}
@@ -125,9 +134,15 @@ func (p *Pusher) Push(ctx context.Context, ref string) error {
125 return fmt.Errorf("image ref %s must include a tag", ref)134 return fmt.Errorf("image ref %s must include a tag", ref)
126 }135 }
127 136
128- authHeader, err := resolveAuthHeader(parsedRef)137+ var authHeader string
129- if err != nil {138+ if p.authHeader != "" {
130- return err139+ authHeader = p.authHeader
140+ } else {
141+ var resolveErr error
142+ authHeader, resolveErr = resolveAuthHeader(parsedRef)
143+ if resolveErr != nil {
144+ return resolveErr
145+ }
131 }146 }
132 147
133 img, sourceCleanup, err := p.imageSource(ctx, ref)148 img, sourceCleanup, err := p.imageSource(ctx, ref)
M pkg/registrypush/push_test.go +75 -0
@@ -660,3 +660,78 @@ func pathSegmentBetween(value, start, end string) string {
660660 func typesOCIManifestSchema1() string {
661661 return "application/vnd.oci.image.manifest.v1+json"
662662 }
663+
664+// TestPusher_WithAuth verifies that when WithAuth is provided, its header is
665+// used for all registry requests instead of the Docker keychain, and that
666+// omitting WithAuth leaves existing behavior unchanged.
667+func TestPusher_WithAuth(t *testing.T) {
668+ t.Run("WithAuth header is sent on every registry request", func(t *testing.T) {
669+ img, err := random.Image(1024, 1)
670+ require.NoError(t, err)
671+
672+ const explicitToken = "Bearer my-token"
673+
674+ // authAssertingRegistry fails the test if any request lacks the expected
675+ // Authorization header.
676+ var requestCount int
677+ registry := httptest.NewServer(newFakePushRegistry(t, img, map[string]bool{}).handlerWithAuthCheck(t, explicitToken, &requestCount))
678+ defer registry.Close()
679+
680+ serverURL, err := url.Parse(registry.URL)
681+ require.NoError(t, err)
682+ ref := serverURL.Host + "/demo/app:v1"
683+
684+ p := registrypush.New(
685+ registrypush.WithTransport(registry.Client().Transport),
686+ registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) {
687+ return img, nil
688+ }),
689+ registrypush.WithAuth(explicitToken),
690+ )
691+
692+ err = p.Push(context.Background(), ref)
693+ require.NoError(t, err)
694+ assert.Positive(t, requestCount, "at least one request should have been made")
695+ })
696+
697+ t.Run("without WithAuth, existing push behavior is unchanged", func(t *testing.T) {
698+ img, err := random.Image(1024, 1)
699+ require.NoError(t, err)
700+
701+ serverState := newFakePushRegistry(t, img, map[string]bool{})
702+ registry := httptest.NewServer(serverState.handler())
703+ defer registry.Close()
704+
705+ serverURL, err := url.Parse(registry.URL)
706+ require.NoError(t, err)
707+ ref := serverURL.Host + "/demo/app:v1"
708+
709+ // No WithAuth — relies on Docker keychain (returns empty string for localhost).
710+ p := registrypush.New(
711+ registrypush.WithTransport(registry.Client().Transport),
712+ registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) {
713+ return img, nil
714+ }),
715+ )
716+
717+ err = p.Push(context.Background(), ref)
718+ require.NoError(t, err)
719+
720+ // Manifest should have been pushed — verifies the push completed normally.
721+ _, ok := serverState.manifestPuts[serverState.expectedRepo+":"+serverState.expectedTag]
722+ assert.True(t, ok, "manifest should have been uploaded")
723+ })
724+}
725+
726+// handlerWithAuthCheck wraps the fakePushRegistry handler and asserts that
727+// every request carries the expected Authorization header.
728+func (f *fakePushRegistry) handlerWithAuthCheck(t *testing.T, expectedAuth string, requestCount *int) http.Handler {
729+ t.Helper()
730+ inner := f.handler()
731+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
732+ *requestCount++
733+ assert.Equal(t, expectedAuth, r.Header.Get("Authorization"),
734+ "expected Authorization header on %s %s", r.Method, r.URL.Path)
735+ inner.ServeHTTP(w, r)
736+ })
737+}
@@ -660,3 +660,78 @@ func pathSegmentBetween(value, start, end string) string {
660 func typesOCIManifestSchema1() string {660 func typesOCIManifestSchema1() string {
661 return "application/vnd.oci.image.manifest.v1+json"661 return "application/vnd.oci.image.manifest.v1+json"
662 }662 }
663+
664+// TestPusher_WithAuth verifies that when WithAuth is provided, its header is
665+// used for all registry requests instead of the Docker keychain, and that
666+// omitting WithAuth leaves existing behavior unchanged.
667+func TestPusher_WithAuth(t *testing.T) {
668+ t.Run("WithAuth header is sent on every registry request", func(t *testing.T) {
669+ img, err := random.Image(1024, 1)
670+ require.NoError(t, err)
671+
672+ const explicitToken = "Bearer my-token"
673+
674+ // authAssertingRegistry fails the test if any request lacks the expected
675+ // Authorization header.
676+ var requestCount int
677+ registry := httptest.NewServer(newFakePushRegistry(t, img, map[string]bool{}).handlerWithAuthCheck(t, explicitToken, &requestCount))
678+ defer registry.Close()
679+
680+ serverURL, err := url.Parse(registry.URL)
681+ require.NoError(t, err)
682+ ref := serverURL.Host + "/demo/app:v1"
683+
684+ p := registrypush.New(
685+ registrypush.WithTransport(registry.Client().Transport),
686+ registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) {
687+ return img, nil
688+ }),
689+ registrypush.WithAuth(explicitToken),
690+ )
691+
692+ err = p.Push(context.Background(), ref)
693+ require.NoError(t, err)
694+ assert.Positive(t, requestCount, "at least one request should have been made")
695+ })
696+
697+ t.Run("without WithAuth, existing push behavior is unchanged", func(t *testing.T) {
698+ img, err := random.Image(1024, 1)
699+ require.NoError(t, err)
700+
701+ serverState := newFakePushRegistry(t, img, map[string]bool{})
702+ registry := httptest.NewServer(serverState.handler())
703+ defer registry.Close()
704+
705+ serverURL, err := url.Parse(registry.URL)
706+ require.NoError(t, err)
707+ ref := serverURL.Host + "/demo/app:v1"
708+
709+ // No WithAuth — relies on Docker keychain (returns empty string for localhost).
710+ p := registrypush.New(
711+ registrypush.WithTransport(registry.Client().Transport),
712+ registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) {
713+ return img, nil
714+ }),
715+ )
716+
717+ err = p.Push(context.Background(), ref)
718+ require.NoError(t, err)
719+
720+ // Manifest should have been pushed — verifies the push completed normally.
721+ _, ok := serverState.manifestPuts[serverState.expectedRepo+":"+serverState.expectedTag]
722+ assert.True(t, ok, "manifest should have been uploaded")
723+ })
724+}
725+
726+// handlerWithAuthCheck wraps the fakePushRegistry handler and asserts that
727+// every request carries the expected Authorization header.
728+func (f *fakePushRegistry) handlerWithAuthCheck(t *testing.T, expectedAuth string, requestCount *int) http.Handler {
729+ t.Helper()
730+ inner := f.handler()
731+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
732+ *requestCount++
733+ assert.Equal(t, expectedAuth, r.Header.Get("Authorization"),
734+ "expected Authorization header on %s %s", r.Method, r.URL.Path)
735+ inner.ServeHTTP(w, r)
736+ })
737+}

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