Author SHA1 Message Date
sneak b6d5d2d6f8 Add script/update-policy to re-vendor policy files (closes #162)
check / check (push) Successful in 17s
One command fetches the vendored files from prompts at a commit, keeps a repo's own entries, commits and pushes. The canonical .dockerignore gains the closing comment the script needs.

Model: opus-5-5
2026-11-09 07:33:11 +00:00
11 changed files with 1306 additions and 1022 deletions

No files matched your search

+4
View File
@@ -76,3 +76,7 @@
**/.idea
**/.vscode
**/*.sublime-*
# This repository's own entries, such as its build outputs, go below
# this comment, and a re-vendor keeps them. Anchor a binary built at the
# root: `/myapp`, never `**/myapp`, which also matches `cmd/myapp/`.
+4 -1
View File
@@ -1,4 +1,4 @@
.PHONY: bootstrap setup test lint fmt fmt-check check docker hooks
.PHONY: bootstrap setup test lint fmt fmt-check check docker hooks update-policy
# Makefile targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
@@ -30,3 +30,6 @@ docker:
hooks:
@script/install-precommit
update-policy:
@script/update-policy
+4 -2
View File
@@ -141,6 +141,9 @@ alpine. We provide:
`script/check`
- `script/install-precommit` — installs the git pre-commit hook (our own
extension); `make hooks` shims to it
- `script/update-policy` — fetch the vendored policy files from this repo,
commit them and push (our own extension); it is for the repos that vendor them
and stops when run here
`make hooks` installs the pre-commit hook that runs `script/precommit`.
@@ -172,8 +175,7 @@ share, and evolve prompts across projects.
The repository is a collection of Markdown files organized in the `prompts/`
subdirectory. Each file contains one or more related prompts or policy
documents. There is no build step or runtime component; the prompts are consumed
by copying them into other projects or referencing them directly. `examples/`
holds the example `Dockerfile` files that `prompts/REPO_POLICIES.md` links to.
by copying them into other projects or referencing them directly.
## Template Repos
+6 -7
View File
@@ -21,13 +21,12 @@ fmt-check, and commit.
# Completed Steps
- 2026-11-09: Compacted `prompts/REPO_POLICIES.md` from 1,165 lines and 8,556
words to 982 lines and 7,281 words
(https://git.eeqj.de/sneak/prompts/issues/158). Every rule keeps its meaning.
The rules now sit under headings, the pinning rule first. Each rule that was
stated in several places is stated once. The Makefile copies of the Go and
Python test commands are gone, because the `Dockerfile` examples carry the
same commands. Both checklists name the `.golangci.yml` section.
- 2026-11-09: `script/update-policy` and `make update-policy` update a repo's
vendored policy files with one command
(https://git.eeqj.de/sneak/prompts/issues/162). They fetch the files from
`prompts`, keep the repo's own entries, commit and push. The canonical
`.dockerignore` now ends with the comment that the repo's own entries go
below. The template repositories get the script in their own issues.
- 2026-11-07: Go code is now formatted with `gofmt` and then `golines -m 77`
(https://git.eeqj.de/sneak/prompts/issues/160). `prompts/REPO_POLICIES.md`
says how `script/fmt`, `script/fmt-check` and `script/bootstrap` do it and
@@ -1,8 +0,0 @@
FROM golangci/golangci-lint@sha256:... AS lint
RUN apt-get update \
&& apt-get install -y --no-install-recommends libvips-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
-85
View File
@@ -1,85 +0,0 @@
# In every stage ARG CACHEBUST comes right after the steps that install
# system packages and download Go's modules, or right after FROM in a
# stage with neither, and every step that lints, tests or compiles
# comes after it. script/ passes a new value on every build, which
# reruns every RUN step after it. Docker keeps the layers above it
# until the base image, their own lines or go.mod and go.sum change.
# The steps that compile mount Go's build cache, and golangci-lint run
# also mounts golangci-lint's; both survive from build to build.
# Lint phase
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/root/.cache/golangci-lint \
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. -count=1 because the mounted
# cache also holds test results, which go test would otherwise replay.
# A first run that timed out gets no rerun, because a rerun would only
# wait for the same hang.
# golang:1.x, YYYY-MM-DD
FROM golang@sha256:... AS test
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
status=0; \
output=$(go test -count=1 -timeout 90s -race -cover ./... 2>&1) || status=$?; \
printf '%s\n' "$output"; \
test "$status" -eq 0 && exit 0; \
case "$output" in \
*"panic: test timed out"*|*"*** Test killed"*) exit 1 ;; \
esac; \
echo "--- Rerunning with -v for details ---"; \
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 nothing after them can
# run unless lint and test passed.
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
# git comes from the base image's own distribution package index, which
# is trusted, so it needs no version and no hash.
RUN apk add --no-cache git
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
COPY . .
# The VERSION build arg when one is given, otherwise
# `git describe --tags --always` on the .git in the build context. With
# .git present, a version that is still empty, dev or unknown fails the
# build: git is missing or could not read the checkout.
ARG VERSION
RUN --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; \
exit 1 ;; \
esac; \
fi; \
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage, and the last one
FROM alpine@sha256:...
ARG CACHEBUST
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
-24
View File
@@ -1,24 +0,0 @@
# Test phase. Both pytest runs share one 90-second timeout, so a hang
# uses it once and gets no rerun.
# debian:trixie-slim, YYYY-MM-DD
FROM debian@sha256:... AS test
# Python, pip and venv come from the distribution, which is trusted, so
# they need no version and no hash. make and git are base tools that
# script/bootstrap installs when they are missing. Installed here, they
# stay in their own layer when script/ changes.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates make git \
python3 python3-venv \
&& rm -rf /var/lib/apt/lists/*
# script/bootstrap installs pipenv in a virtual environment at
# $HOME/.pipenv, and the tests below run that pipenv.
ENV PATH=/root/.pipenv/bin:$PATH
WORKDIR /app
COPY script/ script/
COPY Pipfile Pipfile.lock ./
RUN script/bootstrap
ARG CACHEBUST
COPY . .
RUN timeout 90 sh -c 'pipenv run pytest || { \
echo "--- Rerunning with -v for details ---"; \
pipenv run pytest -v; exit 1; }'
+11 -6
View File
@@ -71,8 +71,8 @@ with your task.
also mounts golangci-lint's
(`--mount=type=cache,target=/root/.cache/golangci-lint`), and no step
mounts anything else; both `go test` runs carry `-count=1`; a first run
that timed out gets no rerun, as in `examples/go.Dockerfile` in the
`prompts` repo
that timed out gets no rerun, as in the canonical Go `Dockerfile` in
`REPO_POLICIES.md`
- [ ] Python: a `test` phase that installs its packages from `Pipfile.lock` does
it above `ARG CACHEBUST`, with `COPY script/ script/`,
`COPY Pipfile Pipfile.lock ./` and `RUN script/bootstrap`. When
@@ -92,8 +92,8 @@ with your task.
installs no Python, because a step above it already did. `Pipfile` names no
`python_version`, because `--deploy` stops when the distribution's Python
differs from it. The tests run after `ARG CACHEBUST`, and every other stage
installs its Python packages after it, as in
`examples/python-test-phase.Dockerfile` in the `prompts` repo
installs its Python packages after it, as in the Python `test` phase example
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
@@ -163,7 +163,7 @@ with your task.
- [ ] Go: `go.mod`, `go.sum`, `.golangci.yml` (fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and,
in the same commit, set the lint phase digest to the one named in the
`.golangci.yml` section of `REPO_POLICIES.md`)
`.golangci.yml` paragraph of `REPO_POLICIES.md`)
- [ ] JS: `package.json`, `yarn.lock`, `.yarnrc.yml`, `.yarn/releases/`,
`.gitattributes`, `.prettierrc`, `.prettierignore` (fetch the last two
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.prettierrc`
@@ -205,12 +205,17 @@ with your task.
- [ ] `Makefile` exists in root — reference
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`
- [ ] Has targets: `test`, `lint`, `fmt`, `fmt-check`, `check`, `docker`,
`hooks`
`hooks`, `update-policy`
- [ ] Target implementations live in `script/` (scripts-to-rule-them-all);
Makefile targets are thin shims calling them — model scripts at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`
- [ ] `script/precommit` exists and the pre-commit hook (installed by
`script/install-precommit`, shimmed by `make hooks`) runs it
- [ ] `script/update-policy` exists, identical to the model script, and
`make update-policy` shims to it. Before its first use, the repo's own
entries in `.gitignore`, `.editorconfig` and `.dockerignore` go below the
comment that ends each canonical file. Entries above that comment are
replaced.
- [ ] README has an **Entrypoints** section documenting the `script/`
entrypoints and linking the standard
- [ ] `script/lint` and `script/test` each run
+10 -5
View File
@@ -62,6 +62,8 @@ Template files can be fetched from:
`CLAUDE.md` or `.claude/`, and never separate memory files
- [ ] `Dockerfile` and `.dockerignore` — fetch `.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`
- The repo's own entries go at the end of the file, where a re-vendor keeps
them.
- Extend `.dockerignore` with the repo's own host-built artifacts, giving
every depth-independent pattern a `**/` prefix — but write a repo-root
binary anchored, `/myapp` and never `**/myapp`, which would also match
@@ -138,8 +140,8 @@ Template files can be fetched from:
also mounts golangci-lint's
(`--mount=type=cache,target=/root/.cache/golangci-lint`), and no step
mounts anything else; both `go test` runs carry `-count=1`; a first run
that timed out gets no rerun, as in `examples/go.Dockerfile` in the
`prompts` repo
that timed out gets no rerun, as in the canonical Go `Dockerfile` in
`REPO_POLICIES.md`
- Python: a `test` phase that installs its packages from `Pipfile.lock` does
it above `ARG CACHEBUST`, with `COPY script/ script/`,
`COPY Pipfile Pipfile.lock ./` and `RUN script/bootstrap`. When
@@ -160,8 +162,8 @@ Template files can be fetched from:
because a step above it already did. `Pipfile` names no
`python_version`, because `--deploy` stops when the distribution's
Python differs from it. The tests run after `ARG CACHEBUST`, and every
other stage installs its Python packages after it, as in
`examples/python-test-phase.Dockerfile` in the `prompts` repo
other stage installs its Python packages after it, as in the Python
`test` phase example in `REPO_POLICIES.md`
- Server: the final stage builds and runs the application
- Non-server: the final stage brings up the dev environment
@@ -178,7 +180,7 @@ Template files can be fetched from:
- [ ] Go: `go mod init sneak.berlin/go/<name>`, `.golangci.yml` (fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and,
in the same commit, set the lint phase digest to the one named in the
`.golangci.yml` section of `REPO_POLICIES.md`)
`.golangci.yml` paragraph of `REPO_POLICIES.md`)
- [ ] JS, or any repository that runs prettier through yarn: make the first
`yarn.lock` with the steps below. `script/bootstrap` installs no yarn
until `yarn.lock` exists, so run them with any distribution's node and
@@ -264,6 +266,9 @@ are thin shims calling them. Model scripts:
- [ ] `script/install-precommit` — installs the pre-commit hook that runs
`script/precommit`
- [ ] `make hooks` — shims to `script/install-precommit`
- [ ] `script/update-policy` / `make update-policy` — fetches the vendored
policy files from the `prompts` repo, commits them and pushes; identical
across repos
- [ ] README **Entrypoints** section documents the scripts and links the
standard
+1079 -884
View File
File diff suppressed because it is too large. Load diff
+188
View File
@@ -0,0 +1,188 @@
#!/bin/sh
# script/update-policy: replace this repo's vendored policy files with
# the canonical ones from the prompts repo, commit them and push.
# Identical in all repos. Our own extension to
# scripts-to-rule-them-all.
#
# Usage: script/update-policy [full commit hash of prompts]
# Without a hash it uses the head of the prompts main branch.
#
# The script writes nothing until every file has been fetched and
# merged. A repo's own entries are kept:
# - .gitignore, .editorconfig, .dockerignore: the lines below the
# canonical file's last line
# - .golangci.yml: the entries the repo added to the deny list of the
# test-support rule
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
PROMPTS_URL="https://git.eeqj.de/sneak/prompts"
# Paths in the prompts repo. A file this repo does not carry is skipped.
VENDORED_FILES="
.dockerignore
.editorconfig
.gitea/workflows/check.yml
.gitignore
.golangci.yml
.prettierrc
prompts/API_POLICIES.md
prompts/CODE_STYLEGUIDE.md
prompts/CODE_STYLEGUIDE_GO.md
prompts/CODE_STYLEGUIDE_JS.md
prompts/CODE_STYLEGUIDE_PYTHON.md
prompts/EXISTING_REPO_CHECKLIST.md
prompts/GO_HTTP_SERVER_CONVENTIONS.md
prompts/LLM_PROSE_TELLS.md
prompts/NEW_REPO_CHECKLIST.md
prompts/REPO_POLICIES.md
"
die() {
echo "update-policy: $*" >&2
exit 1
}
# local_path <path in prompts>: print where this repo keeps the file, or
# nothing if git does not track it here. A prompts/NAME.md lands at
# docs/NAME.md or NAME.md.
local_path() {
name="${1#prompts/}"
if [ "$name" != "$1" ] && [ -n "$(git ls-files -- "docs/$name")" ]; then
echo "docs/$name"
elif [ -n "$(git ls-files -- "$name")" ]; then
echo "$name"
fi
}
# keep_lines_below_last <fetched> <local>: append to the fetched file
# whatever follows its last line in the local file.
keep_lines_below_last() {
last="$(tail -n 1 "$1")"
number="$(grep -n -F -x -- "$last" "$2" | head -n 1 | cut -d: -f1)"
if [ -z "$last" ] || [ -z "$number" ]; then
die "$2 lacks this line; add it and put the repo's own entries below it: $last"
fi
tail -n "+$((number + 1))" "$2" >> "$1"
}
# keep_deny_entries <fetched> <local>: add to the deny list of the
# test-support rule in the fetched file the entries only the local file
# has. An entry is a "- pkg:" line, the lines under it and any comment
# above it.
keep_deny_entries() {
awk '
function indent(line) { return match(line, /[^ ]/) - 1 }
function print_local_entries(i) {
for (i = 1; i <= count; i++)
if (!(pkg[i] in fetched_pkg))
printf "%s", entry[i]
}
FNR == 1 { in_rule = 0; in_list = 0 }
in_list && /[^ ]/ && indent($0) <= list_indent {
in_list = 0
if (FILENAME == ARGV[2]) print_local_entries()
}
in_list {
is_entry = ($1 == "-" && $2 == "pkg:")
if (FILENAME == ARGV[2]) {
if (is_entry) fetched_pkg[$3]
print
} else if (is_entry) {
count++
pkg[count] = $3
entry[count] = comment $0 "\n"
comment = ""
} else if (/^ *#/) {
comment = comment $0 "\n"
} else if (count) {
entry[count] = entry[count] $0 "\n"
}
next
}
/^ *test-support:/ { in_rule = 1 }
in_rule && /^ *deny:/ {
in_rule = 0
in_list = 1
list_indent = indent($0)
if (FILENAME == ARGV[2]) found = 1
}
FILENAME == ARGV[2] { print }
END {
if (in_list) print_local_entries()
exit !found
}
' "$2" "$1" > "$1.merged" ||
die "the fetched $2 has no deny list in its test-support rule"
mv "$1.merged" "$1"
}
main() {
cd "$ROOT"
[ "$#" -le 1 ] || die "usage: script/update-policy [full commit hash]"
project="$(script/projectname)"
if [ "$project" = "prompts" ]; then
die "this repo is the source of the policy files"
fi
commit="${1:-}"
if [ -z "$commit" ]; then
head_line="$(git ls-remote "$PROMPTS_URL.git" refs/heads/main)"
commit="${head_line%%[[:space:]]*}"
fi
printf '%s\n' "$commit" | grep -Eqx '[0-9a-f]{40}' ||
die "not a full commit hash: '$commit'"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
carried=""
for file in $VENDORED_FILES; do
path="$(local_path "$file")"
[ -n "$path" ] || continue
if [ -n "$(git status --porcelain -- "$path")" ]; then
die "$path has uncommitted changes"
fi
mkdir -p "$tmp/$(dirname "$path")"
curl --fail --silent --show-error --max-time 60 \
--output "$tmp/$path" \
"$PROMPTS_URL/raw/commit/$commit/$file" ||
die "cannot fetch $file at $commit"
carried="$carried $path"
done
[ -n "$carried" ] || die "this repo carries none of the vendored files"
for path in $carried; do
case "$path" in
.gitignore | .editorconfig | .dockerignore)
keep_lines_below_last "$tmp/$path" "$path"
;;
.golangci.yml)
keep_deny_entries "$tmp/$path" "$path"
;;
esac
done
for path in $carried; do
cp "$tmp/$path" "$path"
git add -- "$path"
done
if git diff --cached --quiet -- $carried; then
echo "update-policy: already at prompts $commit, nothing to commit"
exit 0
fi
git commit -m "Update policy files from prompts $commit" -- $carried
git push origin HEAD ||
die "push failed; the commit exists locally, so fix that and run git push"
}
main "$@"