detached 20b9aac

feat(push): derive VERSION from CI tag refs and git describe

commit
20b9aac1aad37ce5d2e33f2704587ecf3b8a36f3
author
bnema · · 2026-02-11 03:49 UTC
parent
c00708a
signature
unsigned
3 files · +118 -8
M docs/cli/push.md +5 -4
@@ -24,7 +24,7 @@ gordon push <domain> [options]
2424 | `-f, --file` | Path to Dockerfile (default: `./Dockerfile`, used with `--build`) |
2525 | `--platform` | Target platform for buildx (default: `linux/amd64`) |
2626 | `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) |
27-| `--tag` | Override version tag (default: `git describe --tags --abbrev=0`) |
27+| `--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 |
3030 | `--remote` | Remote Gordon URL |
@@ -36,10 +36,11 @@ gordon push <domain> [options]
3636 Gordon registry, pushes it, and optionally deploys it.
3737
3838 - The registry and repository are derived from the route image on the server.
39-- The version tag defaults to the latest git tag. If no tag is found, `latest`
40- is used.
39+- The version tag defaults to a CI tag ref (like `refs/tags/v1.2.3`) when available,
40+ then falls back to `git describe --tags --dirty` (for example
41+ `v1.2.3-4-gabc1234` or `v1.2.3-dirty`). If no tag is found, `latest` is used.
4142 - When `--build` is set, the command builds with `docker buildx build --load`
42- and injects `VERSION=<tag>` plus any `--build-arg` values.
43+ and injects `VERSION` into the build environment plus any `--build-arg` values.
4344 - Use `-f/--file` to build from a Dockerfile outside the current directory root.
4445 - The version tag and `latest` are both pushed (unless the version is `latest`).
4546
@@ -24,7 +24,7 @@ gordon push <domain> [options]
24 | `-f, --file` | Path to Dockerfile (default: `./Dockerfile`, used with `--build`) |24 | `-f, --file` | Path to Dockerfile (default: `./Dockerfile`, used with `--build`) |
25 | `--platform` | Target platform for buildx (default: `linux/amd64`) |25 | `--platform` | Target platform for buildx (default: `linux/amd64`) |
26 | `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) |26 | `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) |
27-| `--tag` | Override version tag (default: `git describe --tags --abbrev=0`) |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 | `--remote` | Remote Gordon URL |30 | `--remote` | Remote Gordon URL |
@@ -36,10 +36,11 @@ gordon push <domain> [options]
36 Gordon registry, pushes it, and optionally deploys it.36 Gordon registry, pushes it, and optionally deploys it.
37 37
38 - The registry and repository are derived from the route image on the server.38 - The registry and repository are derived from the route image on the server.
39-- The version tag defaults to the latest git tag. If no tag is found, `latest`39+- The version tag defaults to a CI tag ref (like `refs/tags/v1.2.3`) when available,
40- is used.40+ then falls back to `git describe --tags --dirty` (for example
41+ `v1.2.3-4-gabc1234` or `v1.2.3-dirty`). If no tag is found, `latest` is used.
41 - When `--build` is set, the command builds with `docker buildx build --load`42 - When `--build` is set, the command builds with `docker buildx build --load`
42- and injects `VERSION=<tag>` plus any `--build-arg` values.43+ and injects `VERSION` into the build environment plus any `--build-arg` values.
43 - Use `-f/--file` to build from a Dockerfile outside the current directory root.44 - Use `-f/--file` to build from a Dockerfile outside the current directory root.
44 - The version tag and `latest` are both pushed (unless the version is `latest`).45 - The version tag and `latest` are both pushed (unless the version is `latest`).
45 46
M internal/adapters/in/cli/push.go +46 -4
@@ -66,7 +66,7 @@ Examples:
6666 cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")
6767 cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")
6868 cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")
69- cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: git describe)")
69+ cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: CI tag ref or git describe)")
7070 cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
7171 cmd.Flags().StringVar(&domainFlag, "domain", "", "Explicit domain override (legacy mode)")
7272
@@ -403,6 +403,9 @@ func parseLabelPair(pair string) (key, value string, ok bool) {
403403
404404 func determineVersion(ctx context.Context, tag string) string {
405405 version := tag
406+ if version == "" {
407+ version = versionFromTagRefs(os.Getenv)
408+ }
406409 if version == "" {
407410 version = getGitVersion(ctx)
408411 }
@@ -412,6 +415,44 @@ func determineVersion(ctx context.Context, tag string) string {
412415 return version
413416 }
414417
418+func versionFromTagRefs(getenv func(string) string) string {
419+ if ref := strings.TrimSpace(getenv("GITHUB_REF")); ref != "" {
420+ if tag := parseTagRef(ref); tag != "" {
421+ return tag
422+ }
423+ }
424+
425+ if strings.TrimSpace(getenv("GITHUB_REF_TYPE")) == "tag" {
426+ if tag := strings.TrimSpace(getenv("GITHUB_REF_NAME")); tag != "" {
427+ return tag
428+ }
429+ }
430+
431+ if tag := strings.TrimSpace(getenv("CI_COMMIT_TAG")); tag != "" {
432+ return tag
433+ }
434+
435+ if ref := strings.TrimSpace(getenv("BUILD_SOURCEBRANCH")); ref != "" {
436+ if tag := parseTagRef(ref); tag != "" {
437+ return tag
438+ }
439+ }
440+
441+ return ""
442+}
443+
444+func parseTagRef(ref string) string {
445+ ref = strings.TrimSpace(strings.TrimSuffix(ref, "^{}"))
446+ if !strings.HasPrefix(ref, "refs/tags/") {
447+ return ""
448+ }
449+ tag := strings.TrimPrefix(ref, "refs/tags/")
450+ if tag == "" {
451+ return ""
452+ }
453+ return tag
454+}
455+
415456 func buildAndPush(ctx context.Context, version, platform, dockerfile string, buildArgs []string, versionRef, latestRef string) error {
416457 if _, err := os.Stat(dockerfile); os.IsNotExist(err) {
417458 return fmt.Errorf("dockerfile not found: %s", dockerfile)
@@ -423,6 +464,7 @@ func buildAndPush(ctx context.Context, version, platform, dockerfile string, bui
423464 // using docker push gives us chunked uploads (~5MB per request).
424465 fmt.Println("\nBuilding image...")
425466 buildCmd := exec.CommandContext(ctx, "docker", buildImageArgs(version, platform, dockerfile, buildArgs, versionRef, latestRef)...) // #nosec G204
467+ buildCmd.Env = append(os.Environ(), "VERSION="+version)
426468 buildCmd.Stdout = os.Stdout
427469 buildCmd.Stderr = os.Stderr
428470 if err := buildCmd.Run(); err != nil {
@@ -451,7 +493,7 @@ func buildImageArgs(version, platform, dockerfile string, buildArgs []string, ve
451493 "--platform", platform,
452494 "-f", dockerfile,
453495 "-t", latestRef,
454- "--build-arg", "VERSION=" + version,
496+ "--build-arg", "VERSION",
455497 }
456498 if version != "latest" {
457499 args = append(args, "-t", versionRef)
@@ -639,9 +681,9 @@ func parseImageRef(image string) (registry, name, tag string) {
639681 return
640682 }
641683
642-// getGitVersion returns the latest git tag, or empty string if unavailable.
684+// getGitVersion returns git describe output, or empty string if unavailable.
643685 func getGitVersion(ctx context.Context) string {
644- out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--abbrev=0").Output()
686+ out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--dirty").Output()
645687 if err != nil {
646688 return ""
647689 }
@@ -66,7 +66,7 @@ Examples:
66 cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")66 cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")
67 cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")67 cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")
68 cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")68 cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")
69- cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: git describe)")69+ cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: CI tag ref or git describe)")
70 cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")70 cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
71 cmd.Flags().StringVar(&domainFlag, "domain", "", "Explicit domain override (legacy mode)")71 cmd.Flags().StringVar(&domainFlag, "domain", "", "Explicit domain override (legacy mode)")
72 72
@@ -403,6 +403,9 @@ func parseLabelPair(pair string) (key, value string, ok bool) {
403 403
404 func determineVersion(ctx context.Context, tag string) string {404 func determineVersion(ctx context.Context, tag string) string {
405 version := tag405 version := tag
406+ if version == "" {
407+ version = versionFromTagRefs(os.Getenv)
408+ }
406 if version == "" {409 if version == "" {
407 version = getGitVersion(ctx)410 version = getGitVersion(ctx)
408 }411 }
@@ -412,6 +415,44 @@ func determineVersion(ctx context.Context, tag string) string {
412 return version415 return version
413 }416 }
414 417
418+func versionFromTagRefs(getenv func(string) string) string {
419+ if ref := strings.TrimSpace(getenv("GITHUB_REF")); ref != "" {
420+ if tag := parseTagRef(ref); tag != "" {
421+ return tag
422+ }
423+ }
424+
425+ if strings.TrimSpace(getenv("GITHUB_REF_TYPE")) == "tag" {
426+ if tag := strings.TrimSpace(getenv("GITHUB_REF_NAME")); tag != "" {
427+ return tag
428+ }
429+ }
430+
431+ if tag := strings.TrimSpace(getenv("CI_COMMIT_TAG")); tag != "" {
432+ return tag
433+ }
434+
435+ if ref := strings.TrimSpace(getenv("BUILD_SOURCEBRANCH")); ref != "" {
436+ if tag := parseTagRef(ref); tag != "" {
437+ return tag
438+ }
439+ }
440+
441+ return ""
442+}
443+
444+func parseTagRef(ref string) string {
445+ ref = strings.TrimSpace(strings.TrimSuffix(ref, "^{}"))
446+ if !strings.HasPrefix(ref, "refs/tags/") {
447+ return ""
448+ }
449+ tag := strings.TrimPrefix(ref, "refs/tags/")
450+ if tag == "" {
451+ return ""
452+ }
453+ return tag
454+}
455+
415 func buildAndPush(ctx context.Context, version, platform, dockerfile string, buildArgs []string, versionRef, latestRef string) error {456 func buildAndPush(ctx context.Context, version, platform, dockerfile string, buildArgs []string, versionRef, latestRef string) error {
416 if _, err := os.Stat(dockerfile); os.IsNotExist(err) {457 if _, err := os.Stat(dockerfile); os.IsNotExist(err) {
417 return fmt.Errorf("dockerfile not found: %s", dockerfile)458 return fmt.Errorf("dockerfile not found: %s", dockerfile)
@@ -423,6 +464,7 @@ func buildAndPush(ctx context.Context, version, platform, dockerfile string, bui
423 // using docker push gives us chunked uploads (~5MB per request).464 // using docker push gives us chunked uploads (~5MB per request).
424 fmt.Println("\nBuilding image...")465 fmt.Println("\nBuilding image...")
425 buildCmd := exec.CommandContext(ctx, "docker", buildImageArgs(version, platform, dockerfile, buildArgs, versionRef, latestRef)...) // #nosec G204466 buildCmd := exec.CommandContext(ctx, "docker", buildImageArgs(version, platform, dockerfile, buildArgs, versionRef, latestRef)...) // #nosec G204
467+ buildCmd.Env = append(os.Environ(), "VERSION="+version)
426 buildCmd.Stdout = os.Stdout468 buildCmd.Stdout = os.Stdout
427 buildCmd.Stderr = os.Stderr469 buildCmd.Stderr = os.Stderr
428 if err := buildCmd.Run(); err != nil {470 if err := buildCmd.Run(); err != nil {
@@ -451,7 +493,7 @@ func buildImageArgs(version, platform, dockerfile string, buildArgs []string, ve
451 "--platform", platform,493 "--platform", platform,
452 "-f", dockerfile,494 "-f", dockerfile,
453 "-t", latestRef,495 "-t", latestRef,
454- "--build-arg", "VERSION=" + version,496+ "--build-arg", "VERSION",
455 }497 }
456 if version != "latest" {498 if version != "latest" {
457 args = append(args, "-t", versionRef)499 args = append(args, "-t", versionRef)
@@ -639,9 +681,9 @@ func parseImageRef(image string) (registry, name, tag string) {
639 return681 return
640 }682 }
641 683
642-// getGitVersion returns the latest git tag, or empty string if unavailable.684+// getGitVersion returns git describe output, or empty string if unavailable.
643 func getGitVersion(ctx context.Context) string {685 func getGitVersion(ctx context.Context) string {
644- out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--abbrev=0").Output()686+ out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--dirty").Output()
645 if err != nil {687 if err != nil {
646 return ""688 return ""
647 }689 }
M internal/adapters/in/cli/push_test.go +67 -0
@@ -72,6 +72,73 @@ func TestBuildAndPush_BuildArgs(t *testing.T) {
7272 assert.Contains(t, args, "--platform")
7373 assert.Contains(t, args, "-f")
7474 assert.Contains(t, args, "Dockerfile")
75+ assert.Contains(t, args, "VERSION")
76+ assert.NotContains(t, args, "VERSION=v1.0.0")
77+}
78+
79+func TestParseTagRef(t *testing.T) {
80+ tests := []struct {
81+ name string
82+ ref string
83+ want string
84+ }{
85+ {name: "github tag ref", ref: "refs/tags/v1.2.3", want: "v1.2.3"},
86+ {name: "peeled tag ref", ref: "refs/tags/v1.2.3^{}", want: "v1.2.3"},
87+ {name: "branch ref", ref: "refs/heads/main", want: ""},
88+ {name: "empty", ref: "", want: ""},
89+ }
90+
91+ for _, tt := range tests {
92+ t.Run(tt.name, func(t *testing.T) {
93+ assert.Equal(t, tt.want, parseTagRef(tt.ref))
94+ })
95+ }
96+}
97+
98+func TestVersionFromTagRefs(t *testing.T) {
99+ tests := []struct {
100+ name string
101+ env map[string]string
102+ want string
103+ }{
104+ {
105+ name: "github ref tag",
106+ env: map[string]string{"GITHUB_REF": "refs/tags/v2.0.0"},
107+ want: "v2.0.0",
108+ },
109+ {
110+ name: "github ref type and name",
111+ env: map[string]string{
112+ "GITHUB_REF_TYPE": "tag",
113+ "GITHUB_REF_NAME": "v2.1.0",
114+ },
115+ want: "v2.1.0",
116+ },
117+ {
118+ name: "gitlab commit tag",
119+ env: map[string]string{"CI_COMMIT_TAG": "v3.0.0"},
120+ want: "v3.0.0",
121+ },
122+ {
123+ name: "azure source branch tag ref",
124+ env: map[string]string{"BUILD_SOURCEBRANCH": "refs/tags/v4.0.0"},
125+ want: "v4.0.0",
126+ },
127+ {
128+ name: "no tag refs",
129+ env: map[string]string{"GITHUB_REF": "refs/heads/main"},
130+ want: "",
131+ },
132+ }
133+
134+ for _, tt := range tests {
135+ t.Run(tt.name, func(t *testing.T) {
136+ got := versionFromTagRefs(func(key string) string {
137+ return tt.env[key]
138+ })
139+ assert.Equal(t, tt.want, got)
140+ })
141+ }
75142 }
76143
77144 func TestBuildImageArgs_CustomDockerfile(t *testing.T) {
@@ -72,6 +72,73 @@ func TestBuildAndPush_BuildArgs(t *testing.T) {
72 assert.Contains(t, args, "--platform")72 assert.Contains(t, args, "--platform")
73 assert.Contains(t, args, "-f")73 assert.Contains(t, args, "-f")
74 assert.Contains(t, args, "Dockerfile")74 assert.Contains(t, args, "Dockerfile")
75+ assert.Contains(t, args, "VERSION")
76+ assert.NotContains(t, args, "VERSION=v1.0.0")
77+}
78+
79+func TestParseTagRef(t *testing.T) {
80+ tests := []struct {
81+ name string
82+ ref string
83+ want string
84+ }{
85+ {name: "github tag ref", ref: "refs/tags/v1.2.3", want: "v1.2.3"},
86+ {name: "peeled tag ref", ref: "refs/tags/v1.2.3^{}", want: "v1.2.3"},
87+ {name: "branch ref", ref: "refs/heads/main", want: ""},
88+ {name: "empty", ref: "", want: ""},
89+ }
90+
91+ for _, tt := range tests {
92+ t.Run(tt.name, func(t *testing.T) {
93+ assert.Equal(t, tt.want, parseTagRef(tt.ref))
94+ })
95+ }
96+}
97+
98+func TestVersionFromTagRefs(t *testing.T) {
99+ tests := []struct {
100+ name string
101+ env map[string]string
102+ want string
103+ }{
104+ {
105+ name: "github ref tag",
106+ env: map[string]string{"GITHUB_REF": "refs/tags/v2.0.0"},
107+ want: "v2.0.0",
108+ },
109+ {
110+ name: "github ref type and name",
111+ env: map[string]string{
112+ "GITHUB_REF_TYPE": "tag",
113+ "GITHUB_REF_NAME": "v2.1.0",
114+ },
115+ want: "v2.1.0",
116+ },
117+ {
118+ name: "gitlab commit tag",
119+ env: map[string]string{"CI_COMMIT_TAG": "v3.0.0"},
120+ want: "v3.0.0",
121+ },
122+ {
123+ name: "azure source branch tag ref",
124+ env: map[string]string{"BUILD_SOURCEBRANCH": "refs/tags/v4.0.0"},
125+ want: "v4.0.0",
126+ },
127+ {
128+ name: "no tag refs",
129+ env: map[string]string{"GITHUB_REF": "refs/heads/main"},
130+ want: "",
131+ },
132+ }
133+
134+ for _, tt := range tests {
135+ t.Run(tt.name, func(t *testing.T) {
136+ got := versionFromTagRefs(func(key string) string {
137+ return tt.env[key]
138+ })
139+ assert.Equal(t, tt.want, got)
140+ })
141+ }
75 }142 }
76 143
77 func TestBuildImageArgs_CustomDockerfile(t *testing.T) {144 func TestBuildImageArgs_CustomDockerfile(t *testing.T) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close