10 Commits
Author SHA1 Message Date
clawbot e3f79c49da Keep Go's build cache in check builds (closes #113)
check / check (push) Waiting to run
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
2026-10-15 16:50:31 +02:00
clawbot f9e23ccae3 Install the pinned node when the installed one is another major version (closes #118)
check / check (push) Canceled after 0s
script/bootstrap used whatever node was installed, so on the CI runner
image, which ships node 24, the pinned yarn 1.22.22 printed the
deprecation warning DEP0169 on every bootstrap. It now uses the
installed node only when its major version is the pinned one, and
otherwise installs the pinned node with nvm, as it already did when node
was missing, with yarn and the packages under it. It prints the node
version it uses. Only the major is compared because the Dockerfile
stages use a node 22 alpine image that nvm cannot replace. script/fmt
and script/fmt-check carry the same test to pick their yarn: the yarn on
PATH, or yarn under the pinned node loaded through nvm.

Model: opus-5-5
2026-10-08 04:14:21 +02:00
clawbot c55a0cb2f0 Tag the image build in the checklist's script/cibuild item (closes #125)
check / check (push) Canceled after 0s
The new-repository checklist gave script/cibuild's image build without a
tag, so a repository written from it left a dangling image behind on
every run, and it used $version without saying where it comes from. The
item now gives the build as the canonical script/cibuild runs it, with
-t "$tag", and names the two steps the script runs before it, each on
its own line: the version from git describe --tags --always --dirty
(unknown if empty) and the tag from script/projectname. The other docker
build commands the checklists and REPO_POLICIES.md give for script/
already matched their scripts.

Model: opus-5-5
2026-10-07 13:02:05 +02:00
clawbot 1d9b046b91 Stop the lint and test builds writing an image (closes #123)
check / check (push) Canceled after 0s
script/lint and script/test build their Dockerfile phase with
--output type=cacheonly in place of a tag. The phase still runs
uncached and a failing step still fails the build, but no image is
written: nothing used those images, and writing one out took about 16
seconds of a Go repository's test build. script/cibuild and
script/docker keep their tags. REPO_POLICIES.md, both checklists and
the README now say the gate builds write no image.

Model: opus-5-5
2026-10-07 12:02:02 +02:00
clawbot 0b20f18734 Set a time limit on the canonical check job (closes #120)
check / check (push) Canceled after 0s
The canonical check workflow gave its job no time limit, so a hung
script/cibuild held the shared runner until the runner's own default.
The check job now sets timeout-minutes: 20. script/cibuild runs three
Docker builds (the test phase, the lint phase, then the image, which
runs both again), each held to the 5-minute build limit, plus the
bootstrap. REPO_POLICIES.md and both checklists name the limit among
what the workflow sets.

Model: opus-5-5
2026-10-07 11:31:34 +02:00
clawbot a04a76d59c Make the canonical package.json private instead of declaring MIT (closes #119)
check / check (push) Canceled after 0s
Repositories copy package.json to get prettier, and with "license": "MIT"
each of them declared MIT whatever its own licence is. "private": true
keeps yarn from printing "No license field" and makes no licence claim.
This repository's LICENSE and README License section are unchanged.

Model: opus-5-5
2026-10-07 11:02:12 +02:00
clawbot b3508a4361 Fetch history and tags in the canonical workflow (closes #110)
check / check (push) Successful in 24s
The policy said a repository whose version comes from git tags needs `fetch-depth: 0` on its CI checkout, because the checkout action clones shallow with no tags; every repository's version comes from `git describe --tags --always`, but the canonical `.gitea/workflows/check.yml` did not set it, so CI stamped a bare short commit id where a local build of a tagged repository stamps the tag. The checkout step now sets `fetch-depth: 0` with a one-line comment, and the workflow bullet of `prompts/REPO_POLICIES.md` and both checklists name it beside `persist-credentials: false` and the `concurrency` block, as part of what the workflow does.

Unverified: a live run, which waits on the shared runner.

Model: opus-5-5
2026-10-06 08:15:41 +02:00
clawbot 8fe0709414 Refresh apt package lists once in script/bootstrap (closes #115)
check / check (push) Canceled after 0s
`pkg_install` in the canonical `script/bootstrap` ran `apt-get install` with no `apt-get update` before it. The Gitea runner image starts with empty package lists, so a repository's own `pkg_install` of anything the image lacks, Go included, failed with "Unable to locate package". The apt branch now refreshes the lists once, before the first install of a run, in the same `$SUDO env DEBIAN_FRONTEND=noninteractive` form, and skips the refresh on later calls. nix, brew and apk are unchanged. Repositories that refresh in their own section, or changed their copy, drop that at their next re-vendor.

Model: opus-5-5
2026-10-06 07:31:21 +02:00
clawbot 01954b6946 Say that a build from a linked worktree needs the version passed in (closes #111)
check / check (push) Canceled after 0s
A plain `docker build .` from a checkout whose `.git` is a file (a linked worktree, or a repository checked out as a submodule) stops at the version check of the canonical `Dockerfile` example: that file points to a git directory outside the build context, so `git describe` prints nothing. The check is right to refuse an empty version; what was missing is what to do. `prompts/REPO_POLICIES.md` and both checklists now say such a build is given its version with `--build-arg VERSION=...`, as `script/docker` and `script/cibuild` already do. The check itself is unchanged.

Model: opus-5-5
2026-10-06 06:33:19 +02:00
clawbot 6aac45857a Cancel replaced CI runs and drop the checkout token (closes #107)
check / check (push) Canceled after 0s
The canonical `.gitea/workflows/check.yml` lacked two settings `dnswatcher` had added, so a byte-identical re-vendor removed them. A `concurrency` block grouped by workflow and branch, with `cancel-in-progress: true`, makes a new push cancel the older run on the same branch and leaves every other branch's runs alone; on 2026-10-02 45 stale runs had queued on the one shared runner. `persist-credentials: false` on the checkout step keeps the job's token out of `.git/config`; `script/cibuild` needs no token. Each has a one-line comment, and the policy's workflow bullet and both checklists describe the file as it now is.

Unverified: the two live checks, which wait on the shared runner.

Model: opus-5-5
2026-10-06 05:52:57 +02:00
16 changed files with 466 additions and 182 deletions
+11
View File
@@ -1,9 +1,20 @@
name: check
on: [push]
# Free the shared runner: a new push cancels only the same branch's older run.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
# Free the shared runner from a hung build.
timeout-minutes: 20
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
# script/cibuild needs no token, so none is left in .git/config.
with:
persist-credentials: false
# All history and tags, so git describe finds the version tag.
fetch-depth: 0
- run: script/cibuild
+6
View File
@@ -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
+16 -8
View File
@@ -116,22 +116,26 @@ alpine. We provide:
`script/bootstrap`, then `script/install-precommit`
- `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 -t prompts-test .`,
building the `test` phase of the `Dockerfile` (no tests defined here)
- `script/lint` — `docker build --no-cache --target lint -t prompts-lint .`,
building the `lint` phase, which runs prettier over the markdown files
- `script/test` —
`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 --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`
@@ -140,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
+82
View File
@@ -21,6 +21,88 @@ 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
packages under it. It prints the version of the node it uses either way. The
CI runner image ships node 24, under which the pinned yarn 1.22.22 printed the
deprecation warning DEP0169 on every bootstrap. Only the major is compared
because the `Dockerfile` stages start from a node 22 alpine image whose exact
version is not the pin, and nvm cannot install a prebuilt node on alpine; the
version-comparison rule in `REPO_POLICIES.md` names node as its exception.
`script/fmt` and `script/fmt-check` pick their yarn with the same test: the
`yarn` on `PATH` when the installed node is the pinned major, and otherwise
yarn under the pinned node through nvm. `REPO_POLICIES.md` and both checklists
say so, and the checklists' final `script/cibuild` check names what the CI
runner image has: node 24 and no yarn. Not yet tried on the shared runner.
Repositories pick this up on their next re-vendor.
- 2026-10-07: The `script/cibuild` item in `NEW_REPO_CHECKLIST.md` now matches
the canonical `script/cibuild` (issue 125). Its image build carries the tag,
`-t "$tag"`, and the item says that `$version` comes from
`git describe --tags --always --dirty` (`unknown` if empty) and `$tag` from
`script/projectname`, each assigned on its own line before the build. A
repository written from the checklist got an untagged build, which leaves a
dangling image behind on every run, and was never told where `$version` comes
from. The other `docker build` commands the checklists and `REPO_POLICIES.md`
give for `script/` already matched their scripts.
- 2026-10-07: `script/lint` and `script/test` now build with
`--output type=cacheonly` in place of a tag (issue 123), so they still run
their phase uncached and fail on a failing step but write no image. Nothing
used those images, and writing one out cost about 16 seconds of a Go
repository's test build. `script/cibuild` and `script/docker` keep their tags.
`REPO_POLICIES.md`, both checklists and the README no longer say the gate
builds are tagged. Not yet tried on the shared runner. Repositories pick this
up on their next re-vendor.
- 2026-10-07: The canonical `.gitea/workflows/check.yml` now sets
`timeout-minutes: 20` on its `check` job (issue 120), so a hung build frees
the shared runner instead of holding it until the runner's own limit.
`script/cibuild` runs three Docker builds, each held to the 5-minute Docker
build limit, plus the bootstrap; if that limit changes (issue 113), the value
follows it. `REPO_POLICIES.md` and both checklists name the limit among what
the workflow sets. Not yet tried on the shared runner. Repositories pick this
up on their next re-vendor.
- 2026-10-07: The canonical `package.json` now has `"private": true` in place of
`"license": "MIT"` (issue 119), so a repository that copies it no longer
declares MIT whatever its own licence is. yarn does not print "No license
field" for a private package. This repository's own licence is unchanged.
- 2026-10-06: The canonical `.gitea/workflows/check.yml` now sets
`fetch-depth: 0` on its checkout step (issue 110), so CI fetches the history
and tags that `git describe --tags --always` needs, and a tagged repository
stamps the same version in CI as in a local build. `REPO_POLICIES.md` and both
checklists name `fetch-depth: 0` among what the workflow does, next to
`persist-credentials: false` and the `concurrency` block, instead of asking
each tagged repository to add it. Not yet tried on the shared runner, which is
out of disk space. Repositories pick this up on their next re-vendor.
- 2026-10-06: The canonical `script/bootstrap` now runs `apt-get update` once,
before the first `apt-get install` of a run (issue 115). The Gitea runner
image starts with empty package lists, so installing anything it lacks, such
as Go, failed with `Unable to locate package`. A repository's own section no
longer needs a refresh of its own; `sneak/bsfirehose` and `sneak/dnswatcher`
drop theirs at their next re-vendor.
- 2026-10-06: `REPO_POLICIES.md` and both checklists now say that a checkout
whose `.git` is a file, a linked worktree or a repository checked out as a
submodule, is the exception to a plain `docker build .` succeeding (issue
111): that file points to a git directory outside the build context, so the
build cannot read the version and the version check in the canonical
`Dockerfile` stops it. Such a build is given its version with
`--build-arg VERSION=...`, as `script/docker` and `script/cibuild` already do.
The check itself is unchanged.
- 2026-10-06: The canonical `.gitea/workflows/check.yml` now has a `concurrency`
block, so a new push cancels the older run on the same branch and no other,
and its checkout step sets `persist-credentials: false`, so the job's token is
not left in `.git/config` (issue 107). `REPO_POLICIES.md` and both checklists
describe the workflow as it now is. Not yet tried on the shared runner, which
is out of disk space. Repositories pick this up on their next re-vendor.
- 2026-10-06: The canonical `.golangci.yml` now disables `canonicalheader`
(issue 105). In golangci-lint v2.14.0 it misses findings at random in a
package that also calls `ResponseWriter.Header()`, so the same tree can fail
+1 -1
View File
@@ -1,5 +1,5 @@
{
"license": "MIT",
"private": true,
"devDependencies": {
"prettier": "3.8.1"
}
+5 -4
View File
@@ -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)
+49 -24
View File
@@ -1,6 +1,6 @@
---
title: Existing Repo Checklist
last_modified: 2026-10-06
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
@@ -91,12 +100,21 @@ with your task.
version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument.
`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.
A checkout whose `.git` is a file (a linked worktree, or a repository
checked out as a submodule) is the exception: that file points to a git
directory outside the build context, so the build cannot read the version
and a plain `docker build .` fails; pass the version with
`--build-arg VERSION=...`. `script/docker` and `script/cibuild` already
pass the version they compute on the host; it takes precedence. The
canonical `.gitea/workflows/check.yml` sets `fetch-depth: 0` on its
checkout step, which otherwise clones shallow and fetches no tags, so a CI
build finds the tag too.
- [ ] Gitea Actions workflow in `.gitea/workflows/` runs `script/cibuild` on
push — reference
push, checks out with `persist-credentials: false` and with
`fetch-depth: 0` (which fetches the tags `git describe` needs), carries
the `concurrency` block that lets a new push cancel only the same branch's
older run, and sets `timeout-minutes: 20` on the `check` job so a hung
build frees the shared runner — reference
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml`
- [ ] Language-specific config:
- [ ] Go: `go.mod`, `go.sum`, `.golangci.yml` (fetch from
@@ -123,25 +141,31 @@ with your task.
`script/install-precommit`, shimmed by `make hooks`) runs it
- [ ] README has an **Entrypoints** section documenting the `script/`
entrypoints and linking the standard
- [ ] `script/lint` and `script/test` build their phase by name
(`docker build --no-cache --target <phase> -t <name>-<phase> .`), 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 `script/lint`. A second path is likeliest here: a `make lint-fast`,
an older host-versus-container branch, or a CI step calling the binary
- [ ] `script/lint` and `script/test` each run
`docker build --build-arg CACHEBUST="$(date +%s)-$$" --target <phase> --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
`script/lint`. A second path is likeliest here: a `make lint-fast`, an
older host-versus-container branch, or a CI step calling the binary
directly. `script/fmt` and `script/fmt-check` are expected hits and stay
on the host.
- [ ] Every `docker build` in `script/` is tagged — an untagged one leaves a
dangling image behind on every run, on every host and CI runner
- [ ] No `docker build` in `script/` leaves a dangling image behind:
`script/lint` and `script/test` write no image, and `script/docker` and
`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` source nvm for the pinned node version
before invoking `yarn`, as `script/bootstrap`'s own install step does.
`script/bootstrap` leaves the node and yarn it installs off the `PATH` of
the shell that called it, so a bare `yarn` exits 127 on a runner carrying
nothing but docker and git.
- [ ] `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
nvm loaded through `$HOME/.nvm/nvm.sh`. `script/bootstrap` leaves the node
and yarn it installs under nvm off the `PATH` of the shell that called it,
so a bare `yarn` exits 127 on a runner carrying nothing but docker and
git, or runs under another node.
- [ ] `script/bootstrap` installs no linter of its own — delete the block, its
version variables and its call site. A JS repo's `yarn install` stays; it
brings a linter along with every other dependency, and no verdict is taken
@@ -194,10 +218,11 @@ with your task.
# Final
- [ ] `make check` passes
- [ ] `script/cibuild` succeeds in a fresh clone on a host carrying nothing but
docker and git, with no node or yarn on `PATH`, which is what CI has, and
demonstrably executed the checks — a sub-second build, or `CACHED` on a
gate layer, means nothing ran
- [ ] `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'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
+54 -23
View File
@@ -1,6 +1,6 @@
---
title: New Repo Checklist
last_modified: 2026-10-06
last_modified: 2026-10-15
---
Use this checklist when creating a new repository from scratch. Follow the steps
@@ -97,16 +97,36 @@ Template files can be fetched from:
version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument.
A checkout whose `.git` is a file (a linked worktree, or a repository
checked out as a submodule) is the exception: that file points to a git
directory outside the build context, so the build cannot read the version
and a plain `docker build .` fails; pass the version with
`--build-arg VERSION=...`, as `script/docker` and `script/cibuild` already
do.
- 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
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
- [ ] Gitea Actions workflow at `.gitea/workflows/check.yml` that runs
`script/cibuild` on push — reference
`script/cibuild` on push, checks out with `persist-credentials: false` and
with `fetch-depth: 0` (which fetches the tags `git describe` needs),
carries the `concurrency` block that lets a new push cancel only the same
branch's older run, and sets `timeout-minutes: 20` on the `check` job so a
hung build frees the shared runner — reference
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml`
- [ ] Language-specific:
- [ ] Go: `go mod init sneak.berlin/go/<name>`, `.golangci.yml` (fetch from
@@ -126,18 +146,22 @@ are thin shims calling them. Model scripts:
alpine images without bash
- [ ] `script/bootstrap` / `make bootstrap` — installs all dependencies,
idempotently, assuming nothing (pkg manager detection nix/apt/brew/apk;
node used if present, else pinned version via nvm from a hash-verified
archive; pinned yarn via corepack); a non-server repo's development
node used if its major version is the pinned one, else pinned version via
nvm from a hash-verified archive, with yarn and the packages installed
under it; pinned yarn via corepack); a non-server repo's development
environment stage runs it instead of inline installs; a gate phase or the
build stage installs what its base image lacks either inline or by running
it
- [ ] `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 .`,
tagged; 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 .`,
tagged. No lint verdict may come from a host invocation of the linter.
- [ ] `script/test` / `make test` —
`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 --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;
@@ -147,20 +171,26 @@ 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" .` (what CI runs).
`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` source nvm for the pinned node version
before invoking `yarn`, as `script/bootstrap`'s own install step does.
`script/bootstrap` leaves the node and yarn it installs off the `PATH` of
the shell that called it, so a bare `yarn` exits 127 on a runner carrying
nothing but docker and git.
- [ ] Every `docker build` in `script/` is tagged, so no invocation leaves a
dangling image behind
- [ ] `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
nvm loaded through `$HOME/.nvm/nvm.sh`. `script/bootstrap` leaves the node
and yarn it installs under nvm off the `PATH` of the shell that called it,
so a bare `yarn` exits 127 on a runner carrying nothing but docker and
git, or runs under another node.
- [ ] No `docker build` in `script/` leaves a dangling image behind:
`script/lint` and `script/test` write no image, and `script/docker` and
`script/cibuild` tag theirs
- [ ] `script/precommit` — called by the pre-commit hook; runs `script/check`
- [ ] `script/install-precommit` — installs the pre-commit hook that runs
`script/precommit`
@@ -172,10 +202,11 @@ are thin shims calling them. Model scripts:
- [ ] `make check` passes
- [ ] `make docker` succeeds
- [ ] `script/cibuild` succeeds in a fresh clone on a host carrying nothing but
docker and git, with no node or yarn on `PATH`, which is what CI has, and
demonstrably executed the checks — a sub-second build, or `CACHED` on a
gate layer, means nothing ran
- [ ] `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'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.
+139 -74
View File
@@ -1,6 +1,6 @@
---
title: Repository Policies
last_modified: 2026-10-06
last_modified: 2026-10-15
---
This document covers repository structure, tooling, and workflow standards. Code
@@ -54,34 +54,39 @@ style conventions are in separate documents:
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk
(detected in that order; apt runs noninteractive). For node it uses the
installed node if present; otherwise it installs a PINNED node version via
nvm, first installing nvm itself if missing — from a hash-verified GitHub
release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
with the version; the Gitea workflow calls it. **`script/cibuild` runs
installed node only when its major version is the pinned one; otherwise (node
missing, or another major, such as the node 24 the CI runner image ships) it
installs the PINNED node version via nvm and installs yarn and the packages
under it. nvm itself is installed first if missing, from a hash-verified
GitHub release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. Only the major version is compared
because the `Dockerfile` stages start from a node image whose exact version is
not the pin, and nvm cannot install a prebuilt node on alpine. yarn is pinned
via `corepack prepare yarn@<version> --activate`. Never install "latest" or
"lts"; always exact versions. `script/cibuild` runs the CI build: it changes
to the repo root, runs `script/bootstrap`, runs `script/check`, and builds the
image with the version; the Gitea workflow calls it. **`script/cibuild` runs
`script/bootstrap` first**, because the workflow checks out the repo and runs
nothing else, while `script/fmt-check` runs the formatter on the host: on a
pristine checkout with nothing installed the run dies there, after the
containerised gates have passed. **The bootstrap alone is not enough**:
`script/bootstrap` installs node and yarn under nvm and leaves neither on the
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
source nvm for the pinned node version before invoking it, exactly as
`script/bootstrap`'s own install step does. A runner carrying nothing but
docker and git then gets through `script/check`. 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
containerised gates have passed. **The bootstrap alone is not enough**: when
`script/bootstrap` installs node and yarn under nvm it leaves neither on the
`PATH` of the shell that called it, so a bare `yarn` either exits 127 or runs
under another node. The host entrypoints that need yarn — `script/fmt` and
`script/fmt-check` — therefore use the same test as `script/bootstrap`: when
the node on `PATH` has the pinned major version they run the `yarn` on `PATH`,
and otherwise they load nvm through `$HOME/.nvm/nvm.sh` and run yarn under the
pinned node. A runner carrying nothing but docker and git then gets through
`script/check`. 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/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
@@ -118,9 +123,10 @@ style conventions are in separate documents:
and nothing else:
```sh
tag="$(script/projectname)"
docker build --no-cache --target lint -t "$tag-lint" .
docker build --no-cache --target test -t "$tag-test" .
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
@@ -130,11 +136,14 @@ style conventions are in separate documents:
plain `docker build .` builds the last stage alone and exits 0 having linted
and tested nothing.
**Every `docker build` in `script/` is tagged**, here and in
`script/cibuild` and `script/docker`. An untagged build leaves a dangling
image behind on every invocation, on every developer host and every CI
runner; a tagged one replaces the previous image. Each script assigns the
tag on its own line before the build, so `set -e` stops it where
**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. 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`,
@@ -150,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
@@ -174,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
@@ -215,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; \
@@ -228,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"]
```
@@ -242,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:
@@ -281,20 +323,39 @@ style conventions are in separate documents:
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument.
A checkout whose `.git` is a file (a linked worktree, or a repository
checked out as a submodule) is the exception: that file points to a git
directory outside the build context, so the build cannot read the version
and a plain `docker build .` fails; pass the version with
`--build-arg VERSION=...`, as `script/docker` and `script/cibuild` already
do.
- 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.
That script bootstraps, runs the gate phases, and then builds the image, so a
successful run means every check passed; a bare `docker build .` does not
runs `script/cibuild` on push, and checks out the repo as its only other step,
with `persist-credentials: false`: `script/cibuild` needs no token, and
without it the checkout leaves the job's token in `.git/config` for every
later step. The checkout step also sets `fetch-depth: 0`, which fetches the
tags `git describe` needs: by default it clones shallow with no tags, and a
tagged repository's CI build would stamp a bare short commit id. The
workflow's `concurrency` block groups runs by workflow and branch
(`${{ github.workflow }}-${{ github.ref }}`) with `cancel-in-progress: true`,
so a new push cancels the older run on the same branch, queued or running, and
no other: runs for replaced commits do not hold up the shared runner.
`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. 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
@@ -354,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:
@@ -446,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" .
```
@@ -460,8 +520,8 @@ style conventions are in separate documents:
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
checkout action clones shallow and fetches no tags, so a repo that embeds a
tag-derived version must set `fetch-depth: 0` on its checkout step.
checkout action clones shallow and fetches no tags, so the canonical
`.gitea/workflows/check.yml` sets `fetch-depth: 0` on its checkout step.
- **Verify `.dockerignore` by enumerating the image, not by reading the
patterns.** Plant files at the root _and_ at least two directories deep, build
@@ -528,6 +588,11 @@ style conventions are in separate documents:
function defined and never invoked has the same exit status and the same
empty output as one that worked.
Node is the exception to the whole-token comparison: the canonical
`script/bootstrap` compares only its major version, because the node 22
alpine image the `Dockerfile` stages start from is not the exact pin, and
nvm cannot install a prebuilt node on alpine.
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
A Go tool a repo needs on the host is installed with `go install` pinned to
+43 -12
View File
@@ -2,10 +2,12 @@
# script/bootstrap: install all dependencies needed to build and develop
# this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes nothing is present. Node is
# used directly if installed; otherwise it is installed at a pinned
# version via nvm (installing nvm itself first, from a hash-verified
# release archive, never curl | sh).
# or apk (detected in that order); assumes nothing is present. The
# installed node is used only when its major version is the pinned one;
# otherwise (node missing, or another major) the pinned version is
# installed via nvm (installing nvm itself first, from a hash-verified
# release archive, never curl | sh), and yarn and the packages are
# installed under it.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -19,6 +21,7 @@ YARN_VERSION="1.22.22"
PKGMGR=""
SUDO=""
APT_UPDATED=""
detect_pkgmgr() {
[ -n "$PKGMGR" ] && return 0
@@ -47,7 +50,14 @@ pkg_install() {
detect_pkgmgr
case "$PKGMGR" in
nix) nix-env -iA "nixpkgs.$1" ;;
apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;;
apt)
# Package lists may be empty (fresh images); refresh once per run.
if [ -z "$APT_UPDATED" ]; then
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update
APT_UPDATED=1
fi
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2"
;;
brew) brew install "$3" ;;
apk) apk add --no-cache "$4" ;;
esac
@@ -92,31 +102,52 @@ ensure_nvm() {
rm -rf "$tmp"
}
# True when the node on PATH has the pinned major version. Only the
# major is compared: the Dockerfile stages start from a node 22 alpine
# image whose exact version is not the pin, and nvm cannot install a
# prebuilt node on alpine. Another major is not used: the pinned yarn 1
# prints a deprecation warning under node 24. script/fmt and
# script/fmt-check carry this function unchanged, to run the yarn
# installed here.
node_is_pinned_major() {
if ! command -v node >/dev/null 2>&1; then return 1; fi
installed="$(node --version)"
installed="${installed#v}"
[ "${installed%%.*}" = "${NODE_VERSION%%.*}" ]
}
ensure_node() {
if ! missing node; then return 0; fi
if node_is_pinned_major; then
echo "bootstrap: using node $(node --version)"
return 0
fi
ensure_nvm
nvm_sh "nvm install $NODE_VERSION"
nvm_sh "nvm use $NODE_VERSION >/dev/null && \
echo \"bootstrap: using node \$(node --version)\""
}
ensure_yarn() {
if ! node_is_pinned_major; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
corepack prepare yarn@$YARN_VERSION --activate"
return 0
fi
if ! missing yarn; then return 0; fi
if ! missing corepack; then
corepack enable
corepack prepare "yarn@$YARN_VERSION" --activate
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
corepack prepare yarn@$YARN_VERSION --activate"
else
npm install -g "yarn@$YARN_VERSION"
fi
}
install_js_deps() {
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
if node_is_pinned_major; then
yarn install --frozen-lockfile
else
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
yarn install --frozen-lockfile"
else
yarn install --frozen-lockfile
fi
}
+6 -4
View File
@@ -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" .
}
+6 -3
View File
@@ -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" .
}
+16 -6
View File
@@ -7,15 +7,25 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
# True when the node on PATH has the pinned major version: the same
# function as in script/bootstrap, which uses it to decide where it
# installs yarn.
node_is_pinned_major() {
if ! command -v node >/dev/null 2>&1; then return 1; fi
installed="$(node --version)"
installed="${installed#v}"
[ "${installed%%.*}" = "${NODE_VERSION%%.*}" ]
}
# Run the yarn script/bootstrap installed: the yarn on PATH when the
# node on PATH has the pinned major version, and otherwise yarn under
# the pinned node in nvm, which bootstrap leaves off the PATH of the
# shell that called it. nvm is a bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
if node_is_pinned_major && command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
if node_is_pinned_major || [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt: no yarn; run script/bootstrap first" >&2
exit 1
fi
+16 -6
View File
@@ -7,15 +7,25 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
# True when the node on PATH has the pinned major version: the same
# function as in script/bootstrap, which uses it to decide where it
# installs yarn.
node_is_pinned_major() {
if ! command -v node >/dev/null 2>&1; then return 1; fi
installed="$(node --version)"
installed="${installed#v}"
[ "${installed%%.*}" = "${NODE_VERSION%%.*}" ]
}
# Run the yarn script/bootstrap installed: the yarn on PATH when the
# node on PATH has the pinned major version, and otherwise yarn under
# the pinned node in nvm, which bootstrap leaves off the PATH of the
# shell that called it. nvm is a bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
if node_is_pinned_major && command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
if node_is_pinned_major || [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt-check: no yarn; run script/bootstrap first" >&2
exit 1
fi
+8 -9
View File
@@ -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. The tag makes each build replace the previous image
# instead of leaving a dangling one behind.
# --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,13 +18,9 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
# The tag gets its own line: a failing command substitution inside
# an argument does not trip `set -e`, so the inline form degrades
# silently to an empty constant.
tag="$("$SCRIPT_DIR/projectname")"
docker build --no-cache \
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--target lint \
-t "$tag-lint" .
--output type=cacheonly .
}
main "$@"
+8 -8
View File
@@ -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, --no-cache because a cached test layer is a test that did not
# run, and a tag so each build replaces the previous image.
# 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,13 +15,9 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
# The tag gets its own line: a failing command substitution inside
# an argument does not trip `set -e`, so the inline form degrades
# silently to an empty constant.
tag="$("$SCRIPT_DIR/projectname")"
docker build --no-cache \
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--target test \
-t "$tag-test" .
--output type=cacheonly .
}
main "$@"