From a08e30979f730863c686890f9e799d12d2dd7904 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Sat, 29 Aug 2026 20:52:02 +0100 Subject: [PATCH 1/5] fix(docker): build the image version from the workflow input, not git tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docker image derived its version from `git describe` inside the build, while the smoke test inferred the expected version from the image tag. Those two disagree whenever a release tag is cut at the same commit as an in-flight push-to-main pipeline: the build picks up the new tag and reports v, the test still expects dev+, and the version smoke test fails. Resolve the version once in the `prepare` job and feed it to both sides — the build bakes it in as a VERSION build arg, the smoke test asserts the image reports it. This is what #1133 originally suggested; #1137 closed that issue with `fetch-tags: true` instead, which is what let a stray tag reach the build. The build job no longer fetches tags, so the image is a function of the build request rather than of whichever tags the checkout happens to have by the time it runs. The Makefile needs `origin` rather than `ifdef` to tell "VERSION not passed" (local build, fall back to the tag at HEAD) from "VERSION passed empty" (CI, not a release). Metadata clearing moves from GIT_TAG to BINARY_VERSION so an explicit version is self-consistent: `make build VERSION=v2.39.0` on an untagged tree previously produced v2.39.0+, matching neither goreleaser nor the smoke test. `make docker` passes the tag at HEAD so a local image build at a release tag still reports it; a bare `docker build .` reports dev+. --- .github/workflows/docker.yml | 37 +++++++++++++++++++++++------------ Dockerfile | 9 ++++++++- Makefile | 19 ++++++++++++------ scripts/docker-smoke-tests.sh | 4 ++-- 4 files changed, 48 insertions(+), 21 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 60027c935..2df051b1b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -49,7 +49,8 @@ env: jobs: # --------------------------------------------------------------------------- - # Job 1: Resolve the platforms input into a JSON matrix. + # Job 1: Resolve the platforms input into a JSON matrix, and resolve the + # version the built binary must report. # # Why a separate job: workflow-level matrix entries must be static or come # from job outputs; they cannot be computed inline from an input string. @@ -61,6 +62,7 @@ jobs: runs-on: ubuntu-latest outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} + version: ${{ steps.set-version.outputs.version }} steps: - name: Harden Runner uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 @@ -95,6 +97,20 @@ jobs: ') echo "matrix=$MATRIX" >> $GITHUB_OUTPUT + # The one place that decides whether this is a release build: the build + # job bakes this into the binary and the smoke test asserts the image + # reports it. Empty means "not a release" — the binary reports dev+. + - name: Resolve the version to build + id: set-version + env: + TAG: ${{ inputs.tag }} + run: | + VERSION="" + if [[ "$TAG" =~ ^v[0-9] ]]; then + VERSION="$TAG" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + # --------------------------------------------------------------------------- # Job 2: Build each platform on its native runner, push by digest. @@ -127,9 +143,8 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 3 - # The Makefile derives the version from `git describe`, and - # fetch-depth > 0 fetches no tags unless asked (#1133). - fetch-tags: true + # No fetch-tags: the version arrives as a build arg below, so the + # build must not pick one up from this checkout. # .git/ ships in the build context, which mode=max exports to the # public cache ref. persist-credentials: false @@ -151,6 +166,8 @@ jobs: uses: docker/build-push-action@v7 with: context: . + build-args: | + VERSION=${{ needs.prepare.outputs.version }} # Push without a tag — the merge job creates the final manifest. platforms: ${{ matrix.platform }} outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true @@ -183,7 +200,7 @@ jobs: merge: name: Docker Merge & Attest runs-on: ubuntu-latest - needs: [build] + needs: [prepare, build] permissions: id-token: write contents: write @@ -348,13 +365,9 @@ jobs: env: IMAGE: ${{ env.IMAGE }} TAG: ${{ inputs.tag }} - run: | - # Release builds must report their tag; others are tagged with a - # sha and report dev+. - if [[ "$TAG" =~ ^v[0-9] ]]; then - export EXPECTED_VERSION="$TAG" - fi - ./scripts/docker-smoke-tests.sh + # The same value the build job baked in. + EXPECTED_VERSION: ${{ needs.prepare.outputs.version }} + run: ./scripts/docker-smoke-tests.sh - name: Report Docker smoke test attestation to Kosli if: ${{ inputs.report_to_kosli != 'none' && (success() || failure()) }} diff --git a/Dockerfile b/Dockerfile index abac0de8d..4f62f5edb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,11 @@ ARG GO_VERSION="1.26" ARG ALPINE_VERSION="3.23" +# The release version the binary reports; empty builds dev+. Passed in +# rather than read from git tags so the image depends on the build request, +# not on which tags the checkout has (#1133). `make docker` passes it for you. +ARG VERSION="" + ### Go Builder ### FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS builder @@ -15,7 +20,9 @@ WORKDIR /go/src/kosli COPY . . -RUN make build +# Re-declare inside the stage — a global ARG is not in scope in a build stage. +ARG VERSION +RUN make build VERSION="${VERSION}" RUN mkdir -p /image-tmp diff --git a/Makefile b/Makefile index ff73a96c0..f1b56a406 100644 --- a/Makefile +++ b/Makefile @@ -21,19 +21,25 @@ GOTESTSUM = $(shell which gotestsum || echo "~/go/bin/gotestsum") # These are only used when running tests locally (real CI already sets them). FAKE_CI_ENV = GITHUB_RUN_NUMBER=1 GITHUB_SERVER_URL=https://github.com GITHUB_REPOSITORY=kosli-dev/cli GITHUB_REPOSITORY_ID=123456 -ifdef VERSION +# VERSION is the release version the binary reports. Unset, it falls back to +# the tag at HEAD, so a local `make build` in a checked-out release still +# reports it. Set — including to the empty string, meaning "not a release" — +# it wins outright, which is how CI keeps a build from depending on whichever +# tags the checkout happens to have (#1133). +ifeq ($(origin VERSION),undefined) + BINARY_VERSION = $(GIT_TAG) +else BINARY_VERSION = $(VERSION) endif -BINARY_VERSION ?= ${GIT_TAG} -# Only set Version if building a tag or VERSION is set +# Only set Version if we have one; otherwise the binary keeps its "dev" default ifneq ($(BINARY_VERSION),) LDFLAGS += -X github.com/kosli-dev/cli/internal/version.version=${BINARY_VERSION} endif +# Release builds report their version alone; others append the short sha. VERSION_METADATA = $(GIT_SHA) -# Clear the short-sha BuildMetadata for tagged releases -ifneq ($(GIT_TAG),) +ifneq ($(BINARY_VERSION),) VERSION_METADATA = endif @@ -180,8 +186,9 @@ follow_integration_test_server: enter_integration_test_server: @docker exec -it --workdir / cli_kosli_server bash +# The image never reads git tags itself, so pass the tag at HEAD in. docker: ## Build CLI Docker image - @docker build -t kosli-cli . + @docker build -t kosli-cli --build-arg VERSION="$(GIT_TAG)" . licenses: ## Update licenses @rm -rf licenses || true diff --git a/scripts/docker-smoke-tests.sh b/scripts/docker-smoke-tests.sh index 66b515132..7ba028874 100755 --- a/scripts/docker-smoke-tests.sh +++ b/scripts/docker-smoke-tests.sh @@ -60,8 +60,8 @@ run_case() { # Asserts the image reports the release it was published as, built from a clean # tree at the built commit — the two halves of #1133. EXPECTED_VERSION is the -# exact version required; unset for non-release builds, which are tagged with a -# sha and report dev+. +# exact version required — the same value CI baked into the binary; empty or +# unset means a non-release build, which must report dev+. test_version() { local full short commit full="$(docker run --rm -e KOSLI_NO_UPDATE_CHECK=1 "${IMAGE}:${TAG}" version)" || return 1 From 3eb3defd8bbfb2d016b06739706e763c0588fd62 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Sat, 29 Aug 2026 20:52:06 +0100 Subject: [PATCH 2/5] fix(docker): harden the version classifier against silent mis-stamping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on the version build-arg change. The Makefile now honours VERSION only as a command-line assignment. `origin` reports `environment` for an inherited variable, so an ambient VERSION in the caller's shell beat the tag at HEAD — and with metadata cleared it stamped a fake clean release. The Dockerfile passes VERSION on the command line, so the one caller that needs the override still has it. The tag classifier in `prepare` is anchored at both ends: the value reaches single-quoted ldflags, so a tag carrying a quote or whitespace is no longer treated as a release. Build arg and smoke-test expectation come from one workflow output, so nothing was checking the mapping itself — a misclassification would make both halves agree on the wrong version and pass. The smoke test now re-derives the release case from the image tag: a tag matching the looser ^v[0-9] must carry its own version, so a tag the strict classifier rejects fails loudly instead of being published as dev+. --- .github/workflows/docker.yml | 5 ++++- Makefile | 11 ++++++----- scripts/docker-smoke-tests.sh | 9 +++++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 2df051b1b..b46936315 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -105,8 +105,11 @@ jobs: env: TAG: ${{ inputs.tag }} run: | + # Strict because the value reaches single-quoted ldflags. The smoke + # test tripwires on the looser ^v[0-9], so a tag rejected here fails + # loudly there instead of publishing a release image as dev+. VERSION="" - if [[ "$TAG" =~ ^v[0-9] ]]; then + if [[ "$TAG" =~ ^v[0-9][A-Za-z0-9.+_-]*$ ]]; then VERSION="$TAG" fi echo "version=$VERSION" >> $GITHUB_OUTPUT diff --git a/Makefile b/Makefile index f1b56a406..02537d591 100644 --- a/Makefile +++ b/Makefile @@ -21,12 +21,13 @@ GOTESTSUM = $(shell which gotestsum || echo "~/go/bin/gotestsum") # These are only used when running tests locally (real CI already sets them). FAKE_CI_ENV = GITHUB_RUN_NUMBER=1 GITHUB_SERVER_URL=https://github.com GITHUB_REPOSITORY=kosli-dev/cli GITHUB_REPOSITORY_ID=123456 -# VERSION is the release version the binary reports. Unset, it falls back to +# VERSION is the release version the binary reports. Without it we fall back to # the tag at HEAD, so a local `make build` in a checked-out release still -# reports it. Set — including to the empty string, meaning "not a release" — -# it wins outright, which is how CI keeps a build from depending on whichever -# tags the checkout happens to have (#1133). -ifeq ($(origin VERSION),undefined) +# reports it. Passed on the command line — empty string included, meaning "not +# a release" — it wins, which is how CI keeps a build off the checkout's tags +# (#1133). Only a command-line assignment counts: an inherited environment +# VERSION must not silently stamp a release. ("command line" is two words.) +ifeq ($(filter command line override,$(origin VERSION)),) BINARY_VERSION = $(GIT_TAG) else BINARY_VERSION = $(VERSION) diff --git a/scripts/docker-smoke-tests.sh b/scripts/docker-smoke-tests.sh index 7ba028874..edf8f9f08 100755 --- a/scripts/docker-smoke-tests.sh +++ b/scripts/docker-smoke-tests.sh @@ -64,6 +64,15 @@ run_case() { # unset means a non-release build, which must report dev+. test_version() { local full short commit + + # EXPECTED_VERSION and the baked-in version share one workflow output, so a + # misclassification there would have both agree on the wrong answer. + # Re-derive from the image tag: a release tag must carry its own version. + if [[ "$TAG" =~ ^v[0-9] ]] && [ "${EXPECTED_VERSION:-}" != "$TAG" ]; then + echo "release tag ${TAG} but the build baked '${EXPECTED_VERSION:-}'" >&2 + return 1 + fi + full="$(docker run --rm -e KOSLI_NO_UPDATE_CHECK=1 "${IMAGE}:${TAG}" version)" || return 1 echo "$full" From c6910cf804a372c9d0f3a21f01b7518e6402b3e3 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Sat, 29 Aug 2026 20:52:08 +0100 Subject: [PATCH 3/5] fix(docker): close both nits in the version classifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `origin` returns "environment override" when the makefile assigns VERSION and `-e` makes the environment win. `filter` matched the trailing `override` and treated that as a command-line assignment, re-opening the hole the comment above it claims to close. `filter-out` keys on the leftover word instead, so only a genuine command-line or override-directive assignment counts. The Makefile does not assign VERSION today, so this is not currently reachable — it stops a later `VERSION ?=` default from quietly restoring the bug. The smoke-test tripwire only checked one direction: a release tag with a wrong or empty version. The mirror case — a sha tag carrying a release version, which would publish a dev image reporting v — passed. Derive the expectation from the image tag once and compare, covering both. --- Makefile | 7 ++++--- scripts/docker-smoke-tests.sh | 13 ++++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 02537d591..0565e61c2 100644 --- a/Makefile +++ b/Makefile @@ -25,9 +25,10 @@ FAKE_CI_ENV = GITHUB_RUN_NUMBER=1 GITHUB_SERVER_URL=https://github.com GITHUB_RE # the tag at HEAD, so a local `make build` in a checked-out release still # reports it. Passed on the command line — empty string included, meaning "not # a release" — it wins, which is how CI keeps a build off the checkout's tags -# (#1133). Only a command-line assignment counts: an inherited environment -# VERSION must not silently stamp a release. ("command line" is two words.) -ifeq ($(filter command line override,$(origin VERSION)),) +# (#1133). Only a command-line assignment counts, so an inherited environment +# VERSION cannot silently stamp a release: `origin` yields multi-word strings +# ("command line", "environment override"), and filter-out cannot confuse them. +ifneq ($(filter-out command line override,$(origin VERSION)),) BINARY_VERSION = $(GIT_TAG) else BINARY_VERSION = $(VERSION) diff --git a/scripts/docker-smoke-tests.sh b/scripts/docker-smoke-tests.sh index edf8f9f08..488186fbe 100755 --- a/scripts/docker-smoke-tests.sh +++ b/scripts/docker-smoke-tests.sh @@ -63,13 +63,16 @@ run_case() { # exact version required — the same value CI baked into the binary; empty or # unset means a non-release build, which must report dev+. test_version() { - local full short commit + local full short commit from_tag # EXPECTED_VERSION and the baked-in version share one workflow output, so a - # misclassification there would have both agree on the wrong answer. - # Re-derive from the image tag: a release tag must carry its own version. - if [[ "$TAG" =~ ^v[0-9] ]] && [ "${EXPECTED_VERSION:-}" != "$TAG" ]; then - echo "release tag ${TAG} but the build baked '${EXPECTED_VERSION:-}'" >&2 + # misclassification there would have both agree on the wrong answer. Derive + # the expectation from the image tag instead and compare: a release tag must + # carry its own version, and a sha tag must carry none. + from_tag="" + [[ "$TAG" =~ ^v[0-9] ]] && from_tag="$TAG" + if [ "${EXPECTED_VERSION:-}" != "$from_tag" ]; then + echo "image tag ${TAG} implies '${from_tag}' but the build baked '${EXPECTED_VERSION:-}'" >&2 return 1 fi From 075d126a31d3b45ccd90a3c9d61a121bd82a0245 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Sat, 29 Aug 2026 20:52:09 +0100 Subject: [PATCH 4/5] fix(docker): report the published tag as the image version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the release classifier. The image now reports whatever tag it is published under — a release version from release.yml, a short sha from main.yml — so the build arg and the smoke-test expectation are the same expression and cannot drift. That removes the prepare output, both tag regexes and the smoke-test tripwire they needed. Non-release images now report the sha instead of dev+, so the update check keys on the v prefix a release always has rather than on the "dev" string that no longer appears. It skips for dev builds, dev+ and shas like, and now does so before the HTTP call rather than after it. EXPECTED_VERSION is always set by CI, so the smoke test requires it and asserts equality; its dev+ branch is gone. --- .github/workflows/docker.yml | 29 ++++++------------------- Makefile | 13 ++++++----- internal/version/update_check.go | 5 +++-- internal/version/update_check_test.go | 9 ++++++++ scripts/docker-smoke-tests.sh | 31 ++++++--------------------- 5 files changed, 31 insertions(+), 56 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index b46936315..a4eea8436 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -49,8 +49,7 @@ env: jobs: # --------------------------------------------------------------------------- - # Job 1: Resolve the platforms input into a JSON matrix, and resolve the - # version the built binary must report. + # Job 1: Resolve the platforms input into a JSON matrix. # # Why a separate job: workflow-level matrix entries must be static or come # from job outputs; they cannot be computed inline from an input string. @@ -62,7 +61,6 @@ jobs: runs-on: ubuntu-latest outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} - version: ${{ steps.set-version.outputs.version }} steps: - name: Harden Runner uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 @@ -97,23 +95,6 @@ jobs: ') echo "matrix=$MATRIX" >> $GITHUB_OUTPUT - # The one place that decides whether this is a release build: the build - # job bakes this into the binary and the smoke test asserts the image - # reports it. Empty means "not a release" — the binary reports dev+. - - name: Resolve the version to build - id: set-version - env: - TAG: ${{ inputs.tag }} - run: | - # Strict because the value reaches single-quoted ldflags. The smoke - # test tripwires on the looser ^v[0-9], so a tag rejected here fails - # loudly there instead of publishing a release image as dev+. - VERSION="" - if [[ "$TAG" =~ ^v[0-9][A-Za-z0-9.+_-]*$ ]]; then - VERSION="$TAG" - fi - echo "version=$VERSION" >> $GITHUB_OUTPUT - # --------------------------------------------------------------------------- # Job 2: Build each platform on its native runner, push by digest. @@ -169,8 +150,10 @@ jobs: uses: docker/build-push-action@v7 with: context: . + # The image reports the tag it is published under — a release version + # or a sha. Nothing infers which, so the two cannot disagree (#1133). build-args: | - VERSION=${{ needs.prepare.outputs.version }} + VERSION=${{ inputs.tag }} # Push without a tag — the merge job creates the final manifest. platforms: ${{ matrix.platform }} outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true @@ -203,7 +186,7 @@ jobs: merge: name: Docker Merge & Attest runs-on: ubuntu-latest - needs: [prepare, build] + needs: [build] permissions: id-token: write contents: write @@ -369,7 +352,7 @@ jobs: IMAGE: ${{ env.IMAGE }} TAG: ${{ inputs.tag }} # The same value the build job baked in. - EXPECTED_VERSION: ${{ needs.prepare.outputs.version }} + EXPECTED_VERSION: ${{ inputs.tag }} run: ./scripts/docker-smoke-tests.sh - name: Report Docker smoke test attestation to Kosli diff --git a/Makefile b/Makefile index 0565e61c2..17bc9146b 100644 --- a/Makefile +++ b/Makefile @@ -21,13 +21,12 @@ GOTESTSUM = $(shell which gotestsum || echo "~/go/bin/gotestsum") # These are only used when running tests locally (real CI already sets them). FAKE_CI_ENV = GITHUB_RUN_NUMBER=1 GITHUB_SERVER_URL=https://github.com GITHUB_REPOSITORY=kosli-dev/cli GITHUB_REPOSITORY_ID=123456 -# VERSION is the release version the binary reports. Without it we fall back to -# the tag at HEAD, so a local `make build` in a checked-out release still -# reports it. Passed on the command line — empty string included, meaning "not -# a release" — it wins, which is how CI keeps a build off the checkout's tags -# (#1133). Only a command-line assignment counts, so an inherited environment -# VERSION cannot silently stamp a release: `origin` yields multi-word strings -# ("command line", "environment override"), and filter-out cannot confuse them. +# VERSION is the version the binary reports. Unset, we fall back to the tag at +# HEAD so a local `make build` in a checked-out release still reports it; passed +# on the command line it wins outright, which is how CI stamps the image tag it +# publishes under (#1133). Only a command-line assignment counts, so an ambient +# VERSION cannot stamp a release: `origin` yields multi-word strings ("command +# line", "environment override"), so filter-out is correct where filter is not. ifneq ($(filter-out command line override,$(origin VERSION)),) BINARY_VERSION = $(GIT_TAG) else diff --git a/internal/version/update_check.go b/internal/version/update_check.go index e8eb50130..add64ad03 100644 --- a/internal/version/update_check.go +++ b/internal/version/update_check.go @@ -65,8 +65,9 @@ func checkForUpdateWithURL(currentVersion string, apiURL string) (string, error) if os.Getenv("KOSLI_NO_UPDATE_CHECK") != "" { return "", nil } - // dev build — skip - if currentVersion == "" || strings.HasPrefix(currentVersion, "dev") { + // Not a release build — skip. Releases are v-prefixed; dev builds report + // "dev"/"dev+" and branch containers report their short sha. + if !strings.HasPrefix(currentVersion, "v") { return "", nil } diff --git a/internal/version/update_check_test.go b/internal/version/update_check_test.go index aac05607d..a9ca351e1 100644 --- a/internal/version/update_check_test.go +++ b/internal/version/update_check_test.go @@ -73,6 +73,15 @@ func TestCheckForUpdate_DevBuildWithMetadata(t *testing.T) { assert.Empty(t, notice) } +func TestCheckForUpdate_ShaTaggedBuild(t *testing.T) { + // Branch containers report their short sha — skip without any HTTP call. + for _, version := range []string{"2cd8d803", "12345678"} { + notice, err := checkForUpdateWithURL(version, "http://should-not-be-called") + assert.NoError(t, err) + assert.Empty(t, notice) + } +} + func TestCheckForUpdate_NetworkError(t *testing.T) { notice, err := checkForUpdateWithURL("v0.1.0", "http://localhost:1") // nothing listening assert.NoError(t, err) // must be silent diff --git a/scripts/docker-smoke-tests.sh b/scripts/docker-smoke-tests.sh index 488186fbe..3e0e59027 100755 --- a/scripts/docker-smoke-tests.sh +++ b/scripts/docker-smoke-tests.sh @@ -3,13 +3,14 @@ # outcome to $RESULTS_FILE for the CI workflow to report as a Kosli # attestation. # -# Usage: IMAGE=... TAG=... RESULTS_FILE=... [EXPECTED_VERSION=...] \ +# Usage: IMAGE=... TAG=... RESULTS_FILE=... EXPECTED_VERSION=... \ # ./scripts/docker-smoke-tests.sh set -uo pipefail IMAGE="${IMAGE:?IMAGE is required}" TAG="${TAG:?TAG is required}" RESULTS_FILE="${RESULTS_FILE:?RESULTS_FILE is required}" +EXPECTED_VERSION="${EXPECTED_VERSION:?EXPECTED_VERSION is required}" REPO_ROOT="${GITHUB_WORKSPACE:-$(git rev-parse --show-toplevel)}" if [ -z "$REPO_ROOT" ]; then @@ -58,23 +59,10 @@ run_case() { # Add a new smoke test by writing a test_* function below and adding one # entry to the CASES array further down — no CI workflow changes needed. -# Asserts the image reports the release it was published as, built from a clean -# tree at the built commit — the two halves of #1133. EXPECTED_VERSION is the -# exact version required — the same value CI baked into the binary; empty or -# unset means a non-release build, which must report dev+. +# Asserts the image reports the version it ships as, built from a clean tree at +# the built commit — the two halves of #1133. EXPECTED_VERSION is what CI baked in. test_version() { - local full short commit from_tag - - # EXPECTED_VERSION and the baked-in version share one workflow output, so a - # misclassification there would have both agree on the wrong answer. Derive - # the expectation from the image tag instead and compare: a release tag must - # carry its own version, and a sha tag must carry none. - from_tag="" - [[ "$TAG" =~ ^v[0-9] ]] && from_tag="$TAG" - if [ "${EXPECTED_VERSION:-}" != "$from_tag" ]; then - echo "image tag ${TAG} implies '${from_tag}' but the build baked '${EXPECTED_VERSION:-}'" >&2 - return 1 - fi + local full short commit full="$(docker run --rm -e KOSLI_NO_UPDATE_CHECK=1 "${IMAGE}:${TAG}" version)" || return 1 echo "$full" @@ -95,13 +83,8 @@ test_version() { short="$(docker run --rm -e KOSLI_NO_UPDATE_CHECK=1 "${IMAGE}:${TAG}" version --short)" || return 1 echo "version --short: ${short}" - if [ -n "${EXPECTED_VERSION:-}" ]; then - if [ "$short" != "$EXPECTED_VERSION" ]; then - echo "expected version ${EXPECTED_VERSION}, got ${short}" >&2 - return 1 - fi - elif ! grep -qE '^dev\+[0-9a-f]{7,}$' <<< "$short"; then - echo "expected dev+ for a non-release build, got ${short}" >&2 + if [ "$short" != "$EXPECTED_VERSION" ]; then + echo "expected version ${EXPECTED_VERSION}, got ${short}" >&2 return 1 fi } From 70f03505a0f9601cdb7914736a90758a96220696 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Sat, 29 Aug 2026 20:52:10 +0100 Subject: [PATCH 5/5] fix(docker): mark non-release images as dev+ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the version once in prepare rather than passing the tag straight through: a v-prefixed tag is reported as-is, anything else becomes dev+. Both the build arg and the smoke-test expectation read that one output, so the baked version and the asserted one still cannot drift. Branch images therefore keep reporting dev+ exactly as before, which makes the update check's existing "dev" prefix test correct again — so the v-prefix change to update_check.go and its test are reverted. The match is deliberately loose. This repo cuts -rc tags, which the stricter ^v[0-9]+\.[0-9]+\.[0-9]+$ used in install-script-tests.yml would misread as dev builds. --- .github/workflows/docker.yml | 28 +++++++++++++++++++-------- Dockerfile | 5 ++--- Makefile | 10 +++++----- internal/version/update_check.go | 5 ++--- internal/version/update_check_test.go | 9 --------- 5 files changed, 29 insertions(+), 28 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index a4eea8436..5b6848c7b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -49,7 +49,8 @@ env: jobs: # --------------------------------------------------------------------------- - # Job 1: Resolve the platforms input into a JSON matrix. + # Job 1: Resolve the platforms input into a JSON matrix, and the version the + # built binary reports. # # Why a separate job: workflow-level matrix entries must be static or come # from job outputs; they cannot be computed inline from an input string. @@ -61,6 +62,7 @@ jobs: runs-on: ubuntu-latest outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} + version: ${{ steps.set-version.outputs.version }} steps: - name: Harden Runner uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 @@ -95,6 +97,19 @@ jobs: ') echo "matrix=$MATRIX" >> $GITHUB_OUTPUT + # Resolved once and read by both the build arg and the smoke test, so the + # baked version and the asserted one cannot drift apart (#1133). + - name: Resolve the version to build + id: set-version + env: + TAG: ${{ inputs.tag }} + run: | + # A version tag is reported as-is; any other tag is a dev build. + # Loose on purpose: ^v[0-9] also accepts the -rc tags this repo cuts. + VERSION="$TAG" + [[ "$TAG" =~ ^v[0-9] ]] || VERSION="dev+$TAG" + echo "version=$VERSION" >> $GITHUB_OUTPUT + # --------------------------------------------------------------------------- # Job 2: Build each platform on its native runner, push by digest. @@ -127,8 +142,7 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 3 - # No fetch-tags: the version arrives as a build arg below, so the - # build must not pick one up from this checkout. + # No fetch-tags: the version arrives as a build arg, not from here. # .git/ ships in the build context, which mode=max exports to the # public cache ref. persist-credentials: false @@ -150,10 +164,8 @@ jobs: uses: docker/build-push-action@v7 with: context: . - # The image reports the tag it is published under — a release version - # or a sha. Nothing infers which, so the two cannot disagree (#1133). build-args: | - VERSION=${{ inputs.tag }} + VERSION=${{ needs.prepare.outputs.version }} # Push without a tag — the merge job creates the final manifest. platforms: ${{ matrix.platform }} outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true @@ -186,7 +198,7 @@ jobs: merge: name: Docker Merge & Attest runs-on: ubuntu-latest - needs: [build] + needs: [prepare, build] permissions: id-token: write contents: write @@ -352,7 +364,7 @@ jobs: IMAGE: ${{ env.IMAGE }} TAG: ${{ inputs.tag }} # The same value the build job baked in. - EXPECTED_VERSION: ${{ inputs.tag }} + EXPECTED_VERSION: ${{ needs.prepare.outputs.version }} run: ./scripts/docker-smoke-tests.sh - name: Report Docker smoke test attestation to Kosli diff --git a/Dockerfile b/Dockerfile index 4f62f5edb..83fa1cb44 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,9 +3,8 @@ ARG GO_VERSION="1.26" ARG ALPINE_VERSION="3.23" -# The release version the binary reports; empty builds dev+. Passed in -# rather than read from git tags so the image depends on the build request, -# not on which tags the checkout has (#1133). `make docker` passes it for you. +# The version the binary reports; empty builds dev+. Passed in rather than +# read from git tags, so the image cannot depend on the checkout's tags (#1133). ARG VERSION="" diff --git a/Makefile b/Makefile index 17bc9146b..a477d41e4 100644 --- a/Makefile +++ b/Makefile @@ -23,10 +23,10 @@ FAKE_CI_ENV = GITHUB_RUN_NUMBER=1 GITHUB_SERVER_URL=https://github.com GITHUB_RE # VERSION is the version the binary reports. Unset, we fall back to the tag at # HEAD so a local `make build` in a checked-out release still reports it; passed -# on the command line it wins outright, which is how CI stamps the image tag it -# publishes under (#1133). Only a command-line assignment counts, so an ambient -# VERSION cannot stamp a release: `origin` yields multi-word strings ("command -# line", "environment override"), so filter-out is correct where filter is not. +# on the command line it wins outright, which is how CI stamps the version it +# resolved for the build (#1133). Only a command-line assignment counts, so an +# ambient VERSION cannot stamp a release: `origin` yields multi-word strings +# ("command line", "environment override"), so filter-out, not filter. ifneq ($(filter-out command line override,$(origin VERSION)),) BINARY_VERSION = $(GIT_TAG) else @@ -38,7 +38,7 @@ ifneq ($(BINARY_VERSION),) LDFLAGS += -X github.com/kosli-dev/cli/internal/version.version=${BINARY_VERSION} endif -# Release builds report their version alone; others append the short sha. +# With a version, report it alone; without one, "dev" plus the short sha. VERSION_METADATA = $(GIT_SHA) ifneq ($(BINARY_VERSION),) VERSION_METADATA = diff --git a/internal/version/update_check.go b/internal/version/update_check.go index add64ad03..e8eb50130 100644 --- a/internal/version/update_check.go +++ b/internal/version/update_check.go @@ -65,9 +65,8 @@ func checkForUpdateWithURL(currentVersion string, apiURL string) (string, error) if os.Getenv("KOSLI_NO_UPDATE_CHECK") != "" { return "", nil } - // Not a release build — skip. Releases are v-prefixed; dev builds report - // "dev"/"dev+" and branch containers report their short sha. - if !strings.HasPrefix(currentVersion, "v") { + // dev build — skip + if currentVersion == "" || strings.HasPrefix(currentVersion, "dev") { return "", nil } diff --git a/internal/version/update_check_test.go b/internal/version/update_check_test.go index a9ca351e1..aac05607d 100644 --- a/internal/version/update_check_test.go +++ b/internal/version/update_check_test.go @@ -73,15 +73,6 @@ func TestCheckForUpdate_DevBuildWithMetadata(t *testing.T) { assert.Empty(t, notice) } -func TestCheckForUpdate_ShaTaggedBuild(t *testing.T) { - // Branch containers report their short sha — skip without any HTTP call. - for _, version := range []string{"2cd8d803", "12345678"} { - notice, err := checkForUpdateWithURL(version, "http://should-not-be-called") - assert.NoError(t, err) - assert.Empty(t, notice) - } -} - func TestCheckForUpdate_NetworkError(t *testing.T) { notice, err := checkForUpdateWithURL("v0.1.0", "http://localhost:1") // nothing listening assert.NoError(t, err) // must be silent