From c460b0d6a5453568a698caadb3a3f8e27df10409 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 00:49:56 +0000 Subject: [PATCH] Derive the image version from git; send .git to the build (closes #69) The canonical documents told every repo to exclude .git from the build context, default ARG VERSION to dev and never run git describe in a build stage, so an image built from a clone with no build argument reported dev. .dockerignore now sends .git. The Dockerfile example in REPO_POLICIES.md installs git, takes the VERSION build argument when one is given and otherwise git describe --tags --always, and fails when .git is present but the version is empty, dev or unknown. The checklists, the Go docs' Makefile comments, the README, and this repo's Dockerfile and scripts now say the same. Model: opus-5-5 --- .dockerignore | 5 ++- Dockerfile | 7 ++-- README.md | 3 +- prompts/CODE_STYLEGUIDE_GO.md | 13 +++----- prompts/EXISTING_REPO_CHECKLIST.md | 16 ++++++--- prompts/GO_HTTP_SERVER_CONVENTIONS.md | 13 +++----- prompts/NEW_REPO_CHECKLIST.md | 15 +++++---- prompts/REPO_POLICIES.md | 47 ++++++++++++++++++--------- script/cibuild | 5 ++- script/docker | 5 ++- 10 files changed, 73 insertions(+), 56 deletions(-) diff --git a/.dockerignore b/.dockerignore index fc4fd74..968019b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,9 +13,8 @@ # `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and # deletes the package directory from the context. -# Excluding .git means `git describe` cannot run in any build stage and -# fails quietly there; pass the version in with --build-arg VERSION. -.git +# .git is sent: without a VERSION build argument, the stage that compiles +# takes the version from `git describe --tags --always` on it. # Agent scratch: one full checkout of the repo per in-flight agent. # Anchored because it occurs once where agents run at the repo root. diff --git a/Dockerfile b/Dockerfile index da25dee..e37890c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,7 +52,8 @@ RUN script/bootstrap COPY . . -# The version is computed on the host and passed in, because -# .dockerignore excludes .git. -ARG VERSION=dev +# Nothing here is compiled and a LABEL cannot run git, so the version is +# the VERSION build argument that script/docker and script/cibuild pass; +# a plain `docker build .` leaves it empty. +ARG VERSION LABEL org.opencontainers.image.version="${VERSION}" diff --git a/README.md b/README.md index 68dca9c..370a06e 100644 --- a/README.md +++ b/README.md @@ -132,8 +132,7 @@ alpine. We provide: `script/check`, compute `version` from `git describe`, then `docker build --no-cache --build-arg VERSION="$version" -t prompts .` (what CI runs; it bootstraps because CI checks out and runs this alone while - `script/fmt-check` is native, and the version is computed on the host because - `.dockerignore` excludes `.git`) + `script/fmt-check` is native) - `script/precommit` — run by the git pre-commit hook (our own extension); calls `script/check` - `script/install-precommit` — installs the git pre-commit hook (our own diff --git a/prompts/CODE_STYLEGUIDE_GO.md b/prompts/CODE_STYLEGUIDE_GO.md index d91a35c..2b409cf 100644 --- a/prompts/CODE_STYLEGUIDE_GO.md +++ b/prompts/CODE_STYLEGUIDE_GO.md @@ -1,6 +1,6 @@ --- title: Code Styleguide — Go -last_modified: 2026-09-08 +last_modified: 2026-10-02 --- 1. Try to hard wrap long lines at 77 characters or less. @@ -48,13 +48,10 @@ last_modified: 2026-09-08 ``` ```make - # ?= rather than := because this `$(shell git describe ...)` is only - # correct on the host: `.dockerignore` excludes `.git`, so evaluated - # inside a build stage it expands to the empty string without failing - # and the binary reports no version. The version is computed on the - # host by `script/docker` / `script/cibuild` and passed with - # `--build-arg VERSION=...`; where a build stage invokes make, - # `ARG VERSION` puts it in the environment and `?=` defers to it. + # ?= rather than := so that a `VERSION` build argument takes precedence: + # where a build stage invokes make, `ARG VERSION` puts it in the + # environment and `?=` defers to it. Otherwise `git describe` runs, in a + # build stage on the `.git` the build context carries. VERSION ?= $(shell git describe --always --dirty) GOLDFLAGS += -X main.Version=$(VERSION) diff --git a/prompts/EXISTING_REPO_CHECKLIST.md b/prompts/EXISTING_REPO_CHECKLIST.md index 70b5f58..a8bc4e2 100644 --- a/prompts/EXISTING_REPO_CHECKLIST.md +++ b/prompts/EXISTING_REPO_CHECKLIST.md @@ -1,6 +1,6 @@ --- title: Existing Repo Checklist -last_modified: 2026-09-08 +last_modified: 2026-10-02 --- Use this checklist when beginning work in a repo that may not yet conform to our @@ -44,7 +44,7 @@ with your task. `script/test` — those are themselves a `docker build` and would recurse inside a build step - [ ] Every depth-independent pattern in `.dockerignore` carries a `**/` prefix, - only genuinely root-anchored entries such as `.git` are unprefixed, and + only genuinely root-anchored entries such as `.claude` are unprefixed, and `.gitignore`'s patterns have not been transplanted unmodified — the transplanted form leaves `config/.env` and `certs/server.key` in the build context while reading as solved @@ -58,9 +58,15 @@ with your task. can copy another session's unreviewed work into an image layer. If agents here run anywhere other than the repo root, the anchored entry misses `services/api/.claude/`: add anchored entries for those directories. -- [ ] If the repo embeds a version in a binary, that version is computed on the - host and passed with `--build-arg VERSION=...` by `script/docker` and - `script/cibuild`, and no stage calls `git describe`. A tag-derived version +- [ ] If the repo embeds a version in a binary: `.dockerignore` lets `.git` into + the build context. The stage that compiles has `git` (the Debian Go image + has it; an alpine one needs `apk add --no-cache git`) and takes the + version from the `VERSION` build argument when one is given, otherwise + from `git describe --tags --always` (a tag when the commit has one, + otherwise the short commit). `ARG VERSION` has no default, and the build + fails if the context carries `.git` and the version still comes out empty, + `dev` or `unknown`. `script/docker` and `script/cibuild` pass the version + they compute on the host; it takes precedence. A tag-derived version additionally needs `fetch-depth: 0` on the CI checkout step, which clones shallow and fetches no tags by default. - [ ] Gitea Actions workflow in `.gitea/workflows/` runs `script/cibuild` on diff --git a/prompts/GO_HTTP_SERVER_CONVENTIONS.md b/prompts/GO_HTTP_SERVER_CONVENTIONS.md index 72d528c..0aa35b5 100644 --- a/prompts/GO_HTTP_SERVER_CONVENTIONS.md +++ b/prompts/GO_HTTP_SERVER_CONVENTIONS.md @@ -1,6 +1,6 @@ --- title: Go HTTP Server Conventions -last_modified: 2026-09-08 +last_modified: 2026-10-02 --- This document defines the architectural patterns, design decisions, and @@ -984,13 +984,10 @@ func main() { Use ldflags to inject version information at build time: ```makefile -# ?= rather than := because this `$(shell git describe ...)` is only correct -# on the host: `.dockerignore` excludes `.git`, so evaluated inside a build -# stage it expands to the empty string without failing and the binary reports -# no version. The version is computed on the host by `script/docker` / -# `script/cibuild` and passed with `--build-arg VERSION=...`; where the build -# stage invokes make, `ARG VERSION` puts it in the environment and `?=` defers -# to it. +# ?= rather than := so that a `VERSION` build argument takes precedence: +# where a build stage invokes make, `ARG VERSION` puts it in the +# environment and `?=` defers to it. Otherwise `git describe` runs, in a +# build stage on the `.git` the build context carries. VERSION ?= $(shell git describe --tags --always) build: diff --git a/prompts/NEW_REPO_CHECKLIST.md b/prompts/NEW_REPO_CHECKLIST.md index 4789182..59476e0 100644 --- a/prompts/NEW_REPO_CHECKLIST.md +++ b/prompts/NEW_REPO_CHECKLIST.md @@ -1,6 +1,6 @@ --- title: New Repo Checklist -last_modified: 2026-09-08 +last_modified: 2026-10-02 --- Use this checklist when creating a new repository from scratch. Follow the steps @@ -67,11 +67,14 @@ Template files can be fetched from: note that it only covers agents running at the repo root — if this repo will run them in subdirectories, `services/api/.claude/` needs its own anchored entry. - - If the image embeds a version in a binary, the version is computed on the - host and passed with `--build-arg VERSION=...`, and `ARG VERSION=dev` is - declared in the stage that compiles. **No stage calls `git describe`** — - `.dockerignore` excludes `.git`, so it yields an empty version without - failing the build. + - If the image embeds a version in a binary: `.dockerignore` lets `.git` + into the build context. The stage that compiles has `git` (the Debian Go + image has it; an alpine one needs `apk add --no-cache git`) and takes the + version from the `VERSION` build argument when one is given, otherwise + from `git describe --tags --always` (a tag when the commit has one, + otherwise the short commit). `ARG VERSION` has no default, and the build + fails if the context carries `.git` and the version still comes out empty, + `dev` or `unknown`. - The Dockerfile carries a `lint` phase and a `test` phase, each invoking its tool directly rather than through `make` or `script/`, and the final stage carries a `COPY --from=` of a harmless file from each so the image diff --git a/prompts/REPO_POLICIES.md b/prompts/REPO_POLICIES.md index 2256291..c14aabd 100644 --- a/prompts/REPO_POLICIES.md +++ b/prompts/REPO_POLICIES.md @@ -1,6 +1,6 @@ --- title: Repository Policies -last_modified: 2026-09-08 +last_modified: 2026-10-02 --- This document covers repository structure, tooling, and workflow standards. Code @@ -191,15 +191,27 @@ style conventions are in separate documents: FROM golang@sha256:... AS builder COPY --from=lint /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null + RUN apk add --no-cache git WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . - ARG VERSION=dev - RUN CGO_ENABLED=0 go build -trimpath \ - -ldflags="-s -w -X main.Version=${VERSION}" \ - -o /app ./cmd/app/ + # The VERSION build arg when one is given, otherwise the tag or short + # commit from the .git in the build context. With .git present, a + # version that is still empty, dev or unknown fails the build: git is + # missing or could not read the checkout. + ARG VERSION + RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \ + if [ -d .git ]; then \ + case "$VERSION" in ""|dev|unknown) \ + echo "version is '$VERSION' although .git is present" >&2; \ + exit 1 ;; \ + esac; \ + fi; \ + CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o /app ./cmd/app/ # Runtime stage, and the last one FROM alpine@sha256:... @@ -223,8 +235,13 @@ style conventions are in separate documents: `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. - If the project requires CGO or system libraries for linting (e.g. `vips-dev`), install them in the lint phase with `apk add`. - - `ARG VERSION=dev` is declared in the stage that compiles and supplied by - `script/docker` and `script/cibuild`; no stage may call `git describe`. + - `.dockerignore` lets `.git` into the build context. The stage that + compiles has `git` (the Debian Go image has it; an alpine one needs + `apk add --no-cache git`) and takes the version from the `VERSION` build + argument when one is given, otherwise from `git describe --tags --always` + (a tag when the commit has one, otherwise the short commit). `ARG VERSION` + has no default, and the build fails if the context carries `.git` and the + version still comes out empty, `dev` or `unknown`. - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that runs `script/cibuild` on push, and checks out the repo as its only other step. @@ -340,7 +357,7 @@ style conventions are in separate documents: — which is more dangerous than a short file with no secret patterns at all, because it reads as solved and stops anyone looking. Give every depth-independent pattern the `**/` prefix and leave only genuinely - root-anchored entries unprefixed: `.git`, and the repo's own host-built + root-anchored entries unprefixed: `.claude`, and the repo's own host-built binary, written `/myapp` and never `**/myapp`, which would also match `cmd/myapp/` and delete the package directory from the context. Matching is case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so @@ -365,12 +382,12 @@ style conventions are in separate documents: directory, so a repo running agents in subdirectories still ships `services/api/.claude/` and must add its own anchored entry there. -- **Excluding `.git` means `git describe` cannot run inside any build stage, and - it fails quietly there.** In a build stage there is no repository, so - `git describe` writes nothing to stdout, `-X main.Version=` comes out empty, - the binary reports no version at all, and the build still exits 0. Compute the - version on the host and thread it in as a build arg. `script/docker` and - `script/cibuild` do this, byte-identically across repos: +- **A plain `docker build .` of a clone stamps the tag or short commit**, + derived from the `.git` in the build context as the canonical `Dockerfile` + above shows. Without its failure check, a missing `git` or an unreadable + checkout would leave `-X main.Version=` empty and the build would still + exit 0. `script/docker` and `script/cibuild` pass the version they compute on + the host; it takes precedence. They do this byte-identically across repos: ```sh # Own line: a failing command substitution inside an argument does not @@ -387,7 +404,7 @@ style conventions are in separate documents: fallback is applied — a live check that fires on a build from an export with no `.git` and on a repository with no commits yet. Do not fold it into the substitution as `|| echo unknown`, which makes the guard unreachable. The - Dockerfile's side is `ARG VERSION=dev` in the stage that compiles, declared + Dockerfile's side is `ARG VERSION` in the stage that compiles, declared there because `ARG` is stage-scoped; passing `VERSION` to a repo whose Dockerfile declares no such `ARG` is ignored and costs nothing, which is why the scripts stay byte-identical. One consequence for CI: the standard diff --git a/script/cibuild b/script/cibuild index 688299f..d8d3200 100755 --- a/script/cibuild +++ b/script/cibuild @@ -16,9 +16,8 @@ main() { "$SCRIPT_DIR/check" # Own line: a failing command substitution inside an argument does # not trip `set -e`, so the inline form degrades silently to an - # empty constant. VERSION is computed here because .dockerignore - # excludes .git, so `git describe` in a build stage yields an empty - # version without failing. + # empty constant. The VERSION build argument takes precedence over + # the version a build stage derives from the .git in the context. version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" docker build --no-cache \ diff --git a/script/docker b/script/docker index c4688e8..07b626c 100755 --- a/script/docker +++ b/script/docker @@ -12,9 +12,8 @@ main() { cd "$ROOT" # Own line: a failing command substitution inside an argument does # not trip `set -e`, so the inline form degrades silently to an - # empty constant. VERSION is computed here because .dockerignore - # excludes .git, so `git describe` in a build stage yields an empty - # version without failing. + # empty constant. The VERSION build argument takes precedence over + # the version a build stage derives from the .git in the context. version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" docker build --no-cache \