diff --git a/.dockerignore b/.dockerignore index d948f00..84c3383 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,7 +10,10 @@ # context root. A host-built binary is the usual case, and it must be # written anchored: `/myapp`, never `**/myapp`. The prefixed form also # matches `cmd/myapp/`, which deletes the package directory from the -# context. +# context. In-repo agent scratch is the other case, for the same reason +# — with the caveat recorded at that entry: anchoring is exact only +# where agents run at the repo root, and a repo where they do not must +# add its own entries. # # Matching is case-sensitive, so `**/*.key` does not match # `certs/SERVER.KEY`, which is reachable on the case-insensitive @@ -31,9 +34,33 @@ # belong here because a host build otherwise drops them into the # context. -# Repository metadata: exactly one, at the context root. +# Repository metadata: exactly one, at the context root. Excluding it +# means `git describe` cannot run in any build stage, and it fails +# quietly there rather than erroring, so a version embedded that way +# comes out empty. Compute the version on the host and pass it in with +# `--build-arg VERSION=...`; see the version rule in REPO_POLICIES.md. .git +# In-repo agent scratch: a directory holding a full additional checkout +# of the repo for each in-flight agent. Anchored because it occurs +# exactly once *where agents run at the repo root*, which is the +# convention this file assumes; the `**/` form would also match any +# nested directory of that name and delete it from the build. +# +# KNOWN GAP, and it is not hypothetical: the directory is created in the +# agent's working directory. If agents in this repo run in +# subdirectories — a monorepo with a per-service agent, say — then +# `services/api/.claude/` is NOT excluded by the line below and still +# reaches the build context and the image, which is the exposure this +# entry exists to close. A repo in that shape adds its own anchored +# entries (`/services/api/.claude`), or `**/.claude` after confirming no +# legitimately named nested directory would be caught. +# +# Not case-folded, unlike the secret patterns below: tooling creates +# this directory in exactly one spelling, so a folded pattern would add +# no coverage. +.claude + # Environment files. `*.env` covers both the bare `.env` name (`*` matches # the empty string) and the `prod.env` convention. **/*.[eE][nN][vV] diff --git a/.gitignore b/.gitignore index c5a0882..3558b76 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,12 @@ Thumbs.db .vscode/ *.sublime-* +# Agent scratch (worktrees of this repo, created and destroyed by +# in-flight tooling). Unanchored: .gitignore patterns already match at +# every depth, so no prefix is wanted here. This is not a .dockerignore +# entry and must not be given a `**/` prefix on the way into one. +.claude/ + # Node node_modules/ diff --git a/TODO.md b/TODO.md index 2826015..c598ba2 100644 --- a/TODO.md +++ b/TODO.md @@ -21,6 +21,32 @@ fmt-check, and commit. # Completed Steps +- 2026-08-09: Kept in-repo agent scratch out of the Docker build context and out + of version control. `.claude/` holds one worktree — an entire additional + checkout of the repo — per in-flight agent, and under `COPY . .` all of it was + reaching the image: another session's unreviewed, sometimes uncommitted work, + inflating the context by a multiple of the repo and invalidating `COPY` for + reasons unrelated to the repo's own content. The `.dockerignore` entry is + root-anchored, because the directory occurs exactly once where agents run at + the repo root and the `**/` form additionally deletes any nested directory of + that name — with the residual gap that follows from anchoring (a monorepo + running agents in subdirectories still ships `services/api/.claude/`) stated + in the canonical `.dockerignore`, the policy and the existing-repo checklist, + since consuming repos receive the files rather than the tracker; the + `.gitignore` entry is unanchored, because `.gitignore` patterns already match + at every depth, and each file is written to its own semantics rather than + derived from the other. Also closed the consequence that ships broken + silently: excluding `.git` means `git describe` cannot run in any build stage + and yields an empty version without erroring, so `script/docker` and + `script/cibuild` now compute the version on the host and pass + `--build-arg VERSION`, and `REPO_POLICIES.md` states where `VERSION` comes + from instead of leaving the reader to fill the gap with `git describe` inside + the build. The two Go documents that carry the `GOLDFLAGS` pattern were + corrected in the same pass, from `:=` to `?=`, since a `$(shell git describe)` + evaluated inside a build stage is exactly the empty version this closes. + Verified by enumerating a probe image before, after, and against the + `**/`-prefixed form, with a positive control and the `CHECK_EPOCH` cache + verification re-run under the changed build context. - 2026-08-09: Closed the secret exposure in the canonical `.dockerignore`: a developer's local `.env`, `*.pem` or `*.key` was reaching the Docker build context under `COPY . .`, invisible to every git-based check because diff --git a/prompts/CODE_STYLEGUIDE_GO.md b/prompts/CODE_STYLEGUIDE_GO.md index d81ffff..be44685 100644 --- a/prompts/CODE_STYLEGUIDE_GO.md +++ b/prompts/CODE_STYLEGUIDE_GO.md @@ -49,7 +49,20 @@ last_modified: 2026-08-09 ``` ```make - VERSION := $(shell git describe --always --dirty) + # ?= 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 at all. The version is computed on + # the host by `script/docker` / `script/cibuild` and passed with + # `--build-arg VERSION=...`. If this repo's Dockerfile compiles by + # invoking make (`RUN make build`), `ARG VERSION` in that stage puts the + # value in the environment and `?=` defers to it. The canonical Go + # template in REPO_POLICIES.md instead runs `go build` directly with + # `-ldflags "... -X main.Version=${VERSION}"`, so there this Makefile is + # a host-only path — but it is still `?=`, because a repo that later + # moves the build behind make must not silently start shipping an empty + # version. See the git-describe rule in REPO_POLICIES.md. + VERSION ?= $(shell git describe --always --dirty) BUILDARCH := $(shell uname -m) GOLDFLAGS += -X main.Version=$(VERSION) diff --git a/prompts/EXISTING_REPO_CHECKLIST.md b/prompts/EXISTING_REPO_CHECKLIST.md index c32a497..83421f9 100644 --- a/prompts/EXISTING_REPO_CHECKLIST.md +++ b/prompts/EXISTING_REPO_CHECKLIST.md @@ -24,9 +24,14 @@ with your task. - [ ] `LICENSE` file exists and matches the README - [ ] `REPO_POLICIES.md` exists and version date is current — fetch from `https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md` -- [ ] `.gitignore` is comprehensive (OS, editor, language artifacts, secrets) — - fetch from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` - if missing +- [ ] `.gitignore` is comprehensive (OS, editor, agent scratch, language + artifacts, secrets) — fetch from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` if missing. + An existing repo usually has a hand-written one that is never re-fetched, + so check the entries rather than the file's presence: `.claude/` in + particular, unanchored, so agent worktrees cannot be committed by + accident. Do not give it a `**/` prefix — that is a `.dockerignore` form + and is wrong here. - [ ] `.editorconfig` exists — fetch from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig` - [ ] `Dockerfile` and `.dockerignore` exist (fetch `.dockerignore` from @@ -42,6 +47,26 @@ with your task. `/myapp`, never `**/myapp`, which would also match `cmd/myapp/`. An existing repo is where such a binary is likeliest to already be sitting in the build context, invisible to git. +- [ ] `.dockerignore` excludes `.claude`, root-anchored and with no `**/` + prefix. Agent worktrees are entire checkouts of the repo, so they inflate + the context by a multiple of it and can copy another session's unreviewed + work into an image layer. Confirm by enumerating the image, not by reading + the file — `.gitignore` hides these from `git status` too. +- [ ] **Do agents in this repo run anywhere other than the repo root?** The + scratch directory is created in the agent's working directory, so the + canonical anchored entry misses `services/api/.claude/` in a monorepo with + a per-service agent — it still reaches the build context and the image. An + existing repo is where such a layout already exists, so check it here + rather than assuming the canonical entry covers you: add anchored entries + for the subdirectories that have one (`/services/api/.claude`), or + `**/.claude` once you have confirmed no legitimately named nested + directory would be caught. +- [ ] 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`. No stage calls `git describe`: `.dockerignore` excludes + `.git`, so it yields an empty version without failing the build. A + tag-derived version additionally needs `fetch-depth: 0` on the CI checkout + step, which clones shallow and fetches no tags by default. - [ ] Every depth-independent pattern in `.dockerignore` carries a `**/` prefix; only genuinely root-anchored entries such as `.git` are unprefixed, and `.gitignore`'s patterns have not been transplanted unmodified. diff --git a/prompts/GO_HTTP_SERVER_CONVENTIONS.md b/prompts/GO_HTTP_SERVER_CONVENTIONS.md index 3a53b3c..06d44f0 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-02-22 +last_modified: 2026-08-09 --- This document defines the architectural patterns, design decisions, and @@ -991,7 +991,14 @@ func main() { Use ldflags to inject version information at build time: ```makefile -VERSION := $(shell git describe --tags --always) +# ?= 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. See the git-describe rule in REPO_POLICIES.md. +VERSION ?= $(shell git describe --tags --always) BUILDARCH := $(shell go env GOARCH) build: diff --git a/prompts/NEW_REPO_CHECKLIST.md b/prompts/NEW_REPO_CHECKLIST.md index 7965c10..bd6d7f0 100644 --- a/prompts/NEW_REPO_CHECKLIST.md +++ b/prompts/NEW_REPO_CHECKLIST.md @@ -35,7 +35,11 @@ Template files can be fetched from: - [ ] `.gitignore` — fetch from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore`, extend for - language-specific artifacts + language-specific artifacts. Extensions are written to `.gitignore`'s own + semantics, where an unanchored pattern already matches at every depth: + never add a `**/` prefix here, which is a `.dockerignore` form. The + canonical file already carries `.claude/` so agent worktrees cannot be + committed by accident. - [ ] `.editorconfig` — fetch from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig` - [ ] `Makefile` — fetch from @@ -59,7 +63,16 @@ Template files can be fetched from: `.gitignore`'s patterns: `.dockerignore` anchors an unprefixed pattern at the context root, so the copied form leaves `config/.env` in the build context while reading as solved. See the `.dockerignore` rule in - `REPO_POLICIES.md`. + `REPO_POLICIES.md`. The canonical file's `.claude` entry is anchored for + the same reason as a repo-root binary; leave it that way, but note it only + covers agents running at the repo root — if this repo will run them in + subdirectories, `services/api/.claude/` is not excluded and 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=...`. `ARG VERSION=dev` is + declared in the stage that compiles, and **no stage calls `git describe`** + — `.dockerignore` excludes `.git`, so it yields an empty version without + failing the build. - All Dockerfiles must run `make check` as a build step, and every stage containing a check-running `RUN` must declare `ARG CHECK_EPOCH` with the `RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard immediately below it — see the @@ -103,14 +116,30 @@ are thin shims calling them. Model scripts: - [ ] `script/projectname` — outputs the project name (used by `script/docker` for the image tag) - [ ] `script/docker` / `make docker` — builds Docker image, tagged via - `script/projectname` (byte-identical across repos); assigns - `epoch="$(date +%s%N)$$"` on its own line and passes - `--build-arg CHECK_EPOCH="$epoch"` -- [ ] `script/cibuild` — cd to repo root, assign `epoch="$(date +%s%N)$$"` on - its own line, then run `docker build --build-arg CHECK_EPOCH="$epoch" .` - (what CI runs). The build arg is mandatory: see the `CHECK_EPOCH` rule in - `REPO_POLICIES.md` for why each element is load-bearing. A bare - `docker build .` fails closed by design. + `script/projectname` (byte-identical across repos); carries the same three + version lines as `script/cibuild` below, and passes + `--build-arg CHECK_EPOCH="$epoch"` and `--build-arg VERSION="$version"` +- [ ] `script/cibuild` — cd to repo root, then, each on its own line: + + ```sh + epoch="$(date +%s%N)$$" + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build \ + --build-arg CHECK_EPOCH="$epoch" \ + --build-arg VERSION="$version" \ + . + ``` + + (what CI runs). Both build args are mandatory, and both assignments must be + on their own line: a failing command substitution inside an argument does + not trip `set -e`, so the inline form degrades silently to an empty + constant. The `[ -n "$version" ]` line is a live check that fires on an + export with no `.git` and on a repo with no commits — keep it, and do not + collapse it into `|| echo unknown`, which makes it unreachable. See the + `CHECK_EPOCH` and git-describe rules in `REPO_POLICIES.md` for why each + element is load-bearing. A bare `docker build .` fails closed by design. + - [ ] `script/precommit` — called by the pre-commit hook; runs `script/check` - [ ] `script/install-precommit` — installs the pre-commit hook that runs `script/precommit` diff --git a/prompts/REPO_POLICIES.md b/prompts/REPO_POLICIES.md index edc7826..19304f1 100644 --- a/prompts/REPO_POLICIES.md +++ b/prompts/REPO_POLICIES.md @@ -60,19 +60,21 @@ style conventions are in separate documents: prerequisite since nvm requires bash. yarn is then pinned via `corepack prepare yarn@ --activate`. Never install "latest" or "lts"; always exact versions. `script/cibuild` runs the CI build: it changes to the - repo root and runs `docker build --build-arg CHECK_EPOCH="$epoch" .`, where - `epoch` is a per-invocation nonce (see the `CHECK_EPOCH` rule below); the - Gitea workflow calls it. Four further scripts are our own extensions to the - standard: `script/check` runs `script/test`, `script/lint`, and - `script/fmt-check`; `script/precommit` is what the git pre-commit hook runs, - and it calls `script/check`; `script/install-precommit` installs the git - pre-commit hook (the `make hooks` target shims to it); and - `script/projectname` (literally that filename) simply outputs the project's - name. Scripts that need the name call `script/projectname` — e.g. - `script/docker` assembles its image tag from it — so those scripts stay - byte-identical across all repos. Repo-type-specific pre-commit extras (e.g. - `go mod tidy` verification in Go repos) belong in `script/precommit`, not in - the hook itself. Model scripts are at + repo root and runs + `docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`, + where `epoch` is a per-invocation nonce (see the `CHECK_EPOCH` rule below) and + `version` is computed on the host because `.git` is not in the build context + (see the git-describe rule below); the Gitea workflow calls it. Four further + scripts are our own extensions to the standard: `script/check` runs + `script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is + what the git pre-commit hook runs, and it calls `script/check`; + `script/install-precommit` installs the git pre-commit hook (the `make hooks` + target shims to it); and `script/projectname` (literally that filename) simply + outputs the project's name. Scripts that need the name call + `script/projectname` — e.g. `script/docker` assembles its image tag from it — + so those scripts stay byte-identical across all repos. Repo-type-specific + pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in + `script/precommit`, not in the hook itself. Model scripts are at `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/`. The README must document the provided scripts in an **Entrypoints** section (see the README requirements below). @@ -122,11 +124,18 @@ style conventions are in separate documents: ```sh epoch="$(date +%s%N)$$" - docker build --build-arg CHECK_EPOCH="$epoch" . + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build \ + --build-arg CHECK_EPOCH="$epoch" \ + --build-arg VERSION="$version" \ + . ``` - All four elements are load-bearing; none is optional, and each guards a - failure mode that otherwise fails green: + The `VERSION` lines are there for a different reason, covered by the + git-describe rule below; they are shown here so the two rules do not each + document half a command. All four `CHECK_EPOCH` elements are load-bearing; + none is optional, and each guards a failure mode that otherwise fails green: - `ARG` is stage-scoped, so a single declaration leaves the other check stages frozen while the fix reviews as complete. Declare it in every stage that runs checks, immediately above the first such `RUN`. @@ -197,6 +206,9 @@ style conventions are in separate documents: RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN echo "check epoch: ${CHECK_EPOCH}" && make test + # VERSION comes from the host via --build-arg; see the git-describe rule + # below. Never run `git describe` here: .dockerignore excludes .git, so + # it yields an empty version without failing the build. ARG VERSION=dev RUN CGO_ENABLED=0 go build -trimpath \ -ldflags="-s -w -X main.Version=${VERSION}" \ @@ -247,18 +259,24 @@ style conventions are in separate documents: each stage is invalidated at two independent points. The later `RUN`s in the same stage need no expansion of their own: they are already invalidated by their busted parent layer. + - `ARG VERSION=dev` is declared in the build stage, and its value is + supplied on the host by `script/docker` and `script/cibuild` via + `--build-arg VERSION=...`. The `dev` default is a placeholder for a local + build, not a source of truth. **No stage may call `git describe`**: + `.dockerignore` excludes `.git`, so it yields an empty version without + failing. See the git-describe rule further down. - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that runs `script/cibuild` (which runs - `docker build --build-arg CHECK_EPOCH="$epoch" .`) on push. The Dockerfile - runs `make check`, so a successful build implies all checks pass — but that - implication holds **only** because of the `CHECK_EPOCH` cache-bust described - above. Without it, an unchanged tree serves the check layer from cache and the - build reports a green it never earned. A bare `docker build .` fails closed by - design, on the `[ -n "$CHECK_EPOCH" ]` guard; always go through - `script/cibuild` or `script/docker`. Never accept a `script/cibuild` pass as - evidence without confirming it ran: a sub-second wall time, or `CACHED` on the - check layer, means nothing was executed. + `docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`) + on push. The Dockerfile runs `make check`, so a successful build implies all + checks pass — but that implication holds **only** because of the `CHECK_EPOCH` + cache-bust described above. Without it, an unchanged tree serves the check + layer from cache and the build reports a green it never earned. A bare + `docker build .` fails closed by design, on the `[ -n "$CHECK_EPOCH" ]` guard; + always go through `script/cibuild` or `script/docker`. Never accept a + `script/cibuild` pass as evidence without confirming it ran: a sub-second wall + time, or `CACHED` on the check layer, means nothing was executed. - Use platform-standard formatters: `black` for Python, `prettier` for JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with @@ -328,8 +346,10 @@ style conventions are in separate documents: must be in `.gitignore`. No exceptions. - `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`), - editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`. - Fetch the standard `.gitignore` from + editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`, + which holds one worktree — an entire additional checkout of the repo — per + in-flight agent), language build artifacts, and `node_modules/`. Fetch the + standard `.gitignore` from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up a new repo. These patterns are written to `.gitignore`'s own semantics, in which an unanchored pattern already matches at every depth. They are not a @@ -349,7 +369,8 @@ style conventions are in separate documents: depth-independent pattern the `**/` prefix — `**/node_modules`, `**/.DS_Store`, and the secret patterns in the canonical file, which are additionally case-folded per the rule below — and leave only genuinely - root-anchored entries such as `.git` unprefixed. The inverse move is equally + root-anchored entries unprefixed: `.git`, the in-repo agent scratch directory + `.claude`, and the repo's own host-built binary. The inverse move is equally wrong: never apply `**/` to `.gitignore`, where it is redundant and produces a file that is wrong in a way that looks careful. Each file is written to its own semantics; neither is derived from the other. Fetch the standard @@ -361,6 +382,33 @@ style conventions are in separate documents: anchored, `/myapp` and never `**/myapp`: the prefixed form also matches `cmd/myapp/` and deletes the package directory from the context. +- **In-repo agent scratch belongs in both files, written to each file's own + semantics.** `.claude/` holds one worktree per in-flight agent — an entire + additional checkout of the repo — so with `COPY . .` the build context + inflates by a multiple of the repo, and another session's unreviewed, + sometimes uncommitted work can be copied into an image layer. The directory is + also created and destroyed constantly, so it invalidates `COPY . .` for + reasons that have nothing to do with the repo's own content. In `.gitignore` + the entry is `.claude/`, unanchored, which already matches at every depth. In + `.dockerignore` it is `.claude`, anchored and with **no** `**/` prefix: the + directory occurs exactly once **where agents run at the repo root**, and the + prefixed form would also match any nested directory of that name and delete it + from the build. It is not case-folded the way the secret patterns are, because + tooling creates it in exactly one spelling, so a folded pattern would add no + coverage. + + **Known gap that comes with the anchored form.** The directory is created in + the agent's working directory, so the "exactly once, at the root" premise is + a property of how agents are run and not of the tooling. Where agents run in + subdirectories — a monorepo with a per-service agent is the ordinary case — + `services/api/.claude/` is **not** excluded by the canonical entry and still + reaches the build context and the image, which is the exposure the entry + exists to close. A repo in that shape adds its own anchored entries + (`/services/api/.claude`), or `**/.claude` once it has confirmed no + legitimately named nested directory would be caught. This is stated in the + canonical `.dockerignore` itself, since that file is what consuming repos + receive. + - **`.dockerignore` matching is case-sensitive, so cover capitalisation with character classes rather than by doubling patterns.** `**/*.key` does not match `certs/SERVER.KEY`, which is reachable on the case-insensitive @@ -390,6 +438,49 @@ style conventions are in separate documents: bytes, and BuildKit transfers only the delta from the previous build, so the reported size describes the transfer and not the contents of the image. +- **Excluding `.git` means `git describe` cannot run inside any build stage, and + it fails quietly there.** The `GOLDFLAGS` version-embedding pattern assumes + `.git` is present; in a build stage there is no repository, so `git describe` + writes nothing to stdout and the `-X main.Version=` value comes out **empty** + rather than erroring. The binary then 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: + + ```sh + # Assign on its own line: a failing command substitution inside an + # argument does not trip `set -e`, so the inline form degrades to an + # empty constant — the same silent-empty failure this rule is about. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build \ + --build-arg CHECK_EPOCH="$epoch" \ + --build-arg VERSION="$version" \ + . + ``` + + `--always` makes an untagged repo yield the abbreviated commit hash instead + of failing. `|| true` keeps a failing `git describe` from tripping `set -e` + and leaves the value empty, so the `[ -n "$version" ]` line is the single + place the fallback is applied — and it is a **live** check, not defence in + depth: it fires on a build from an export with no `.git`, and on a + repository with no commits yet. Do not fold the fallback into the + substitution as `|| echo unknown`; that makes the guard unreachable, and a + guard that cannot fire is indistinguishable from one that works to everyone + who copies it. The result is non-empty by construction either way, which is + the point: an empty version reads as a successful one, while `unknown` is + visibly wrong. The Dockerfile's side is `ARG VERSION=dev` in the stage that + compiles, declared there and not inherited, because `ARG` is stage-scoped + exactly as `CHECK_EPOCH` is. Passing `VERSION` to a repo whose Dockerfile + declares no such `ARG` is silently ignored by BuildKit and costs nothing, + which is why the scripts stay byte-identical rather than growing a per-repo + variant. + + One consequence for CI: the standard checkout action clones shallow and + fetches no tags, so `git describe --tags` there falls back to a bare commit + hash. A repo that embeds a tag-derived version must set `fetch-depth: 0` on + its checkout step; a repo that does not embed a version needs no change. + - **No build artifacts in version control.** Code-derived data (compiled bundles, minified output, generated assets) must never be committed to the repository if it can be avoided. The build process (e.g. Dockerfile, Makefile) diff --git a/script/cibuild b/script/cibuild index 786533a..4fbd751 100755 --- a/script/cibuild +++ b/script/cibuild @@ -14,7 +14,20 @@ main() { # nonce to an empty constant. `$$` is required because busybox `date` # drops %N without erroring. epoch="$(date +%s%N)$$" - docker build --build-arg CHECK_EPOCH="$epoch" . + # VERSION must be computed here, on the host: .dockerignore excludes + # .git, so `git describe` cannot run in any build stage and fails + # quietly there rather than erroring. Same own-line discipline as the + # epoch. `|| true` keeps a failing describe from tripping `set -e` + # and leaves the value empty; the guard below is then the single + # place the fallback is applied, and it does fire — on an export with + # no .git, or a repo with no commits yet. `unknown` is visibly wrong + # in a binary in a way that an empty version is not. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build \ + --build-arg CHECK_EPOCH="$epoch" \ + --build-arg VERSION="$version" \ + . } main "$@" diff --git a/script/docker b/script/docker index ac8897d..f9ebf9a 100755 --- a/script/docker +++ b/script/docker @@ -16,7 +16,19 @@ main() { # nonce to an empty constant. `$$` is required because busybox `date` # drops %N without erroring. epoch="$(date +%s%N)$$" - docker build --build-arg CHECK_EPOCH="$epoch" \ + # VERSION must be computed here, on the host: .dockerignore excludes + # .git, so `git describe` cannot run in any build stage and fails + # quietly there rather than erroring. Same own-line discipline as the + # epoch. `|| true` keeps a failing describe from tripping `set -e` + # and leaves the value empty; the guard below is then the single + # place the fallback is applied, and it does fire — on an export with + # no .git, or a repo with no commits yet. `unknown` is visibly wrong + # in a binary in a way that an empty version is not. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build \ + --build-arg CHECK_EPOCH="$epoch" \ + --build-arg VERSION="$version" \ -t "$("$SCRIPT_DIR/projectname")" . }