The published container images report themselves as dev, not as the release they are tagged with,
and their tree state as dirty. So kosli version inside a container cannot tell you which CLI
you are running.
From ghcr.io/kosli-dev/cli:v2.38.0 (digest
sha256:28b5291ed007d5c8afacb96ba393b50e70dcd5715d26bb46f27126d3cee4c10b, linux/arm64):
$ docker run --rm ghcr.io/kosli-dev/cli:v2.38.0 version
version.BuildInfo{Version:"dev+e5b4118", GitCommit:"e5b41181dd39f6f0e561f4c8359be2a68c94ab0a", GitTreeState:"dirty", GoVersion:"go1.26.6"}
The commit is right — v2.38.0 does tag e5b4118 — so the image holds the correct code. It is only
the version and tree-state strings that are wrong. The goreleaser release archive for the same
version is correct:
version.BuildInfo{Version:"v2.38.0", ..., GitTreeState:"clean", ...}
Why
Two independent causes, both in the Docker build path rather than in the Go code.
The version string. Dockerfile runs a bare make build, and .github/workflows/docker.yml
passes no build-args. The Makefile derives the version from the git tag:
GIT_TAG = $(shell git describe --tags --abbrev=0 --exact-match 2>/dev/null)
BINARY_VERSION ?= ${GIT_TAG}
VERSION_METADATA = $(GIT_SHA)
docker.yml checks out with fetch-depth: 3 and no fetch-tags, so the clone in the build context
has no tags at all. git describe finds nothing, BINARY_VERSION is empty, the version ldflag is
never set, and internal/version/version.go's version = "dev" default survives. VERSION_METADATA
stays at GIT_SHA, which is where the +e5b4118 suffix comes from.
The workflow already knows the answer — it takes a tag input, and the release workflow passes the
release tag to it — so it is only a matter of getting it to make build.
The tree state. .dockerignore is deny-all with an allow-list that keeps .git/:
*
!cmd/
!internal/
!Makefile
!go.*
!.git/
So the builder has the real git history but only a subset of the working tree. git status --porcelain therefore reports every tracked file outside that allow-list as deleted, and the
Makefile's GIT_DIRTY check turns that into dirty. The tree is not actually dirty; the build
context is just partial.
Suggested fix
Pass the version in rather than deriving it inside a partial checkout. In Dockerfile:
ARG VERSION
ARG GIT_COMMIT
RUN make build VERSION=${VERSION} GIT_COMMIT=${GIT_COMMIT} GIT_DIRTY=clean
and in the build step in docker.yml:
build-args: |
VERSION=${{ inputs.tag }}
GIT_COMMIT=${{ github.sha }}
That also removes the build's dependence on .git/ being in the context, so !.git/ can come out
of .dockerignore — which shrinks the context and stops history changes busting the build cache.
Hard-coding GIT_DIRTY=clean is honest here because CI builds from a clean checkout; if that feels
too strong, the alternative is to compute it outside the container and pass it in the same way.
Worth asserting afterwards, so this cannot come back silently: a step in docker.yml that runs the
built image and checks kosli version --short equals the tag it is about to be published under.
Why we noticed
Kosli Capture ships the CLI inside its own image. We wanted to copy the binary out of
ghcr.io/kosli-dev/cli — the pattern Dockerfile.alpine in this repo uses — but a container whose
kosli version cannot name its release is a bad place to start debugging a failed snapshot, so we
fetch the checksum-verified release archive instead. That works, but it is more machinery than the
COPY --from would have been, and we would rather go back to the image once this is fixed.
The published container images report themselves as
dev, not as the release they are tagged with,and their tree state as
dirty. Sokosli versioninside a container cannot tell you which CLIyou are running.
From
ghcr.io/kosli-dev/cli:v2.38.0(digestsha256:28b5291ed007d5c8afacb96ba393b50e70dcd5715d26bb46f27126d3cee4c10b, linux/arm64):The commit is right —
v2.38.0does tage5b4118— so the image holds the correct code. It is onlythe version and tree-state strings that are wrong. The goreleaser release archive for the same
version is correct:
Why
Two independent causes, both in the Docker build path rather than in the Go code.
The version string.
Dockerfileruns a baremake build, and.github/workflows/docker.ymlpasses no
build-args. TheMakefilederives the version from the git tag:docker.ymlchecks out withfetch-depth: 3and nofetch-tags, so the clone in the build contexthas no tags at all.
git describefinds nothing,BINARY_VERSIONis empty, theversionldflag isnever set, and
internal/version/version.go'sversion = "dev"default survives.VERSION_METADATAstays at
GIT_SHA, which is where the+e5b4118suffix comes from.The workflow already knows the answer — it takes a
taginput, and the release workflow passes therelease tag to it — so it is only a matter of getting it to
make build.The tree state.
.dockerignoreis deny-all with an allow-list that keeps.git/:So the builder has the real git history but only a subset of the working tree.
git status --porcelaintherefore reports every tracked file outside that allow-list as deleted, and theMakefile's
GIT_DIRTYcheck turns that intodirty. The tree is not actually dirty; the buildcontext is just partial.
Suggested fix
Pass the version in rather than deriving it inside a partial checkout. In
Dockerfile:and in the build step in
docker.yml:That also removes the build's dependence on
.git/being in the context, so!.git/can come outof
.dockerignore— which shrinks the context and stops history changes busting the build cache.Hard-coding
GIT_DIRTY=cleanis honest here because CI builds from a clean checkout; if that feelstoo strong, the alternative is to compute it outside the container and pass it in the same way.
Worth asserting afterwards, so this cannot come back silently: a step in
docker.ymlthat runs thebuilt image and checks
kosli version --shortequals the tag it is about to be published under.Why we noticed
Kosli Capture ships the CLI inside its own image. We wanted to copy the binary out of
ghcr.io/kosli-dev/cli— the patternDockerfile.alpinein this repo uses — but a container whosekosli versioncannot name its release is a bad place to start debugging a failed snapshot, so wefetch the checksum-verified release archive instead. That works, but it is more machinery than the
COPY --fromwould have been, and we would rather go back to the image once this is fixed.