From e3f79c49daf7cc5b29134427db4ae7298a4a63c0 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 16:50:31 +0200 Subject: [PATCH] Keep Go's build cache in check builds (closes #113) The script/ builds pass a new CACHEBUST build argument on every run in place of --no-cache, which also emptied Go's build cache mount. Every Dockerfile stage declares ARG CACHEBUST first, so every RUN step still reruns on an unchanged tree. The canonical Go Dockerfile mounts only Go's build cache, on the steps that compile, so compiled packages survive between check builds; both go test runs carry -count=1, so no test result is reused. golangci-lint's own cache and the module cache are not kept. A repository re-vendors the scripts and adds the ARG lines in one commit: a stage without the line is served from cache with no warning. Unverified: not yet tried on the shared CI runner. Model: opus-5-5 --- Dockerfile | 6 ++ README.md | 24 +++--- TODO.md | 10 +++ prompts/CODE_STYLEGUIDE_GO.md | 9 +- prompts/EXISTING_REPO_CHECKLIST.md | 20 +++-- prompts/NEW_REPO_CHECKLIST.md | 42 ++++++---- prompts/REPO_POLICIES.md | 130 ++++++++++++++++++----------- script/cibuild | 10 ++- script/docker | 9 +- script/lint | 11 ++- script/test | 10 ++- 11 files changed, 184 insertions(+), 97 deletions(-) diff --git a/Dockerfile b/Dockerfile index e37890c..8e55928 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,8 +2,12 @@ # lint` or `script/lint`, which are themselves a docker build and would # recurse into a daemon that does not exist in a build step. # +# Every stage declares ARG CACHEBUST first: script/ passes a new value on +# every build, which reruns every RUN step after it. +# # node 22-alpine, 2026-02-22 FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS lint +ARG CACHEBUST WORKDIR /app @@ -19,6 +23,7 @@ RUN yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always # # node 22-alpine, 2026-02-22 FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS test +ARG CACHEBUST WORKDIR /app @@ -38,6 +43,7 @@ RUN echo "No tests defined." # # node 22-alpine, 2026-02-22 FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 +ARG CACHEBUST WORKDIR /app diff --git a/README.md b/README.md index 867e9d2..f8dc34c 100644 --- a/README.md +++ b/README.md @@ -117,25 +117,25 @@ alpine. We provide: - `script/projectname` — output the project name (our own extension); used by `script/docker` for the image tag - `script/test` — - `docker build --no-cache --target test --output type=cacheonly .`, building - the `test` phase of the `Dockerfile` without writing an image (no tests - defined here) + `docker build --build-arg CACHEBUST="$(date +%s)-$$" --target test --output type=cacheonly .`, + building the `test` phase of the `Dockerfile` without writing an image (no + tests defined here) - `script/lint` — - `docker build --no-cache --target lint --output type=cacheonly .`, building - the `lint` phase, which runs prettier over the markdown files, without writing - an image + `docker build --build-arg CACHEBUST="$(date +%s)-$$" --target lint --output type=cacheonly .`, + building the `lint` phase, which runs prettier over the markdown files, + without writing an image - `script/fmt` — format all markdown files with prettier (writes; native, not in a container) - `script/fmt-check` — check formatting (read-only; native) - `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own extension); builds no image of its own - `script/docker` — - `docker build --no-cache --build-arg VERSION="$version" -t prompts .`, the tag - coming from `script/projectname` (byte-identical across repos) + `docker build --build-arg CACHEBUST="$(date +%s)-$$" --build-arg VERSION="$version" -t prompts .`, + the tag coming from `script/projectname` (byte-identical across repos) - `script/cibuild` — cd to the repo root, run `script/bootstrap`, run `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 + `docker build --build-arg CACHEBUST="$(date +%s)-$$" --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) - `script/precommit` — run by the git pre-commit hook (our own extension); calls `script/check` @@ -144,6 +144,10 @@ alpine. We provide: `make hooks` installs the pre-commit hook that runs `script/precommit`. +Every stage of the `Dockerfile` declares `ARG CACHEBUST` as its first line after +`FROM`. The `script/` builds pass a new `CACHEBUST` value on every build, which +reruns every `RUN` step and keeps Go's build cache; `--no-cache` would empty it. + ## Rationale LLM prompts, especially development policies, benefit from version control and a diff --git a/TODO.md b/TODO.md index 2eca563..aede55d 100644 --- a/TODO.md +++ b/TODO.md @@ -21,6 +21,16 @@ fmt-check, and commit. # Completed Steps +- 2026-10-15: Check builds now keep Go's build cache between runs (issue 113), + on sneak's ruling there that Go's compile cache may survive. `script/lint`, + `script/test`, `script/cibuild` and `script/docker` pass a new `CACHEBUST` + build argument on every build in place of `--no-cache`, which also emptied the + cache mounts. Every `Dockerfile` stage declares `ARG CACHEBUST` first, so + every `RUN` step still runs on every check build. The canonical Go + `Dockerfile` mounts Go's build cache on the steps that compile and runs both + `go test` runs with `-count=1`, so no test result is reused. A stage without + the `ARG` line is served from cache, so repositories re-vendor the scripts and + the `ARG` lines in one commit. Not yet tried on the shared runner. - 2026-10-08: The canonical `script/bootstrap` now uses the installed node only when its major version is the pinned one (issue 118). Otherwise, as when node is missing, it installs the pinned node under nvm and installs yarn and the diff --git a/prompts/CODE_STYLEGUIDE_GO.md b/prompts/CODE_STYLEGUIDE_GO.md index fd95f55..ded1255 100644 --- a/prompts/CODE_STYLEGUIDE_GO.md +++ b/prompts/CODE_STYLEGUIDE_GO.md @@ -1,6 +1,6 @@ --- title: Code Styleguide — Go -last_modified: 2026-10-06 +last_modified: 2026-10-15 --- 1. Try to hard wrap long lines at 77 characters or less. @@ -111,9 +111,10 @@ last_modified: 2026-10-06 linting. It carries the lint and test phases, and the final stage depends on both, so a build makes sure the code is in an able-to-be-compiled state, linted, and its tests run. Go through `script/cibuild` or `script/docker` - rather than a bare `docker build .`: they pass `--no-cache`, without which - an unchanged tree serves the gate layers from cache and the build reports a - green it never ran. + rather than a bare `docker build .`: they pass a new `CACHEBUST` value on + every build, which reruns every `RUN` step and keeps Go's build cache; + `--no-cache` would empty it. Without a new value an unchanged tree serves + the gate layers from cache and the build reports a green it never ran. 1. Every repo must have a `Makefile`. See [Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md) diff --git a/prompts/EXISTING_REPO_CHECKLIST.md b/prompts/EXISTING_REPO_CHECKLIST.md index f984a75..d92f7ec 100644 --- a/prompts/EXISTING_REPO_CHECKLIST.md +++ b/prompts/EXISTING_REPO_CHECKLIST.md @@ -1,6 +1,6 @@ --- title: Existing Repo Checklist -last_modified: 2026-10-08 +last_modified: 2026-10-15 --- Use this checklist when beginning work in a repo that may not yet conform to our @@ -49,6 +49,15 @@ with your task. `--target` names it, so a lost `COPY --from=` edge leaves `docker build .` passing while the gate never runs. Confirm by planting a violation, not by reading the file. +- [ ] Every stage declares `ARG CACHEBUST` as its first line after `FROM`. The + `script/` builds pass a new `CACHEBUST` value on every build, which reruns + every `RUN` step and keeps Go's build cache; `--no-cache` would empty it. + A stage without the `ARG` line is served from cache on an unchanged tree, + so the scripts and the `ARG` lines are re-vendored in one commit. +- [ ] Go: the steps that compile (`golangci-lint run`, `go test`, `go build`) + mount Go's build cache (`--mount=type=cache,target=/root/.cache/go-build`) + and nothing else, and both `go test` runs carry `-count=1`, as in the + canonical Go `Dockerfile` in `REPO_POLICIES.md` - [ ] The gate phases invoke their tools directly, never through `make lint` or `script/test` — those are themselves a `docker build` and would recurse inside a build step @@ -133,7 +142,7 @@ with your task. - [ ] README has an **Entrypoints** section documenting the `script/` entrypoints and linking the standard - [ ] `script/lint` and `script/test` each run - `docker build --no-cache --target --output type=cacheonly .`, + `docker build --build-arg CACHEBUST="$(date +%s)-$$" --target --output type=cacheonly .`, which builds their phase by name and writes no image, and no host invocation anywhere in the repo can produce a lint verdict — grep for the linter's own name across `script/`, the `Makefile` and CI config, not just @@ -146,7 +155,8 @@ with your task. `script/cibuild` tag theirs. A build that writes an untagged image leaves one behind on every run, on every host and CI runner. - [ ] `script/cibuild` runs `script/bootstrap` before `script/check`, and builds - the image with `--no-cache`. Without the bootstrap the CI run dies in + the image with a new `CACHEBUST` value on every build, like every + `docker build` in `script/`. Without the bootstrap the CI run dies in `script/fmt-check`, which runs the formatter on the host and finds nothing installed. - [ ] `script/fmt` and `script/fmt-check` pick their `yarn` with the same test @@ -211,8 +221,8 @@ with your task. - [ ] `script/cibuild` succeeds in a fresh clone on a host carrying what CI has (the runner image `docker.gitea.com/runner-images:ubuntu-latest`: docker, git and node 24, with no yarn on `PATH`), and demonstrably executed the - checks — a sub-second build, or `CACHED` on a gate layer, means nothing - ran + checks — a sub-second build, or `CACHED` on a gate's `RUN` step, means + nothing ran - [ ] A planted lint violation fails both `make lint` and a plain `docker build .`; revert it afterwards - [ ] Commit and merge fixes before starting your actual task diff --git a/prompts/NEW_REPO_CHECKLIST.md b/prompts/NEW_REPO_CHECKLIST.md index 6561aef..4d5d56c 100644 --- a/prompts/NEW_REPO_CHECKLIST.md +++ b/prompts/NEW_REPO_CHECKLIST.md @@ -1,6 +1,6 @@ --- title: New Repo Checklist -last_modified: 2026-10-08 +last_modified: 2026-10-15 --- Use this checklist when creating a new repository from scratch. Follow the steps @@ -108,6 +108,16 @@ Template files can be fetched from: stage carries a `COPY --from=` of a harmless file from each so the image cannot be built unless both passed. Keep the final stage last: a stage nothing depends on is built only when `--target` names it. + - Every stage declares `ARG CACHEBUST` as its first line after `FROM`. The + `script/` builds pass a new `CACHEBUST` value on every build, which reruns + every `RUN` step and keeps Go's build cache; `--no-cache` would empty it. + A stage without the `ARG` line is served from cache on an unchanged tree, + so the scripts and every stage's `ARG CACHEBUST` line are added in the + same commit. + - Go: the steps that compile (`golangci-lint run`, `go test`, `go build`) + mount Go's build cache (`--mount=type=cache,target=/root/.cache/go-build`) + and nothing else, and both `go test` runs carry `-count=1`, as in the + canonical Go `Dockerfile` in `REPO_POLICIES.md` - Server: the final stage builds and runs the application - Non-server: the final stage brings up the dev environment - Image pinned by sha256 hash with version/date comment @@ -145,13 +155,13 @@ are thin shims calling them. Model scripts: - [ ] `script/setup` / `make setup` — readies a fresh clone: runs `bootstrap`, then `install-precommit`, plus repo-specific init - [ ] `script/test` / `make test` — - `docker build --no-cache --target test --output type=cacheonly .`, which - writes no image; the phase runs real tests, not a no-op (90-second + `docker build --build-arg CACHEBUST="$(date +%s)-$$" --target test --output type=cacheonly .`, + which writes no image; the phase runs real tests, not a no-op (90-second timeout, 60-second hard cap on wall time) - [ ] `script/lint` / `make lint` — - `docker build --no-cache --target lint --output type=cacheonly .`, which - writes no image. No lint verdict may come from a host invocation of the - linter. + `docker build --build-arg CACHEBUST="$(date +%s)-$$" --target lint --output type=cacheonly .`, + which writes no image. No lint verdict may come from a host invocation of + the linter. - [ ] `script/fmt` / `make fmt` — formats code (writes; native, never in a container) - [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only; @@ -161,16 +171,16 @@ 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); `--no-cache`, plus the - version as a build arg + `script/projectname` (byte-identical across repos); a new `CACHEBUST` + value on every build, plus the version as a build arg - [ ] `script/cibuild` — cd to repo root, run `script/bootstrap`, run `script/check`, then - `docker build --no-cache --build-arg VERSION="$version" -t "$tag" .` (what - CI runs), with `$version` from `git describe --tags --always --dirty` - (`unknown` if empty) and `$tag` from `script/projectname`, each assigned - on its own line before the build. The bootstrap is required: CI checks out - and runs this alone, and `script/fmt-check` runs the formatter on the - host. + `docker build --build-arg CACHEBUST="$(date +%s)-$$" --build-arg VERSION="$version" -t "$tag" .` + (what CI runs), with `$version` from + `git describe --tags --always --dirty` (`unknown` if empty) and `$tag` + from `script/projectname`, each assigned on its own line before the build. + The bootstrap is required: CI checks out and runs this alone, and + `script/fmt-check` runs the formatter on the host. - [ ] `script/fmt` and `script/fmt-check` pick their `yarn` with the same test as `script/bootstrap`: the `yarn` on `PATH` when the node on `PATH` has the pinned major version, and otherwise yarn under the pinned node, with @@ -195,8 +205,8 @@ are thin shims calling them. Model scripts: - [ ] `script/cibuild` succeeds in a fresh clone on a host carrying what CI has (the runner image `docker.gitea.com/runner-images:ubuntu-latest`: docker, git and node 24, with no yarn on `PATH`), and demonstrably executed the - checks — a sub-second build, or `CACHED` on a gate layer, means nothing - ran + checks — a sub-second build, or `CACHED` on a gate's `RUN` step, means + nothing ran - [ ] Plant a lint violation and confirm both `make lint` and a plain `docker build .` fail on it; revert. A plain build that passes proves the final stage is missing its `COPY --from=` edge to the gate phases. diff --git a/prompts/REPO_POLICIES.md b/prompts/REPO_POLICIES.md index 17eaf34..5b24098 100644 --- a/prompts/REPO_POLICIES.md +++ b/prompts/REPO_POLICIES.md @@ -1,6 +1,6 @@ --- title: Repository Policies -last_modified: 2026-10-08 +last_modified: 2026-10-15 --- This document covers repository structure, tooling, and workflow standards. Code @@ -123,8 +123,10 @@ style conventions are in separate documents: and nothing else: ```sh - docker build --no-cache --target lint --output type=cacheonly . - docker build --no-cache --target test --output type=cacheonly . + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ + --target lint --output type=cacheonly . + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ + --target test --output type=cacheonly . ``` **A stage that is not the last one in the file is built only when the final @@ -137,12 +139,12 @@ style conventions are in separate documents: **The gate builds write no image.** With `--output type=cacheonly` the phase runs and a failing step fails the build, but the result is not exported. Nothing uses those images, and writing one out is slow: a Go test phase's - image holds the toolchain and every compiled package. A build given neither - `--output` nor `-t` writes an untagged image and leaves it dangling, on - every developer host and every CI runner. `script/cibuild` and - `script/docker` build the image that ships and tag it, so each build - replaces the previous image; each assigns the tag on its own line before the - build, so `set -e` stops it where `script/projectname` fails. + image holds the toolchain. A build given neither `--output` nor `-t` writes + an untagged image and leaves it dangling, on every developer host and every + CI runner. `script/cibuild` and `script/docker` build the image that ships + and tag it, so each build replaces the previous image; each assigns the tag + on its own line before the build, so `set -e` stops it where + `script/projectname` fails. Inside a phase the tool is invoked directly — `golangci-lint`, `go test`, `eslint`, `prettier` — never through `make lint` or `script/test`, which are @@ -157,22 +159,33 @@ style conventions are in separate documents: one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs exit non-zero with `parallel golangci-lint is running` — a status a caller cannot tell from real findings. Both have produced wrong verdicts in this - org, in both directions. A container has its own cache, its own `TMPDIR` and - a digest-pinned binary, so neither is reachable. + org, in both directions. A container has its own golangci-lint cache, its + own `TMPDIR` and a digest-pinned binary, so neither is reachable. -- **Any build that runs checks is built with `--no-cache`.** Docker invalidates - a `COPY` layer only when the copied content changes, so on an unchanged tree - the check `RUN` is served from cache, nothing executes, and the build still - exits 0. Every `docker build` in `script/` therefore passes `--no-cache`: - `script/lint`, `script/test`, `script/cibuild` and `script/docker` are the - four, and there is no fifth — `script/check` runs the two gate phases and - `script/fmt-check`, and builds no image of its own. A bare `docker build .` is - not evidence that anything ran: a sub-second build reporting success is a - cache hit, not a result. Never invalidate by pruning — `docker builder prune` - and friends destroy a build cache shared with every other build on the host. - When a check is added or changed, prove it works by planting a defect it must - catch and watching the run fail on it, then revert the defect. A green run - alone shows neither that the check ran nor that it covers what it should. +- **Any build that runs checks is given a new `CACHEBUST` value.** Docker + invalidates a `COPY` layer only when the copied content changes, so on an + unchanged tree the check `RUN` is served from cache, nothing executes, and the + build still exits 0. Every `docker build` in `script/` therefore passes + `--build-arg CACHEBUST="$(date +%s)-$$"`, the time and the script's process + id, so no two builds share a value, not even the three in one + `script/cibuild`. `script/lint`, `script/test`, `script/cibuild` and + `script/docker` are the four, and there is no fifth — `script/check` runs the + two gate phases and `script/fmt-check`, and builds no image of its own. Every + stage of the `Dockerfile` declares `ARG CACHEBUST` as its first line after + `FROM`, and a new value reruns every `RUN` step after that line; `COPY` steps + are compared by content and need nothing. A stage without the `ARG` line is + served from cache on an unchanged tree, so a repository re-vendors the scripts + and adds the `ARG` lines in one commit. The scripts do not pass `--no-cache`: + it reruns every step too, but it also empties every cache mount, and Go's + build cache, which the canonical Go `Dockerfile` below mounts so that compiled + packages survive between check builds, would start empty every time. A bare + `docker build .` is not evidence that anything ran: a sub-second build + reporting success is a cache hit, not a result. Never invalidate by pruning — + `docker builder prune` and friends destroy a build cache shared with every + other build on the host. When a check is added or changed, prove it works by + planting a defect it must catch and watching the run fail on it, then revert + the defect. A green run alone shows neither that the check ran nor that it + covers what it should. - **The gate phases are separate stages, and the build stage depends on both.** The lint phase is based on the `golangci/golangci-lint` image (pinned by @@ -181,32 +194,42 @@ style conventions are in separate documents: `Dockerfile`: ```dockerfile + # Every stage declares ARG CACHEBUST first: script/ passes a new value + # on every build, which reruns every RUN step after it. The steps that + # compile mount Go's build cache, which survives from build to build. + # Lint phase # golangci/golangci-lint:v2.x.x, YYYY-MM-DD FROM golangci/golangci-lint@sha256:... AS lint + ARG CACHEBUST WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . - RUN golangci-lint run --config .golangci.yml ./... + RUN --mount=type=cache,target=/root/.cache/go-build \ + golangci-lint run --config .golangci.yml ./... # Test phase. -race needs cgo and so a C compiler, which the Debian Go - # image ships and the alpine one does not. + # image ships and the alpine one does not. -count=1 because the mounted + # cache also holds test results, which go test would otherwise replay. # golang:1.x, YYYY-MM-DD FROM golang@sha256:... AS test + ARG CACHEBUST WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . - RUN go test -timeout 90s -race -cover ./... || \ + RUN --mount=type=cache,target=/root/.cache/go-build \ + go test -count=1 -timeout 90s -race -cover ./... || \ { echo "--- Rerunning with -v for details ---"; \ - go test -timeout 90s -race -v ./...; exit 1; } + go test -count=1 -timeout 90s -race -v ./...; exit 1; } # Build stage. Nothing is wanted from either phase above; the copies # are what make BuildKit build them first, so this stage cannot run # unless lint and test passed. # golang:1.x-alpine, YYYY-MM-DD FROM golang@sha256:... AS builder + ARG CACHEBUST COPY --from=lint /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null RUN apk add --no-cache git @@ -222,7 +245,8 @@ style conventions are in separate documents: # .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)}"; \ + RUN --mount=type=cache,target=/root/.cache/go-build \ + VERSION="${VERSION:-$(git describe --tags --always)}"; \ if [ -e .git ]; then \ case "$VERSION" in ""|dev|unknown) \ echo "version is '$VERSION' although .git is present" >&2; \ @@ -235,6 +259,7 @@ style conventions are in separate documents: # Runtime stage, and the last one FROM alpine@sha256:... + ARG CACHEBUST COPY --from=builder /app /usr/local/bin/app ENTRYPOINT ["app"] ``` @@ -249,6 +274,16 @@ style conventions are in separate documents: - Keep the runtime stage last, and if you add a stage after it, give it the same two copies. A plain `docker build .` builds the last stage's chain and nothing else. + - Only Go's build cache is mounted (`/root/.cache/go-build`, where Go keeps + it for root), and only on the steps that compile: `golangci-lint run`, + both `go test` runs and `go build`. It keeps compiled packages between + check builds, and Go reuses one only when all its inputs are unchanged, so + it saves the compile without skipping a check. Nothing else is kept: + golangci-lint's own cache would replay stored findings in place of a lint + run, and Go builds from whatever files a kept module cache holds without + checking them again (only `go mod verify` does), so a file changed there + would go unnoticed. Go's build cache also holds test results, which is why + both `go test` runs carry `-count=1`. - If the project uses `//go:embed` directives that reference build artifacts (e.g. a web frontend compiled in a separate stage), the lint phase must create placeholder files so the embed directives resolve. Example: @@ -309,18 +344,18 @@ style conventions are in separate documents: `script/cibuild` bootstraps, runs the gate phases, and then builds the image, so a successful run means every check passed; a bare `docker build .` does not carry the same guarantee, because its gate phases may come from the cache. The - image build is uncached and so runs the gate phases a second time. That is the - price of the rule above, and it is worth paying: the image that ships is built - from a run of its own gates rather than from a cache entry. The `check` job - sets `timeout-minutes: 20`, so a hung build frees the shared runner after 20 - minutes. That allows for the three Docker builds described above (the test - phase, the lint phase, then the image), each held to the 5-minute Docker build - limit below, plus the bootstrap. A separate workflow limited to `main` by a - `branches` list under `on: push` cannot be checked by review: to try a change - to it, add the feature branch to that list and push, then remove the branch - from the list again before merging. Keep any job in it that publishes behind - `if: github.ref_name == 'main'`, so the run from the feature branch publishes - nothing. + image build gets a `CACHEBUST` value of its own and so runs the gate phases a + second time. That is the price of the rule above, and it is worth paying: the + image that ships is built from a run of its own gates rather than from a cache + entry. The `check` job sets `timeout-minutes: 20`, so a hung build frees the + shared runner after 20 minutes. That allows for the three Docker builds + described above (the test phase, the lint phase, then the image), each held to + the 5-minute Docker build limit below, plus the bootstrap. A separate workflow + limited to `main` by a `branches` list under `on: push` cannot be checked by + review: to try a change to it, add the feature branch to that list and push, + then remove the branch from the list again before merging. Keep any job in it + that publishes behind `if: github.ref_name == 'main'`, so the run from the + feature branch publishes nothing. - Use platform-standard formatters: `black` for Python, `prettier` for JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with @@ -380,12 +415,11 @@ style conventions are in separate documents: That cache is Go's own, separate from Docker's layer cache. Go stores a passing result in its cache directory (`GOCACHE`), and when the same tests run again on unchanged code it prints that result, marked `(cached)`, - without running them. That matters on a developer's machine, where this - target runs and the directory lasts from one run to the next. The `test` - phase of the `Dockerfile` needs no `-count=1`: its base image holds no - result for this repo's tests and nothing before its `go test` step runs a - test, so there is nothing to replay. `--no-cache` (above) is what makes that - step run on an unchanged tree. + without running them. The directory lasts from one run to the next on a + developer's machine, where this target runs, and in the `test` phase of the + `Dockerfile`, which mounts it to keep compiled packages between check + builds, so the phase's `go test` runs carry `-count=1` too. A new + `CACHEBUST` value (above) is what makes that step run on an unchanged tree. Python example: @@ -472,7 +506,7 @@ style conventions are in separate documents: version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" tag="$(script/projectname)" - docker build --no-cache \ + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ --build-arg VERSION="$version" \ -t "$tag" . ``` diff --git a/script/cibuild b/script/cibuild index f00646a..66fcca0 100755 --- a/script/cibuild +++ b/script/cibuild @@ -2,9 +2,11 @@ # script/cibuild: run the CI build. It bootstraps first: a CI runner # checks out and runs this and nothing else, and script/fmt-check runs # the formatter on the host, which a pristine checkout cannot do. -# --no-cache for the same reason as script/docker: the gate phases the -# final stage depends on are RUN steps, and a cached one is a check that -# did not run. +# A new CACHEBUST value reruns every RUN step, since a cached gate phase +# is a check that did not run; unlike --no-cache, it keeps Go's build cache. +# This relies on every stage of the Dockerfile declaring ARG CACHEBUST +# first; a stage without that line is served from cache on an unchanged +# tree, with no warning. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -22,7 +24,7 @@ main() { version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" tag="$("$SCRIPT_DIR/projectname")" - docker build --no-cache \ + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ --build-arg VERSION="$version" \ -t "$tag" . } diff --git a/script/docker b/script/docker index 5b7dda8..360037a 100755 --- a/script/docker +++ b/script/docker @@ -1,8 +1,11 @@ #!/bin/sh # script/docker: build the Docker image tagged with the project name. # Identical in all repos; the tag comes from script/projectname. -# --no-cache because the gate phases the final stage depends on are RUN -# steps, and a cached one is a check that did not run. +# A new CACHEBUST value reruns every RUN step, since a cached gate phase +# is a check that did not run; unlike --no-cache, it keeps Go's build cache. +# This relies on every stage of the Dockerfile declaring ARG CACHEBUST +# first; a stage without that line is served from cache on an unchanged +# tree, with no warning. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -18,7 +21,7 @@ main() { version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" tag="$("$SCRIPT_DIR/projectname")" - docker build --no-cache \ + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ --build-arg VERSION="$version" \ -t "$tag" . } diff --git a/script/lint b/script/lint index a1cf92f..c4c459e 100755 --- a/script/lint +++ b/script/lint @@ -5,9 +5,12 @@ # make its answer untrustworthy. # # The phase is not the last stage in the file, so it is built only when -# --target names it. --no-cache because a cached lint layer is a lint -# that did not run. --output type=cacheonly writes no image, since -# nothing uses one. +# --target names it. A new CACHEBUST value reruns every RUN step, since +# a cached lint layer is a lint that did not run; unlike --no-cache, it +# keeps Go's build cache. This relies on every stage of the Dockerfile +# declaring ARG CACHEBUST first; a stage without that line is served +# from cache on an unchanged tree, with no warning. +# --output type=cacheonly writes no image, since nothing uses one. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -15,7 +18,7 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build --no-cache \ + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ --target lint \ --output type=cacheonly . } diff --git a/script/test b/script/test index 1b17d40..a56c4a1 100755 --- a/script/test +++ b/script/test @@ -2,8 +2,12 @@ # script/test: run the test suite. Testing is a phase of the Dockerfile # and this builds that phase alone, on the same terms as script/lint: # --target because a phase that is not the last stage is built only when -# named, and --no-cache because a cached test layer is a test that did -# not run. --output type=cacheonly writes no image, since nothing uses one. +# named, and a new CACHEBUST value because a cached test layer is a test +# that did not run; unlike --no-cache, it keeps Go's build cache. This +# relies on every stage of the Dockerfile declaring ARG CACHEBUST first; +# a stage without that line is served from cache on an unchanged tree, +# with no warning. --output type=cacheonly writes no image, since +# nothing uses one. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -11,7 +15,7 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build --no-cache \ + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ --target test \ --output type=cacheonly . }