Author SHA1 Message Date
sneak da50899b23 Drop the test timeout from the Dockerfile; date the changed documents
check / check (push) Successful in 59s
Time limits go only in the CI workflow, so the test phase runs
test/commit-upstream-policy-files.sh with no timeout. REPO_POLICIES.md
and both checklists change in this PR, so their last_modified is
2026-11-11.

Model: opus-5-5
2026-11-11 01:11:39 +00:00
sneak f0ec2f38a8 Rename the scripts that update the policy files
check / check (push) Canceled after 0s
script/commit-current-upstream-policy-docs is now
script/write-upstream-policy-files: it writes the policy files and
commits nothing. script/update-policy is now
script/commit-upstream-policy-files, and make update-policy is now
make commit-upstream-policy-files. test/update-policy.sh follows.
Behaviour is unchanged.

Model: opus-5-5
2026-11-11 00:47:30 +00:00
clawbot ed60414378 Rename script/fetch-policy to script/commit-current-upstream-policy-docs
check / check (push) Canceled after 0s
sneak, on the PR: the script does more than fetch, and short names are
not wanted. The make target, the test names, the variables and the
messages take the new name. Behaviour is unchanged.

Model: opus-5-5
2026-11-11 00:00:28 +00:00
clawbot 917fe4bc1d Rename script/install to script/fetch-policy
check / check (push) Canceled after 0s
By convention, and in scripts-to-rule-them-all, script/install installs
the program itself. This script fetches the shared policy files into a
repo. The make target, the test names and the messages take the new
name. Behaviour is unchanged.

Model: opus-5-5
2026-11-10 20:19:00 +00:00
clawbot ff42f4013d Correct the kept-lines bullet; test .editorconfig with no own line
check / check (push) Successful in 1m1s
REPO_POLICIES said an ignore file lacking the closing comment keeps every
line the canonical file has never held; blank lines and comments are
dropped, so it now says entries, in a bullet of its own.

A new scenario covers an .editorconfig lacking the comment with no line
of its own, which the script replaces.

Model: opus-5-5
2026-11-09 22:31:17 +00:00
clawbot 5d82424a20 REPO_POLICIES: say which lines an ignore file lacking the comment keeps
check / check (push) Failing after 49s
Model: opus-5-5
2026-11-09 21:46:41 +00:00
clawbot 8a25e502f4 script/install: do not stop on a missing closing comment
check / check (push) Successful in 55s
A .gitignore or .dockerignore lacking the canonical last line no longer
stops the script: each line the canonical file has never held goes
below the fetched content. An .editorconfig lacking it is replaced,
unless it has such a line, which stops the script.

The stub curl now writes files with no execute bit, as a real download
does, so the tests exercise the mode the script sets.

Model: opus-5-5
2026-11-09 21:34:29 +00:00
clawbot 9f83f3c1b1 README: script/install stops when its target is this repo
check / check (push) Successful in 54s
Started from this repo with a path, the script writes into that clone,
so "stops when run here" was false.

Model: opus-5-5
2026-11-09 20:46:27 +00:00
clawbot e89fc86adc Drop make install from the required targets; test three more stops
check / check (push) Successful in 48s
The install target stays in this repo's Makefile only. sneak/vaultik
already has an install target with another meaning, and every repo
still gets script/install.

Tests: a repo's own script/update-policy stops the script, and a repo
whose only JavaScript source is a .js or .mjs file gets the JavaScript
style guide.

Model: opus-5-5
2026-11-09 20:04:01 +00:00
clawbot 76b75fa79f script/install: write every listed file the repo lacks
check / check (push) Successful in 48s
A listed file the repo lacks is now written whole, with no keep rule.
A prompts/NAME.md goes to docs/NAME.md unless the repo keeps it at
NAME.md, and any other file goes to its own path. A language's files
are skipped when git tracks no source file of that language.

A listed path git ignores now stops the script before it writes.

README bullets split into short sentences, and a false sentence about
template repos deleted.

Model: opus-5-5
2026-11-09 19:38:41 +00:00
clawbot f4275b9ac0 script/install: stop on a repo's own script of the same name
check / check (push) Successful in 26s
script/install replaced any tracked script/install or
script/update-policy in the target, including a repo's own unrelated
script. It now stops when such a file has a line the canonical file
has never held. An older shared copy is still replaced.

The padded file in the self-replacement test is now an earlier
canonical version, so the new stop does not catch it.

Model: opus-5-5
2026-11-09 18:49:16 +00:00
clawbot 454f3410a1 Add script/install; script/update-policy runs it and commits (closes #165)
check / check (push) Successful in 31s
script/install fetches the policy files from prompts over HTTP and
writes them into a repo, installing itself and script/update-policy
there. script/update-policy fetches the current script/install, runs
it on its own repo and commits without pushing. The file list and the
rules for keeping a repo's own entries move to script/install.

Model: opus-5-5
2026-11-09 18:32:46 +00:00
12 changed files with 1776 additions and 825 deletions

No files matched your search

+1 -1
View File
@@ -42,7 +42,7 @@ RUN script/bootstrap
COPY . .
RUN timeout 90 test/update-policy.sh
RUN test/commit-upstream-policy-files.sh
# Development environment, and the last stage: a plain `docker build .`
# names no target and so builds this one. Nothing is wanted from the two
+6 -3
View File
@@ -1,4 +1,4 @@
.PHONY: bootstrap setup test lint fmt fmt-check check docker hooks update-policy
.PHONY: bootstrap setup test lint fmt fmt-check check docker hooks write-upstream-policy-files commit-upstream-policy-files
# Makefile targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
@@ -31,5 +31,8 @@ docker:
hooks:
@script/install-precommit
update-policy:
@script/update-policy
write-upstream-policy-files:
@script/write-upstream-policy-files
commit-upstream-policy-files:
@script/commit-upstream-policy-files
+9 -4
View File
@@ -119,7 +119,7 @@ alpine. We provide:
- `script/test` —
`docker build --build-arg CACHEBUST="$(date +%s)-$$" --target test --output type=cacheonly .`,
building the `test` phase of the `Dockerfile`, which runs
`test/update-policy.sh`, without writing an image
`test/commit-upstream-policy-files.sh`, without writing an image
- `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,
@@ -141,9 +141,14 @@ 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
- `script/write-upstream-policy-files` — fetch the vendored policy files from
this repo over HTTP and write them into a repo (our own extension). It writes
into the repo given as its argument, or its own repo with no argument. It
stops when its target is this repo.
- `script/commit-upstream-policy-files` — fetch the current
`script/write-upstream-policy-files`, run it on its own repo and commit the
result without pushing (our own extension). It is for the repos that vendor
the policy files. It stops when run here.
`make hooks` installs the pre-commit hook that runs `script/precommit`.
+432
View File
@@ -19,8 +19,440 @@ prompts/FIXUP_CLEAN.md (currently a near-empty stub) and prompts/FIXUP_REPORT.md
(a rough draft). Write the missing content, run `make fmt` so they pass
fmt-check, and commit.
# Completed Steps
- 2026-11-09: `script/write-upstream-policy-files` writes the vendored policy
files into a repo over HTTP, itself and `script/commit-upstream-policy-files`
among them (https://git.eeqj.de/sneak/prompts/issues/165).
`make commit-upstream-policy-files` replaces `make update-policy`. It fetches
the current `script/write-upstream-policy-files`, runs it and commits without
pushing. The template repositories get both scripts in their own issues.
- 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
pins `golines`. The Go style guide and both checklists say the same. `lll`
stays at 88, since `golines` leaves long strings and comments as they are.
- 2026-11-06: The pinning rule in `prompts/REPO_POLICIES.md` now opens with its
scope: what we download and install ourselves in scripts and Dockerfiles
(https://git.eeqj.de/sneak/prompts/issues/154). Distribution packages are
outside the rule, and updating and upgrading them is allowed. Both checklists
say the same.
- 2026-11-04: Each repository now commits yarn 4 and installs with
`yarn install --immutable` (https://git.eeqj.de/sneak/prompts/issues/149), on
sneak's ruling at
https://git.eeqj.de/sneak/prompts/issues/149#issuecomment-167558. Product
repositories convert when they next have work. These changed:
- This repository carries `.yarn/releases/yarn-4.18.1.cjs`, and its
`yarn.lock` is in yarn 4's format.
- `script/bootstrap` no longer stops on yarn's major version.
- Node comes from the distribution's newest Node package, which on Alpine
3.24 is `nodejs-current`.
- A `Dockerfile` stage that needs yarn also copies `.yarnrc.yml` and
`.yarn/releases/`, and may start from any distribution that packages node
and yarn.
- Both checklists give the steps, and the ignore files keep yarn's install
state out of git and the build context.
- 2026-11-04: An agent whose work is confined to one package may run `go test`
directly on that package's suite, or on individual tests in it, while
iterating (https://git.eeqj.de/sneak/prompts/issues/51), on sneak's ruling at
https://git.eeqj.de/sneak/prompts/issues/51#issuecomment-167600. The full
`make check` is still required before every push, and the `Makefile` gains
nothing. The rule on Docker now names the lint and test gates, because a run
while iterating is not one.
- 2026-11-04: node, yarn, npm, python, pip and venv now come from the
distribution with no version and no hash
(https://git.eeqj.de/sneak/prompts/issues/146), on sneak's ruling at
https://git.eeqj.de/sneak/prompts/issues/146#issuecomment-166033. Third-party
packages stay pinned by hash. These changed:
- `script/bootstrap` no longer uses nvm or corepack, and stops when yarn's
major version is not 1.
- `script/bootstrap` installs node, yarn and the node packages only when
`yarn.lock` is in the tree.
- `script/bootstrap`, `script/fmt` and `script/fmt-check` run `yarn` or
`yarnpkg`, whichever is present.
- This repository's `Dockerfile` starts from Alpine 3.24.
- The Python `test` phase example starts from `debian:trixie-slim`.
- A Python repository's `script/bootstrap` creates its virtual environment
at `$HOME/.pipenv`, so it works on a host and in a `Dockerfile`.
- `PIP_ONLY_BINARY=:all:` stops a package with no wheel, so pip downloads no
build tools without a hash check.
- 2026-11-04: Rewrote `prompts/REPO_POLICIES.md` sentence by sentence
(https://git.eeqj.de/sneak/prompts/issues/136), on sneak's yes at
https://git.eeqj.de/sneak/prompts/issues/136#issuecomment-165193. Every rule
keeps its meaning and its place, and `must` and `should` stay as they were.
Long sentences are split, comma-chained lists are now bulleted lists, and
every reason stays. Code blocks, digests, URLs, file names, commands and
quoted strings are unchanged.
- 2026-11-03: The canonical golangci-lint is now v2.15.0, still built with
go1.27.0, so the `go` directive rule is unchanged (issue 142). Repositories
pick this up on their next re-vendor. Each item in the canonical
`.golangci.yml` was tried on a scratch module. These changed:
- `canonicalheader` is enabled again, because v2.15.0 fixes the random
misses behind issue 105.
- The `depguard` exemption covers only the files directly inside a directory
whose name ends in `test`. It used to reach every directory below that one
too. The name of the directory a repository is cloned into no longer
counts.
- `gomodguard_v2` also blocks `github.com/go-redis/redis/v9`, whose
pre-releases can still be imported under that path.
- `uniq-by-line` is now `false`, so a line that trips two linters reports
both findings.
- Three comments are corrected:
- `exhaustruct` is listed as deprecated.
- The test double is named `github.com/go-redis/redismock/v9`.
- The claim that every blocked module is in the Go package defaults is
gone.
- A comment says the file has no `formatters` section, so
`golangci-lint run` does not run `gofmt`.
- 2026-11-03: A package from the distribution's own package index now needs no
version and no hash, and pulling that index is allowed
(https://git.eeqj.de/sneak/prompts/issues/72), on sneak's answer at
https://git.eeqj.de/sneak/prompts/issues/72#issuecomment-165196. The
distribution is the pinned base image's in a `Dockerfile` and the host's on a
host. It is trusted, and everything else stays pinned by hash. A package
source that a `Dockerfile` adds itself does not get the exception. The pinning
rule of `REPO_POLICIES.md` and both checklists say so, and the canonical Go
`Dockerfile`'s `apk add` line has a comment saying why it is allowed.
- 2026-11-03: A first test run that timed out now gets no verbose rerun
(https://git.eeqj.de/sneak/prompts/issues/61), on sneak's approval at
https://git.eeqj.de/sneak/prompts/issues/61#issuecomment-165195. The Go recipe
in `REPO_POLICIES.md` looks in the first run's output for
`panic: test timed out` or `*** Test killed`, and exits 1 when either is
there. The Python `make test` example takes the same `timeout 90 sh -c`
wrapper as its `Dockerfile` example. The 60 and 90 second rule and the
5-minute rule now say a hung package uses the backstop once. The Go item in
both repo checklists now asks for this, so a repository with the old recipe
fails it.
- 2026-10-27: Added `prompts/API_POLICIES.md`
(https://git.eeqj.de/sneak/prompts/issues/137). It holds sneak's rule from
https://git.eeqj.de/sneak/routewatch/issues/199 that a value in an API
response is never a string with a label or unit in it. `REPO_POLICIES.md` and
the code style commands in the README name the new file.
- 2026-10-21: Check builds may now also keep the Python packages a `test` phase
installs from `Pipfile.lock` (issue 133), on sneak's ruling in
https://git.eeqj.de/sneak/rfscan/issues/80#issuecomment-153437. The install
comes before `ARG CACHEBUST`: `COPY script/ script/`,
`COPY Pipfile Pipfile.lock ./`, then `RUN script/bootstrap`. In that step
`script/bootstrap` installs nothing beyond system packages, pipenv and its
dependencies by hash, and the packages from the lockfile. It installs no
Python, because the tests use the base image's. `--deploy` installs only what
the lockfile names, checks every package against its hashes, and fails when
the lockfile is out of date with `Pipfile`. A kept layer holds nothing a new
install would refuse. A package built from source and the system packages may
differ in a new install. As with Go's modules, the packages are kept in a
layer, not a cache mount. The tests still run after `ARG CACHEBUST`, and every
other stage still installs its Python packages after it. `REPO_POLICIES.md`
gains a short Python `test` phase example, and both checklists and the README
state the rule the same way. rfscan picks this up on its next re-vendor.
- 2026-10-21: The canonical `.golangci.yml` now accepts a local `replace` of
`github.com/grafana/sobek` in a repository's root `go.mod` (issue 134), for
homoicon, which keeps a local copy of sobek on sneak's decision in
https://git.eeqj.de/sneak/homoicon/issues/1759#issuecomment-153261. Every
other local `replace` is still refused. It is an exclusion rather than a
`replace-allow-list` entry because gomoddirectives does not check that list
for a `replace` that points at a local path. homoicon picks this up on its
next re-vendor.
- 2026-10-18: Check builds may now also keep Go's downloaded modules,
golangci-lint's cache and the layers that install system packages (issue 130),
on sneak's ruling there. In each stage of the canonical Go `Dockerfile`, the
system-package install and the module download (`COPY go.mod go.sum ./`,
`RUN go mod download`) come before `ARG CACHEBUST`, so Docker keeps them until
the base image, the install line, `go.mod` or `go.sum` changes. The modules
are kept as a layer, not a cache mount, because Go does not check the files in
a cache mount again. `golangci-lint run` also mounts golangci-lint's cache,
`/root/.cache/golangci-lint`. Every step that lints, tests or compiles still
comes after `ARG CACHEBUST` and runs on every check build, and both `go test`
runs keep `-count=1`. `REPO_POLICIES.md`, both checklists, the Go style guide,
the README and the script comments state the rule the same way. This repo's
own `Dockerfile` is unchanged: its `script/bootstrap` step also installs node
packages, which the ruling does not cover. Repositories pick this up on their
next re-vendor.
- 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
lint on one run and pass on the next. It comes back once a pinned
golangci-lint release fixes it.
- 2026-10-06: The canonical `.gitignore` and `.editorconfig` now each end with a
comment saying the repository's own entries go below it and a re-vendor keeps
them (issue 103, which took in issue 104), as `.dockerignore`'s header already
does. `.editorconfig` gains a `[*.go]` section with tabs, since `gofmt`
decides Go indentation everywhere. `REPO_POLICIES.md` and both checklists say
that each of these two files is the canonical content followed by the
repository's own entries, which a re-vendor keeps, and the Go styleguide puts
`*.log`, `*.out`, `*.test` and binaries among those entries. Common outputs
stay out of the canonical `.gitignore`; each repository lists its own.
- 2026-10-05: `script/cibuild`, `script/docker`, `script/lint` and `script/test`
now assign the image tag from `script/projectname` on its own line before the
`docker build` (issue 101), so `set -e` stops the script where
`script/projectname` fails instead of running `docker build` with a broken
tag. The comment above it in each script says why, and the snippets in
`REPO_POLICIES.md` show the same form. Repositories pick this up on their next
re-vendor.
- 2026-10-04: Went through the fleet findings recorded on 2026-08-09 (issue 62)
and added the two rules `REPO_POLICIES.md` did not yet state: a new or changed
check is proven by planting a defect it must catch; and a change to a separate
workflow limited to `main` is first run from the feature branch, added to that
workflow's `branches` list and removed again before merging. The other
findings were already stated, replaced by `--no-cache`, about git worktrees,
or about how agents work together. The warning against
`golangci-lint config verify` is dropped because sneak ruled on
https://git.eeqj.de/sneak/prompts/issues/40 (2026-08-10) that there is no
config check step and the config is assumed valid; a vendored `.golangci.yml`
stays byte-identical to the canonical copy. The issue gives each reason.
- 2026-10-04: `REPO_POLICIES.md` now says which `Dockerfile` stages run
`script/bootstrap` (issue 90). The gate phases and the build stage start from
their pinned base images and install what those images lack either inline, as
the canonical Go `Dockerfile` does for `git`, or by running
`script/bootstrap`, as this repo's own `Dockerfile` does for its yarn
packages. The development environment stage, the final stage of a non-server
repo, runs `script/bootstrap`. The new repo checklist says the same.
- 2026-10-04: Added a root `.gitattributes` that merges `TODO.md` with git's
union merge (issue 98), so two branches that each add an entry at the top of
Completed Steps merge without a conflict. Git now never reports a conflict in
`TODO.md`: a real conflict elsewhere keeps both versions of the line, and when
two new entries share an identical line, one is inserted into the middle of
the other, which a rebase can do to an entry already on `next`. Read the
merged entries after every merge or rebase. This applies to this repository
only; no canonical file changed.
- 2026-10-04: The canonical `.dockerignore` now also keeps out the git `config`
of a submodule that keeps its own `.git` directory, which still reached the
image (issue 88): both git patterns now carry the `**/` prefix. A submodule
whose name has a `config` segment (`config`, `deploy/config`, `config/lib`)
still loses its whole git directory, so Go's version stamping fails the build;
the file records this as a `KNOWN GAP:` with the remedy,
`git submodule add --name`. Closing it would take a wildcard re-include, which
makes BuildKit walk every excluded directory, such as `node_modules`, on every
build. `REPO_POLICIES.md` and both checklists say so in the same words.
- 2026-10-04: The note under the canonical Go `Dockerfile` example in
`REPO_POLICIES.md` now installs lint-phase system libraries with `apt-get`
under their Debian package names (issue 83). The `golangci/golangci-lint`
image is Debian-based and has no `apk`, so the old `apk add` instruction
failed as written. Nothing is pinned or unpinned; that is still open on
issue 72.
- 2026-10-04: Fixed the server lifecycle example in
`prompts/GO_HTTP_SERVER_CONVENTIONS.md` (issue 86). Only fx handles SIGINT and
SIGTERM, and `Run()` in `main` exits with the shutdown's exit code. A listen
error asks fx to shut down with exit code 1 through `fx.Shutdowner`; a Sentry
start failure is returned from the server's start hook instead of calling
`os.Exit` from a goroutine, so the stop hooks of what had started still run.
The server's stop hook shuts the HTTP server down within 5 seconds and fails
when requests are still running. A new paragraph says who owns signals and the
exit code.
- 2026-10-04: `REPO_POLICIES.md` now says how a Go tool a repo needs on the host
is pinned (issue 37): installed with `go install` pinned to a commit hash,
never tracked as a `go.mod` tool dependency or through a `tools.go` file.
golangci-lint is unaffected, since no repo installs it on the host.
- 2026-10-04: The canonical `.gitignore` and `.dockerignore` now also keep out
`id_ecdsa_sk` and `id_ed25519_sk`, the private key files `ssh-keygen` writes
for keys backed by a hardware security key (issue 81). Their `.pub` halves
stay trackable.
- 2026-10-04: `package.json` now has `"license": "MIT"`, matching `LICENSE`, so
yarn no longer prints "No license field" when `script/bootstrap` runs it
inside the Docker phases (issue 76). That was the only yarn warning there.
- 2026-10-04: `REPO_POLICIES.md` now states that guidance for coding agents
lives in one `AGENTS.md` at the repository root, never under a file or
directory named after one agent tool and never in separate memory files (issue
31). This retires the rule, still present in older vendored copies, that kept
agent memory as committed files under `.claude/memory/`. `AGENTS.md` joins the
list of files allowed in the root, and both checklists say so.
- 2026-10-04: Rewrote the note under the canonical Go `make test` example in
`REPO_POLICIES.md` (issue 77), which still named the cache-busting build
argument that `--no-cache` replaced. It now says where Go's test result cache
can replay a pass: on a developer's machine, where the Makefile target runs,
and not in the `test` phase of the `Dockerfile`, whose base image and earlier
steps hold no result for the repo's tests.
- 2026-10-04: The Makefile examples in the Go styleguide and the HTTP server
conventions now fall back to `dev` when `git describe` prints nothing (outside
a git checkout, or where git is missing or refuses the checkout), instead of
stamping an empty version (issue 74). The canonical `Dockerfile` already fails
on a `dev` version when `.git` is in the build context.
- 2026-10-04: The canonical `.dockerignore` now also keeps out each submodule's
`config` (issue 75). A submodule's git directory lives under `.git/modules/`,
nested again for its own submodules, and its `config` can hold a credential
just like `.git/config`. The pattern `.git/modules/**/config` covers every
depth and leaves the top-level `.git` that `git describe` reads untouched.
`REPO_POLICIES.md` and both checklists say so in the same words.
- 2026-10-03: Fixed two defects in the canonical Go `Dockerfile` example (issue
73). The test phase now uses the Debian Go image, since `-race` needs cgo and
the alpine image has no C compiler, so the phase failed before running a test.
The stage that compiles runs `git config --system --add safe.directory /src`,
because a context sent as a tar stream keeps the sender's file owners and git
refuses that checkout, leaving the version empty. Both checklists state that
step in the same words.
- 2026-10-03: Moved the canonical golangci-lint to v2.14.0, built with go1.27,
because v2.12.2 refuses to lint a module whose `go` directive is 1.27 (issue
65). Releases from v2.13.0 deprecate `exhaustruct` in favour of
`exhaustruct_v5`, which `default: all` switches on, so the canonical
`.golangci.yml` now disables `exhaustruct_v5` beside `exhaustruct`. v2.12.2
rejects that file, so `REPO_POLICIES.md` and both repo checklists now say a
repo sets the lint phase digest and re-vendors `.golangci.yml` in one commit.
- 2026-10-03: Brought the canonical `.gitignore` level with `.dockerignore` on
secrets (issue 38): it now also ignores `prod.env`-style `*.env` files,
`.envrc`, `*.p12`, `*.pfx` and the extensionless SSH private keys, written to
`.gitignore`'s own rules (no `**/` prefix) and case-folded with character
ranges. `example.env` and `sample.env` stay trackable through negations.
- 2026-10-02: The image version now comes from git inside the build (issues 69
and 71), superseding the 2026-09-08 entry that excluded `.git`. The canonical
`.dockerignore` sends `.git` but keeps out `.git/config`, which can hold a
credential. The Dockerfile example in `REPO_POLICIES.md` installs `git`, takes
the `VERSION` build argument when one is given and otherwise
`git describe --tags --always`, and fails when `.git` exists but the version
is empty, `dev` or `unknown`; a plain `docker build .` with no build arguments
must succeed. This repo's `script/docker` and `script/cibuild` still pass
`--build-arg VERSION`, since its own `Dockerfile` compiles nothing.
- 2026-09-08: Moved linting and testing into Docker as phases of the main
`Dockerfile`, per the owner ruling on issue 40. `script/lint` and
`script/test` build one phase each by name with `--no-cache` — the same answer
issue 26 got, so no separate cache-busting mechanism survives — and the final
stage copies a harmless file from both, so the image cannot be built unless
they pass. This also closes issue 30: a container has its own result cache and
its own lock, so a lint verdict can no longer belong to another checkout. No
separate lint Dockerfile, and no `golangci-lint config verify` step.
`script/check` runs the gates and nothing else, and `script/cibuild`
bootstraps first, since it is all CI runs and `script/fmt-check` is native.
- 2026-09-08: Kept in-repo agent scratch out of the Docker build context and out
of version control: `.claude/` is one full checkout of the repo per in-flight
agent, and under `COPY . .` all of it was reaching the image. Also closed the
consequence of excluding `.git` — `git describe` yields an empty version
inside a build stage without failing, so `script/docker` and `script/cibuild`
now compute the version on the host and pass `--build-arg VERSION`.
- 2026-09-08: Closed the secret exposure in the canonical `.dockerignore`: a
local `.env`, `*.pem` or `*.key` was reaching the build context under
`COPY . .`, invisible to every git-based check. The patterns are now written
to `.dockerignore`'s own semantics — `**/`-prefixed so they hold at every
depth, case-folded with character ranges — and `REPO_POLICIES.md` requires
verifying by enumerating the image rather than by reading the file.
- 2026-09-08: Made a pinned tool in `script/bootstrap` actually reach the host.
`REPO_POLICIES.md` now requires comparing the installed version against the
pin rather than testing `PATH` presence, and re-resolving the binary through
`PATH` after installing, so a version bump cannot be a silent no-op and a
shadowed install cannot report success.
- 2026-09-08: Closed the false green in the canonical CI gate: `script/cibuild`
and `script/docker` now build with `--no-cache`, so the Dockerfile's check
layers cannot be served from cache on an unchanged tree, and the text claiming
a bare `docker build .` proves the checks ran is corrected in
`REPO_POLICIES.md`, both checklists and the Go styleguide.
- 2026-09-03: Added `-count=1` to both `go test` invocations in the canonical Go
`make test` example in `REPO_POLICIES.md`, so the target cannot report a
cached pass it did not earn, and documented that Go's test-result cache is a
second, independent cache stacked below the Docker layer cache.
- 2026-08-31: Migrated the canonical `.golangci.yml` from the deprecated
`gomodguard` to `gomodguard_v2`: the old linter is disabled by name (which is
what silences the deprecation warning), the successor is named explicitly in
`linters.enable`, and it carries a `blocked` module list drawn only from
decisions already recorded in the Go package defaults.
- 2026-08-07: Set the canonical `.golangci.yml` to the org-standard v2-schema
config already deployed byte-identical across the org's Go repos (settings
under `linters.settings` so thresholds like lll/funlen/cyclop/dupl actually
apply under golangci-lint v2). Recorded the canonical golangci-lint version
(v2.12.2, commit-pinned) in REPO_POLICIES.md.
- 2026-03-20: Strengthened constructor naming and Params struct rules in the Go
styleguide.
- 2026-03-18: Documented fail-fast Dockerfile lint stage and conditional -v test
rerun patterns in REPO_POLICIES.md.
- 2026-03-11: Added HTTP service hardening policy for 1.0 releases.
- 2026-03-10: Added policy: no build artifacts in repos.
- 2026-03-04: Added LLM prose tells reference and copyediting checklist, then
several self-applied revision passes.
- 2026-02-28: Expanded the pre-1.0 schema migration rule; added clawpub
reference.
- 2026-02-23: Added Go style rules (no type-only packages, Stringer for
string-based types); template repos section in README.
- 2026-02-22: Initial policy corpus: REPO_POLICIES.md, code styleguides
(general, Go, JS, Python), repo checklists, CI policy, hash pinning, Go HTTP
server conventions, repo scaffolding.
# Future Steps
- Finish, format, and commit FIXUP_CLEAN.md and FIXUP_REPORT.md (the Next Step).
- Commit this TODO.md at the repo root; it is the last missing policy file.
- Decide the fate of untracked resume.sh: commit it or delete it.
- Add more prompt templates for common development tasks (from README TODO).
+20 -11
View File
@@ -1,6 +1,6 @@
---
title: Existing Repo Checklist
last_modified: 2026-11-09
last_modified: 2026-11-11
---
Use this checklist when beginning work in a repo that may not yet conform to our
@@ -205,21 +205,30 @@ 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`, `update-policy`
`hooks`, `commit-upstream-policy-files`
- [ ] 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. An entry above that comment which
the canonical file has never held makes the script stop before it writes
anything, naming the file and the entry. It stops the same way on a line
in `.golangci.yml` that the canonical file has never held, apart from
comments and the repo's entries in the `deny` list of the `test-support`
rule.
- [ ] `script/write-upstream-policy-files` and
`script/commit-upstream-policy-files` exist, identical to the model
scripts, and `make commit-upstream-policy-files` shims to
`script/commit-upstream-policy-files`. `make commit-upstream-policy-files`
commits without pushing, so a person reviews and pushes the commit. A
`.gitignore` or `.dockerignore` without the comment that ends the
canonical file needs no preparation. The script moves each line the
canonical file has never held to below the canonical content. Blank lines
and comments are dropped. Before its first use, add that comment to an
`.editorconfig` that has lines of its own, and put those lines below it.
Otherwise the script stops before it writes anything, naming the file and
each line. In a file that has the comment, an entry above it which the
canonical file has never held also stops the script. It stops the same way
on a line in `.golangci.yml` that the canonical file has never held, apart
from comments and the repo's entries in the `deny` list of the
`test-support` rule. It also stops on a repo's own
`script/write-upstream-policy-files` or
`script/commit-upstream-policy-files`.
- [ ] README has an **Entrypoints** section documenting the `script/`
entrypoints and linking the standard
- [ ] `script/lint` and `script/test` each run
+7 -3
View File
@@ -1,6 +1,6 @@
---
title: New Repo Checklist
last_modified: 2026-11-09
last_modified: 2026-11-11
---
Use this checklist when creating a new repository from scratch. Follow the steps
@@ -266,9 +266,13 @@ 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
- [ ] `script/write-upstream-policy-files` — fetches the vendored policy files
from the `prompts` repo over HTTP and writes them into the repo; identical
across repos
- [ ] `script/commit-upstream-policy-files` /
`make commit-upstream-policy-files` — fetches the current
`script/write-upstream-policy-files`, runs it on the repo and commits the
result without pushing; identical across repos
- [ ] README **Entrypoints** section documents the scripts and links the
standard
+32 -14
View File
@@ -1,6 +1,6 @@
---
title: Repository Policies
last_modified: 2026-11-09
last_modified: 2026-11-11
---
This document covers repository structure, tooling, and workflow standards. Code
@@ -93,8 +93,8 @@ style conventions are in separate documents:
- `make check` (runs `test`, `lint`, `fmt-check`)
- `make docker`
- `make hooks` (installs pre-commit hook)
- `make update-policy` (fetches the vendored policy files, commits and
pushes)
- `make commit-upstream-policy-files` (brings the repo up to standards and
commits)
A model Makefile is at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
@@ -111,7 +111,8 @@ style conventions are in separate documents:
- `script/fmt-check`
- `script/check`
- `script/docker`
- `script/update-policy`
- `script/write-upstream-policy-files`
- `script/commit-upstream-policy-files`
The scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they
run in minimal containers; alpine images, for example, have no bash. Locate
@@ -185,15 +186,19 @@ style conventions are in separate documents:
host. On a pristine checkout with nothing installed, the run dies there,
after the containerised gates have passed.
Five further scripts are our own extensions to the standard:
Six 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.
- `script/update-policy` fetches the vendored policy files from the
`prompts` repo, commits them and pushes. The `make update-policy` target
shims to it.
- `script/write-upstream-policy-files` fetches the vendored policy files
from the `prompts` repo over HTTP and writes them into the repo it is
given, or its own repo with no argument.
- `script/commit-upstream-policy-files` fetches the current
`script/write-upstream-policy-files` from the `prompts` repo, runs it on
its own repo and commits the result. It does not push. The
`make commit-upstream-policy-files` target shims to it.
- `script/projectname` (literally that filename) simply outputs the
project's name.
@@ -933,14 +938,26 @@ style conventions are in separate documents:
- Make all changes on a feature branch. You can do whatever you want on a
feature branch.
- A repo updates its policy files with one command: `make update-policy`. It
runs `script/update-policy`, which fetches each vendored file the repo carries
from the `prompts` repo, commits them and pushes. It uses the head of `main`,
or the commit given by a full hash: `script/update-policy <hash>`. The list of
vendored files is at the top of that script. The script replaces each file and
- One command brings a repo up to standards:
`make commit-upstream-policy-files`. It runs
`script/commit-upstream-policy-files`, which fetches the current
`script/write-upstream-policy-files` from the head of `main` in the `prompts`
repo and runs it on the repo. It then commits the files that changed and does
not push, so a person reviews and pushes the commit.
`script/write-upstream-policy-files` fetches each vendored file from
`prompts` over HTTP and writes it into the repo. It skips a language's files
when git tracks no source file of that language. A file the repo lacks is
written whole. A `prompts/NAME.md` goes to `docs/NAME.md`, unless the repo
keeps it at `NAME.md`. It also writes `script/write-upstream-policy-files`
and `script/commit-upstream-policy-files` into the repo, so a repo updated
once can update itself. The list of vendored files is at the top of
`script/write-upstream-policy-files`. The script replaces each file and
keeps only the repo's own entries:
- in `.gitignore`, `.editorconfig` and `.dockerignore`: the lines below the
comment that ends the canonical file
- in a `.gitignore` or `.dockerignore` lacking that comment: every entry the
canonical file has never held
- in `.golangci.yml`: the entries the repo added to the `deny` list of the
`test-support` depguard rule
@@ -1169,7 +1186,8 @@ style conventions are in separate documents:
- `Makefile`
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
`install-precommit`, `update-policy`)
`install-precommit`, `write-upstream-policy-files`,
`commit-upstream-policy-files`)
- `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml`
+45
View File
@@ -0,0 +1,45 @@
#!/bin/sh
# script/commit-upstream-policy-files: bring this repo up to standards.
# It fetches the current script/write-upstream-policy-files from the
# prompts repo over HTTP, runs it on this repo and commits the files it
# wrote. It does not push. Identical in all repos. Our own extension to
# scripts-to-rule-them-all.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
WRITE_UPSTREAM_POLICY_FILES_URL="https://git.eeqj.de/sneak/prompts/raw/branch/main/script/write-upstream-policy-files"
die() {
echo "commit-upstream-policy-files: $*" >&2
exit 1
}
main() {
[ "$#" -eq 0 ] || die "usage: script/commit-upstream-policy-files"
cd "$ROOT"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
curl --fail --silent --show-error --max-time 60 \
--output "$tmp/write-upstream-policy-files" \
"$WRITE_UPSTREAM_POLICY_FILES_URL" ||
die "cannot fetch $WRITE_UPSTREAM_POLICY_FILES_URL"
sh "$tmp/write-upstream-policy-files" "$ROOT" > "$tmp/written"
commit="$(head -n 1 "$tmp/written")"
paths="$(tail -n +2 "$tmp/written")"
for path in $paths; do
git add -- "$path"
done
if git diff --cached --quiet -- $paths; then
echo "commit-upstream-policy-files: already at prompts $commit, nothing to commit"
else
git commit -m "Update policy files from prompts $commit" -- $paths
fi
}
main "$@"
-222
View File
@@ -1,222 +0,0 @@
#!/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. An entry above that line which the
# canonical file has never held stops the script.
# - .golangci.yml: the entries the repo added to the deny list of the
# test-support rule. Any other line the canonical file has never
# held stops the script, unless it is blank or a comment.
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
}
# local_only_lines <fetched> <local> [end]: print each line of the local
# file, above line end if given, that the canonical file has never held:
# not in the fetched file or in <fetched>.former, the lines earlier
# commits removed from it. Blank lines and comments do not count.
local_only_lines() {
cat "$1" "$1.former" | awk -v end="${3:-}" '
NR == FNR { held[$0]; next }
(end == "" || FNR < end) && !/^[ \t]*(#|$)/ && !($0 in held)
' - "$2"
}
# keep_lines_below_last <fetched> <local>: append to the fetched file
# whatever follows its last line in the local file. Stop if the canonical
# file has never held a line above that one, unless it is blank or a
# comment: replacing the file would delete it.
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
own="$(local_only_lines "$1" "$2" "$number")"
if [ -n "$own" ]; then
die "$2 has entries above its last canonical line that the canonical file lacks; move each below that line to keep it, or delete it:
$own"
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. Stop if the local file still has a line that neither the
# result nor the canonical file has ever held, unless it is blank or a
# comment: replacing the file would delete 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
}
in_rule && /^ *[^ #]/ && indent($0) <= rule_indent { in_rule = 0 }
/^ *test-support:/ { in_rule = 1; rule_indent = indent($0) }
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"
own="$(local_only_lines "$1" "$2")"
if [ -n "$own" ]; then
die "$2 has lines the canonical file has never held; only the deny list of the test-support rule may differ from canonical:
$own"
fi
}
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
git clone --quiet --bare "$PROMPTS_URL.git" "$tmp/prompts.git" ||
die "cannot clone $PROMPTS_URL.git"
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"
git -C "$tmp/prompts.git" log -p --format= "$commit" -- "$file" |
sed -n 's/^-//p' > "$tmp/$path.former"
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"
else
git commit -m "Update policy files from prompts $commit" -- $carried
fi
# Also pushes when there was nothing to commit, so a run after a
# failed push delivers the commit that push left behind.
git push origin HEAD || die "push failed; fix that and run this again"
}
main "$@"
+302
View File
@@ -0,0 +1,302 @@
#!/bin/sh
# script/write-upstream-policy-files: write the current policy files
# from the prompts repo into a repo clone, over HTTP. It commits
# nothing; script/commit-upstream-policy-files does that. Identical in
# all repos. Our own extension to scripts-to-rule-them-all.
#
# Usage: script/write-upstream-policy-files [path of a repo clone]
# Without a path it updates the repo this script sits in.
#
# Standard output is the full hash of the prompts commit, then each path
# written, one per line. Messages go to standard error.
#
# 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. An entry above that line which the
# canonical file has never held stops the script.
# - .gitignore, .dockerignore lacking that last line: each line the
# canonical file has never held, unless it is blank or a comment,
# goes below the canonical content.
# - .editorconfig lacking that last line: it is replaced if it has no
# such line. Otherwise the script stops, because a setting means
# nothing away from its section.
# - .golangci.yml: the entries the repo added to the deny list of the
# test-support rule. Any other line the canonical file has never
# held stops the script, unless it is blank or a comment.
set -eu
PROMPTS_URL="https://git.eeqj.de/sneak/prompts"
PROMPTS_API_URL="https://git.eeqj.de/api/v1/repos/sneak/prompts"
PROMPTS_FIRST_COMMIT="af257bef63f162a681bddc80d9b941c47a44b748"
# Paths in the prompts repo. Every repo gets each file, written whole if
# the repo lacks it. A language's files are skipped when git tracks no
# source file of that language.
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
script/write-upstream-policy-files
script/commit-upstream-policy-files
"
die() {
echo "write-upstream-policy-files: $*" >&2
exit 1
}
# fetch <url> <output file>
fetch() {
curl --fail --silent --show-error --max-time 60 --output "$2" "$1" ||
die "cannot fetch $1"
}
# file_applies <path in prompts>: fail if the file is for a language of
# which git tracks no source file in the target repo.
file_applies() {
case "$1" in
.golangci.yml | prompts/CODE_STYLEGUIDE_GO.md | \
prompts/GO_HTTP_SERVER_CONVENTIONS.md)
[ -n "$(git ls-files -- '*.go')" ]
;;
prompts/CODE_STYLEGUIDE_JS.md)
[ -n "$(git ls-files -- '*.js' '*.mjs' '*.ts')" ]
;;
prompts/CODE_STYLEGUIDE_PYTHON.md)
[ -n "$(git ls-files -- '*.py')" ]
;;
esac
}
# local_path <path in prompts>: print where the file goes in the target
# repo. A prompts/NAME.md stays at NAME.md if git tracks it there and not
# at docs/NAME.md; otherwise it goes to docs/NAME.md. Any other file goes
# to its own path.
local_path() {
name="${1#prompts/}"
if [ "$name" = "$1" ]; then
echo "$1"
elif [ -z "$(git ls-files -- "docs/$name")" ] &&
[ -n "$(git ls-files -- "$name")" ]; then
echo "$name"
else
echo "docs/$name"
fi
}
# former_lines <patch> <path in prompts>: print the lines earlier commits
# removed from the file. The file's section of the patch ends at the
# next diff or the next commit.
former_lines() {
awk -v header="diff --git a/$2 b/$2" '
/^diff --git / { inside = ($0 == header); next }
/^From / { inside = 0; next }
inside && /^-/ { print substr($0, 2) }
' "$1"
}
# local_only_lines <fetched> <local> [end]: print each line of the local
# file, above line end if given, that the canonical file has never held:
# not in the fetched file or in <fetched>.former, the lines earlier
# commits removed from it. Blank lines and comments do not count.
local_only_lines() {
cat "$1" "$1.former" | awk -v end="${3:-}" '
NR == FNR { held[$0]; next }
(end == "" || FNR < end) && !/^[ \t]*(#|$)/ && !($0 in held)
' - "$2"
}
# keep_lines_below_last <fetched> <local>: append to the fetched file
# whatever follows its last line in the local file. Stop if the canonical
# file has never held a line above that one, unless it is blank or a
# comment: replacing the file would delete it. If the local file lacks
# that last line, append each line the canonical file has never held,
# unless it is blank or a comment. An .editorconfig with such a line
# stops the script instead, because a setting means nothing away from
# its section.
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
own="$(local_only_lines "$1" "$2")"
[ -n "$own" ] || return 0
if [ "$2" = .editorconfig ]; then
die "$2 lacks this line and has lines the canonical file has never held; add it and put those lines below it: $last
$own"
fi
printf '%s\n' "$own" >> "$1"
return 0
fi
own="$(local_only_lines "$1" "$2" "$number")"
if [ -n "$own" ]; then
die "$2 has entries above its last canonical line that the canonical file lacks; move each below that line to keep it, or delete it:
$own"
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. Stop if the local file still has a line that neither the
# result nor the canonical file has ever held, unless it is blank or a
# comment: replacing the file would delete 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
}
in_rule && /^ *[^ #]/ && indent($0) <= rule_indent { in_rule = 0 }
/^ *test-support:/ { in_rule = 1; rule_indent = indent($0) }
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"
own="$(local_only_lines "$1" "$2")"
if [ -n "$own" ]; then
die "$2 has lines the canonical file has never held; only the deny list of the test-support rule may differ from canonical:
$own"
fi
}
# stop_on_own_script <fetched> <local>: stop if the local script has a
# line the canonical file has never held, unless it is blank or a
# comment: the repo wrote that script itself, and replacing it would
# delete it.
stop_on_own_script() {
own="$(local_only_lines "$1" "$2")"
if [ -n "$own" ]; then
die "$2 has lines the canonical file has never held; rename the repo's own script to keep it:
$own"
fi
}
main() {
[ "$#" -le 1 ] ||
die "usage: script/write-upstream-policy-files [path of a repo clone]"
target="${1:-$(dirname "$0")/..}"
cd "$target" || die "no such directory: $target"
if [ "$(git rev-parse --show-toplevel 2> /dev/null)" != "$(pwd -P)" ]; then
die "not the top of a git clone: $target"
fi
if [ -e prompts/REPO_POLICIES.md ]; then
die "this repo is the source of the policy files"
fi
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
fetch "$PROMPTS_API_URL/branches/main" "$tmp/branch.json"
commit="$(grep -Eo '"id":"[0-9a-f]{40}"' "$tmp/branch.json" |
head -n 1 | cut -d'"' -f4)"
[ -n "$commit" ] || die "no commit hash in $PROMPTS_API_URL/branches/main"
fetch "$PROMPTS_URL/compare/$PROMPTS_FIRST_COMMIT...$commit.patch" \
"$tmp/history.patch"
to_write=""
for file in $VENDORED_FILES; do
file_applies "$file" || continue
path="$(local_path "$file")"
if git check-ignore -q -- "$path"; then
die "$path is ignored by git; stop ignoring it"
fi
if [ -n "$(git status --porcelain -- "$path")" ]; then
die "$path has uncommitted changes"
fi
mkdir -p "$tmp/$(dirname "$path")"
fetch "$PROMPTS_URL/raw/commit/$commit/$file" "$tmp/$path"
former_lines "$tmp/history.patch" "$file" > "$tmp/$path.former"
to_write="$to_write $path"
done
for path in $to_write; do
[ -e "$path" ] || continue
case "$path" in
.gitignore | .editorconfig | .dockerignore)
keep_lines_below_last "$tmp/$path" "$path"
;;
.golangci.yml)
keep_deny_entries "$tmp/$path" "$path"
;;
script/*)
stop_on_own_script "$tmp/$path" "$path"
;;
esac
done
echo "$commit"
for path in $to_write; do
mkdir -p "$(dirname "$path")"
case "$path" in
script/*)
# A new name and mv, because the script running may be
# script/commit-upstream-policy-files.
cp "$tmp/$path" "$path.new"
chmod 755 "$path.new"
mv "$path.new" "$path"
;;
*)
cp "$tmp/$path" "$path"
;;
esac
echo "$path"
done
}
main "$@"
+922
View File
@@ -0,0 +1,922 @@
#!/bin/sh
# test/commit-upstream-policy-files.sh: test
# script/write-upstream-policy-files and script/commit-upstream-policy-files
# on throwaway repos. Each scenario gets one temp directory holding a
# repo, its local bare origin, a local git repository of the canonical
# files in place of prompts, and a stub curl that serves them, so nothing
# touches the network.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd -P)/script"
WRITE_UPSTREAM_POLICY_FILES="$SCRIPT_DIR/write-upstream-policy-files"
COMMIT_UPSTREAM_POLICY_FILES="$SCRIPT_DIR/commit-upstream-policy-files"
dir=""
trap 'rm -rf "$dir"' EXIT
fail() {
echo "FAIL: $scenario: $*" >&2
cat "$dir/output" >&2
exit 1
}
# The stub stops with curl's HTTP error status on any URL containing
# FAIL_URL, and answers with no commit hash when BAD_BRANCH_JSON is set.
write_stub_curl() {
mkdir "$dir/bin"
cat > "$dir/bin/curl" << 'EOF'
#!/bin/sh
previous=""
fail_flag=""
for argument in "$@"; do
[ "$previous" = "--output" ] && output="$argument"
[ "$argument" = "--fail" ] && fail_flag=yes
previous="$argument"
done
url="$previous"
canonical="$(dirname "$0")/../canonical"
site="https://git.eeqj.de/sneak/prompts"
first="af257bef63f162a681bddc80d9b941c47a44b748"
not_found() {
[ -n "$fail_flag" ] && exit 22
echo "404 page not found" > "$output"
exit 0
}
serve_file() {
[ -f "$canonical/$1" ] || not_found
cat "$canonical/$1" > "$output"
}
case "$url" in
*"${FAIL_URL:-no url contains this}"*)
not_found
;;
"https://git.eeqj.de/api/v1/repos/sneak/prompts/branches/main")
if [ -n "${BAD_BRANCH_JSON:-}" ]; then
echo '{"message":"no such branch"}' > "$output"
else
printf '{"name":"main","commit":{"id":"%s"},"x":{"id":"%s"}}\n' \
"$HASH" 0000000000000000000000000000000000000000 > "$output"
fi
;;
"$site/raw/branch/main/script/write-upstream-policy-files")
serve_file script/write-upstream-policy-files
;;
"$site/compare/$first...$HASH.patch")
git -C "$canonical" format-patch --stdout --root "$HASH" > "$output"
;;
"$site/raw/commit/$HASH/"*)
serve_file "${url#"$site/raw/commit/$HASH/"}"
;;
*)
not_found
;;
esac
EOF
chmod +x "$dir/bin/curl"
}
write_canonical_files() {
mkdir -p "$dir/canonical/prompts" "$dir/canonical/script"
cp "$WRITE_UPSTREAM_POLICY_FILES" "$COMMIT_UPSTREAM_POLICY_FILES" \
"$dir/canonical/script/"
cat > "$dir/canonical/.gitignore" << 'EOF'
*.log
*.tmp
# Own entries go below this comment.
EOF
cat > "$dir/canonical/.dockerignore" << 'EOF'
**/*.log
**/*.tmp
# Own entries go below this comment.
EOF
cat > "$dir/canonical/.editorconfig" << 'EOF'
root = true
[*]
indent_size = 4
# Own sections go below this comment.
EOF
cat > "$dir/canonical/.golangci.yml" << 'EOF'
linters:
settings:
depguard:
rules:
test-support:
deny:
- pkg: net/http/httptest
desc: canonical
- pkg: net/http/cgi
desc: added canonically
other:
deny:
- pkg: example.com/other
EOF
echo '{ "tabWidth": 4 }' > "$dir/canonical/.prettierrc"
echo "new policy" > "$dir/canonical/prompts/REPO_POLICIES.md"
echo "new api policy" > "$dir/canonical/prompts/API_POLICIES.md"
for name in CODE_STYLEGUIDE CODE_STYLEGUIDE_GO CODE_STYLEGUIDE_JS \
CODE_STYLEGUIDE_PYTHON EXISTING_REPO_CHECKLIST \
GO_HTTP_SERVER_CONVENTIONS LLM_PROSE_TELLS NEW_REPO_CHECKLIST; do
echo "new $name" > "$dir/canonical/prompts/$name.md"
done
mkdir -p "$dir/canonical/.gitea/workflows"
echo "name: check" > "$dir/canonical/.gitea/workflows/check.yml"
}
# commit_canonical: commit the canonical files as they stand. The stub
# curl serves only the newest commit; every other commit gets a 404.
commit_canonical() {
git -C "$dir/canonical" add .gitignore .dockerignore .editorconfig \
.golangci.yml .prettierrc .gitea prompts script
git -C "$dir/canonical" commit -q -m "Canonical"
HASH="$(git -C "$dir/canonical" rev-parse HEAD)"
}
# write_outdated_files: this repo's files, one canonical version behind,
# with the repo's own entries in the places the script keeps them. The
# comment above the closing comment in .gitignore is replaced, not kept.
write_outdated_files() {
cat > "$dir/repo/.gitignore" << 'EOF'
*.log
# An outdated comment.
# Own entries go below this comment.
/myapp
EOF
cat > "$dir/repo/.dockerignore" << 'EOF'
**/*.log
# Own entries go below this comment.
/myapp
EOF
cat > "$dir/repo/.editorconfig" << 'EOF'
root = true
[*]
# Own sections go below this comment.
[*.py]
indent_size = 2
EOF
cat > "$dir/repo/.golangci.yml" << 'EOF'
linters:
settings:
depguard:
rules:
test-support:
deny:
- pkg: net/http/httptest
desc: canonical
# Our own test helpers.
- pkg: example.com/app/internal/apptest
desc: own entry
other:
deny:
- pkg: example.com/other
EOF
echo '{ "tabWidth": 2 }' > "$dir/repo/.prettierrc"
echo "old policy" > "$dir/repo/REPO_POLICIES.md"
}
# start_scenario <name>: make a new temp directory holding a repo with
# a Go source file and the outdated files, not yet committed, and an
# empty origin.
start_scenario() {
scenario="$1"
rm -rf "$dir"
dir="$(mktemp -d)"
: > "$dir/output"
mkdir "$dir/tmp"
write_stub_curl
write_canonical_files
export GIT_CONFIG_GLOBAL="$dir/gitconfig"
git config --global user.name "Test"
git config --global user.email "test@example.com"
export FAIL_URL=""
export BAD_BRANCH_JSON=""
git init -q -b main "$dir/canonical"
export HASH
commit_canonical
git init -q --bare -b main "$dir/origin.git"
git init -q -b main "$dir/repo"
git -C "$dir/repo" remote add origin "$dir/origin.git"
mkdir "$dir/repo/script"
cp "$WRITE_UPSTREAM_POLICY_FILES" "$COMMIT_UPSTREAM_POLICY_FILES" \
"$dir/repo/script/"
printf '#!/bin/sh\necho example\n' > "$dir/repo/script/projectname"
chmod +x "$dir/repo/script/projectname"
mkdir -p "$dir/repo/cmd/app"
echo "package main" > "$dir/repo/cmd/app/main.go"
write_outdated_files
}
# commit_and_push_fixture <paths...>: commit those paths as they stand
# and every other shared file as the canonical file has it, so the
# script changes only those paths. Record the commit as $before.
commit_and_push_fixture() {
(
cd "$dir/repo"
git add script cmd "$@"
git clean -q -f -x
for file in $(git -C "$dir/canonical" ls-files); do
case "$file" in
script/*) continue ;;
prompts/REPO_POLICIES.md) path="REPO_POLICIES.md" ;;
prompts/*) path="docs/${file#prompts/}" ;;
*) path="$file" ;;
esac
if [ -z "$(git ls-files -- "$path")" ]; then
mkdir -p "$(dirname "$path")"
cp "$dir/canonical/$file" "$path"
git add "$path"
fi
done
git commit -q -m "Fixture"
git push -q origin HEAD
)
before="$(git -C "$dir/repo" rev-parse HEAD)"
}
# remove_from_fixture <paths...>: delete those paths from the repo and
# its origin, and record the commit as $before.
remove_from_fixture() {
(
cd "$dir/repo"
git rm -q -r "$@"
git commit -q -m "Remove from fixture"
git push -q origin HEAD
)
before="$(git -C "$dir/repo" rev-parse HEAD)"
}
run_commit_upstream_policy_files() {
status=0
(cd "$dir/repo" && PATH="$dir/bin:$PATH" TMPDIR="$dir/tmp" \
script/commit-upstream-policy-files "$@") > "$dir/output" 2>&1 ||
status=$?
}
# run_write_upstream_policy_files <arguments>: run the script in this
# source tree, from outside the repo it updates.
run_write_upstream_policy_files() {
status=0
(cd "$dir" && PATH="$dir/bin:$PATH" TMPDIR="$dir/tmp" \
"$WRITE_UPSTREAM_POLICY_FILES" "$@") > "$dir/output" 2>&1 ||
status=$?
}
head_of_repo() {
git -C "$dir/repo" rev-parse HEAD
}
head_of_origin() {
git -C "$dir/origin.git" rev-parse refs/heads/main
}
files_in_commit() {
git -C "$dir/repo" show --name-only --format= HEAD | tr '\n' ' '
}
expect_success() {
[ "$status" -eq 0 ] || fail "exited $status"
}
expect_failure() {
[ "$status" -ne 0 ] || fail "exited 0"
}
expect_output() {
grep -q -F -e "$1" "$dir/output" || fail "output lacks: $1"
}
expect_output_without() {
if grep -q -F -e "$1" "$dir/output"; then
fail "output has: $1"
fi
}
# expect_file <path>: the file must equal standard input.
expect_file() {
cat > "$dir/expected"
diff "$dir/expected" "$dir/repo/$1" >&2 || fail "$1 is wrong"
}
expect_same_as_canonical() {
diff "$dir/canonical/$1" "$dir/repo/$1" >&2 || fail "$1 is not canonical"
}
expect_absent() {
[ ! -e "$dir/repo/$1" ] || fail "$1 exists"
}
expect_executable() {
[ "$(stat -c %a "$dir/repo/$1")" = 755 ] || fail "$1 is not mode 755"
}
expect_committed_executable() {
git -C "$dir/repo" ls-files -s -- "$1" | grep -q '^100755 ' ||
fail "$1 is not committed as executable"
}
expect_new_commit_not_pushed() {
[ "$(head_of_repo)" != "$before" ] || fail "no commit was made"
[ "$(head_of_origin)" = "$before" ] || fail "the commit was pushed"
}
expect_no_commit() {
[ "$(head_of_repo)" = "$before" ] || fail "the repo has a new commit"
[ "$(head_of_origin)" = "$before" ] || fail "origin has a new commit"
}
expect_nothing_written() {
[ -z "$(git -C "$dir/repo" status --porcelain)" ] ||
fail "the working tree changed"
expect_no_commit
}
test_own_entries_kept() {
start_scenario "own entries kept in all four files"
commit_and_push_fixture .gitignore .dockerignore .editorconfig \
.golangci.yml .prettierrc REPO_POLICIES.md
run_commit_upstream_policy_files
expect_success
expect_file .gitignore << 'EOF'
*.log
*.tmp
# Own entries go below this comment.
/myapp
EOF
expect_file .dockerignore << 'EOF'
**/*.log
**/*.tmp
# Own entries go below this comment.
/myapp
EOF
expect_file .editorconfig << 'EOF'
root = true
[*]
indent_size = 4
# Own sections go below this comment.
[*.py]
indent_size = 2
EOF
expect_file .golangci.yml << 'EOF'
linters:
settings:
depguard:
rules:
test-support:
deny:
- pkg: net/http/httptest
desc: canonical
- pkg: net/http/cgi
desc: added canonically
# Our own test helpers.
- pkg: example.com/app/internal/apptest
desc: own entry
other:
deny:
- pkg: example.com/other
EOF
expect_file .prettierrc << 'EOF'
{ "tabWidth": 4 }
EOF
expect_file REPO_POLICIES.md << 'EOF'
new policy
EOF
expect_new_commit_not_pushed
git -C "$dir/repo" log -1 --format=%s | grep -q -F -e "$HASH" ||
fail "the commit message does not name the head of main"
[ "$(files_in_commit)" = ".dockerignore .editorconfig .gitignore .golangci.yml .prettierrc REPO_POLICIES.md " ] ||
fail "the commit holds other files: $(files_in_commit)"
[ -z "$(ls -A "$dir/tmp")" ] || fail "a temporary directory is left"
}
test_files_the_repo_lacks_are_written_whole() {
start_scenario "files the repo lacks are written whole"
mkdir "$dir/repo/docs"
mv "$dir/repo/REPO_POLICIES.md" "$dir/repo/docs/REPO_POLICIES.md"
echo "print()" > "$dir/repo/tool.py"
echo "export {}" > "$dir/repo/web.ts"
commit_and_push_fixture docs/REPO_POLICIES.md tool.py web.ts
remove_from_fixture REPO_POLICIES.md .gitignore .golangci.yml .gitea \
docs/API_POLICIES.md docs/CODE_STYLEGUIDE_GO.md \
docs/CODE_STYLEGUIDE_JS.md docs/CODE_STYLEGUIDE_PYTHON.md
run_commit_upstream_policy_files
expect_success
expect_same_as_canonical .gitignore
expect_same_as_canonical .golangci.yml
expect_same_as_canonical .gitea/workflows/check.yml
expect_file docs/REPO_POLICIES.md << 'EOF'
new policy
EOF
expect_absent REPO_POLICIES.md
expect_file docs/API_POLICIES.md << 'EOF'
new api policy
EOF
expect_absent API_POLICIES.md
expect_file docs/CODE_STYLEGUIDE_GO.md << 'EOF'
new CODE_STYLEGUIDE_GO
EOF
expect_file docs/CODE_STYLEGUIDE_JS.md << 'EOF'
new CODE_STYLEGUIDE_JS
EOF
expect_file docs/CODE_STYLEGUIDE_PYTHON.md << 'EOF'
new CODE_STYLEGUIDE_PYTHON
EOF
expect_new_commit_not_pushed
}
test_language_files_need_a_source_file() {
start_scenario "a repo with no source file of a language gets none of its files"
commit_and_push_fixture .prettierrc
remove_from_fixture cmd .golangci.yml docs/CODE_STYLEGUIDE_GO.md \
docs/GO_HTTP_SERVER_CONVENTIONS.md docs/CODE_STYLEGUIDE_JS.md \
docs/CODE_STYLEGUIDE_PYTHON.md
run_commit_upstream_policy_files
expect_success
expect_absent .golangci.yml
expect_absent docs/CODE_STYLEGUIDE_GO.md
expect_absent docs/GO_HTTP_SERVER_CONVENTIONS.md
expect_absent docs/CODE_STYLEGUIDE_JS.md
expect_absent docs/CODE_STYLEGUIDE_PYTHON.md
expect_new_commit_not_pushed
}
test_javascript_source_brings_its_styleguide() {
for extension in js mjs; do
start_scenario "a repo whose only JavaScript source is a .$extension file gets the JavaScript style guide"
echo "export {}" > "$dir/repo/web.$extension"
commit_and_push_fixture "web.$extension"
remove_from_fixture docs/CODE_STYLEGUIDE_JS.md
run_commit_upstream_policy_files
expect_success
expect_file docs/CODE_STYLEGUIDE_JS.md << 'EOF'
new CODE_STYLEGUIDE_JS
EOF
expect_new_commit_not_pushed
done
}
test_nothing_changed() {
start_scenario "nothing changed"
cp "$dir/canonical/.prettierrc" "$dir/repo/.prettierrc"
commit_and_push_fixture .prettierrc
run_commit_upstream_policy_files
expect_success
expect_output "nothing to commit"
expect_nothing_written
}
test_target_lacking_write_upstream_policy_files_gets_both_scripts() {
start_scenario "a repo lacking script/write-upstream-policy-files gets it, and the current script/commit-upstream-policy-files, in the commit"
rm "$dir/repo/script/write-upstream-policy-files"
echo "# An older version." >> "$dir/repo/script/commit-upstream-policy-files"
commit_and_push_fixture .prettierrc
run_commit_upstream_policy_files
expect_success
expect_same_as_canonical script/write-upstream-policy-files
expect_same_as_canonical script/commit-upstream-policy-files
expect_committed_executable script/write-upstream-policy-files
expect_committed_executable script/commit-upstream-policy-files
expect_new_commit_not_pushed
[ "$(files_in_commit)" = ".prettierrc script/commit-upstream-policy-files script/write-upstream-policy-files " ] ||
fail "the commit holds other files: $(files_in_commit)"
}
# The shell reads a script in blocks, so the padding makes it read the
# last line only after main has returned. It finds that line only if
# script/write-upstream-policy-files left the old file in place under a
# new name. The old file is an earlier canonical version, so
# script/write-upstream-policy-files replaces it.
test_commit_upstream_policy_files_replaces_itself() {
start_scenario "script/commit-upstream-policy-files replaces its own file and still exits 0"
{
cat "$dir/repo/script/commit-upstream-policy-files"
i=0
while [ "$i" -lt 2000 ]; do
echo "# Padding."
i=$((i + 1))
done
echo "echo 'the old file ran to its end'"
} > "$dir/merged"
cp "$dir/merged" "$dir/canonical/script/commit-upstream-policy-files"
commit_canonical
cp "$COMMIT_UPSTREAM_POLICY_FILES" \
"$dir/canonical/script/commit-upstream-policy-files"
commit_canonical
mv "$dir/merged" "$dir/repo/script/commit-upstream-policy-files"
chmod 755 "$dir/repo/script/commit-upstream-policy-files"
commit_and_push_fixture
run_commit_upstream_policy_files
expect_success
expect_output "the old file ran to its end"
expect_same_as_canonical script/commit-upstream-policy-files
expect_executable script/commit-upstream-policy-files
expect_new_commit_not_pushed
}
test_write_upstream_policy_files_with_path_commits_nothing() {
start_scenario "script/write-upstream-policy-files with a path writes into that clone and commits nothing"
rm "$dir/repo/script/write-upstream-policy-files" \
"$dir/repo/script/commit-upstream-policy-files"
commit_and_push_fixture .prettierrc
run_write_upstream_policy_files "$dir/repo"
expect_success
expect_file .prettierrc << 'EOF'
{ "tabWidth": 4 }
EOF
expect_same_as_canonical script/write-upstream-policy-files
expect_same_as_canonical script/commit-upstream-policy-files
expect_executable script/write-upstream-policy-files
expect_executable script/commit-upstream-policy-files
expect_no_commit
[ "$(head -n 1 "$dir/output")" = "$HASH" ] ||
fail "the first line of output is not the hash"
expect_output "script/write-upstream-policy-files"
[ -z "$(ls -A "$dir/tmp")" ] || fail "a temporary directory is left"
}
test_write_upstream_policy_files_without_script_directory() {
start_scenario "script/write-upstream-policy-files with a path writes both scripts into a clone with no script directory"
commit_and_push_fixture .prettierrc
remove_from_fixture script
run_write_upstream_policy_files "$dir/repo"
expect_success
expect_same_as_canonical script/write-upstream-policy-files
expect_same_as_canonical script/commit-upstream-policy-files
expect_executable script/write-upstream-policy-files
expect_executable script/commit-upstream-policy-files
expect_no_commit
}
test_write_upstream_policy_files_without_argument_updates_own_repo() {
start_scenario "script/write-upstream-policy-files with no argument updates the repo it sits in"
commit_and_push_fixture .prettierrc
status=0
(cd "$dir" && PATH="$dir/bin:$PATH" \
repo/script/write-upstream-policy-files) \
> "$dir/output" 2>&1 || status=$?
expect_success
expect_file .prettierrc << 'EOF'
{ "tabWidth": 4 }
EOF
expect_no_commit
}
test_write_upstream_policy_files_bad_target() {
start_scenario "script/write-upstream-policy-files refuses a path that is not the top of a clone"
commit_and_push_fixture .prettierrc
run_write_upstream_policy_files "$dir/repo/script"
expect_failure
expect_output "not the top of a git clone"
run_write_upstream_policy_files "$dir/bin"
expect_failure
expect_output "not the top of a git clone"
run_write_upstream_policy_files "$dir/no-such-directory"
expect_failure
expect_output "no such directory"
run_write_upstream_policy_files "$dir/repo" extra
expect_failure
expect_output "usage:"
expect_nothing_written
}
test_failed_fetch_stops() {
for url in \
"/api/v1/repos/sneak/prompts/branches/main" \
"/raw/branch/main/script/write-upstream-policy-files" \
"/compare/" \
"/raw/commit/"; do
start_scenario "a failed fetch of $url stops with nothing written"
commit_and_push_fixture .gitignore .prettierrc REPO_POLICIES.md
FAIL_URL="$url"
run_commit_upstream_policy_files
expect_failure
expect_output "cannot fetch"
expect_nothing_written
done
}
test_one_missing_file_stops_all() {
start_scenario "one file missing from prompts stops before any file is written"
rm "$dir/canonical/prompts/REPO_POLICIES.md"
commit_and_push_fixture .prettierrc REPO_POLICIES.md
run_commit_upstream_policy_files
expect_failure
expect_output "cannot fetch"
expect_nothing_written
}
test_branch_response_without_hash_stops() {
start_scenario "a branch response with no commit hash stops"
commit_and_push_fixture .prettierrc
BAD_BRANCH_JSON=yes
run_commit_upstream_policy_files
expect_failure
expect_output "no commit hash"
expect_nothing_written
}
# Each of these files, with the repo's own entries above the closing
# comment. The comment added with them must not be named.
test_own_entry_above_closing_comment() {
for file in .gitignore .dockerignore .editorconfig; do
start_scenario "own entries above the closing comment of $file stop"
{
printf '/myapp\n/.tool/\n\n# A comment of our own.\n'
cat "$dir/repo/$file"
} > "$dir/merged"
mv "$dir/merged" "$dir/repo/$file"
commit_and_push_fixture .gitignore .dockerignore .editorconfig \
.prettierrc REPO_POLICIES.md
run_commit_upstream_policy_files
expect_failure
expect_output "$file has entries"
expect_output "/myapp"
expect_output "/.tool/"
expect_output_without "A comment of our own."
expect_nothing_written
done
}
test_uncommitted_change_stops() {
start_scenario "a carried file with uncommitted changes stops"
commit_and_push_fixture .prettierrc REPO_POLICIES.md
echo "uncommitted" >> "$dir/repo/REPO_POLICIES.md"
run_commit_upstream_policy_files
expect_failure
expect_output "REPO_POLICIES.md has uncommitted changes"
expect_file REPO_POLICIES.md << 'EOF'
old policy
uncommitted
EOF
expect_no_commit
}
test_untracked_script_stops() {
start_scenario "an untracked script/write-upstream-policy-files is not overwritten"
rm "$dir/repo/script/write-upstream-policy-files"
commit_and_push_fixture .prettierrc
echo "our own script" > "$dir/repo/script/write-upstream-policy-files"
run_write_upstream_policy_files "$dir/repo"
expect_failure
expect_output "script/write-upstream-policy-files has uncommitted changes"
expect_file script/write-upstream-policy-files << 'EOF'
our own script
EOF
expect_no_commit
}
test_ignored_file_stops() {
start_scenario "an ignored file at a listed path is not overwritten"
commit_and_push_fixture .prettierrc
remove_from_fixture docs/API_POLICIES.md
echo "docs/API_POLICIES.md" >> "$dir/repo/.git/info/exclude"
echo "our own notes" > "$dir/repo/docs/API_POLICIES.md"
run_write_upstream_policy_files "$dir/repo"
expect_failure
expect_output "docs/API_POLICIES.md is ignored by git"
expect_file docs/API_POLICIES.md << 'EOF'
our own notes
EOF
expect_no_commit
}
test_own_script_stops() {
for script in script/write-upstream-policy-files \
script/commit-upstream-policy-files; do
start_scenario "a tracked $script with a line the canonical file has never held stops"
echo "echo our own script" >> "$dir/repo/$script"
commit_and_push_fixture .prettierrc
run_commit_upstream_policy_files
expect_failure
expect_output "$script has lines"
expect_output "echo our own script"
expect_nothing_written
done
}
test_gitignore_lacks_canonical_last_line() {
start_scenario ".gitignore lacking the canonical last line keeps the repo's own line below the canonical content"
printf '*.log\n\n# A comment of our own.\n/myapp\n' > "$dir/repo/.gitignore"
commit_and_push_fixture .gitignore .prettierrc
run_commit_upstream_policy_files
expect_success
expect_file .gitignore << 'EOF'
*.log
*.tmp
# Own entries go below this comment.
/myapp
EOF
expect_new_commit_not_pushed
}
test_dockerignore_lacks_canonical_last_line() {
start_scenario ".dockerignore lacking the canonical last line drops a line an earlier canonical commit held"
echo "**/*.log" > "$dir/repo/.dockerignore"
expect_former_canonical_line_replaced .dockerignore ".git"
expect_same_as_canonical .dockerignore
}
test_editorconfig_lacks_canonical_last_line() {
start_scenario ".editorconfig lacking the canonical last line with a line of its own stops"
printf 'root = true\n[*.py]\nindent_size = 2\n' > "$dir/repo/.editorconfig"
commit_and_push_fixture .editorconfig .prettierrc
run_commit_upstream_policy_files
expect_failure
expect_output ".editorconfig lacks this line"
expect_output "[*.py]"
expect_output "indent_size = 2"
expect_nothing_written
}
test_editorconfig_lacks_canonical_last_line_no_own_line() {
start_scenario ".editorconfig lacking the canonical last line with no line of its own is replaced"
printf 'root = true\n[*]\n' > "$dir/repo/.editorconfig"
expect_former_canonical_line_replaced .editorconfig "indent_size = 8"
expect_same_as_canonical .editorconfig
}
test_fetched_golangci_without_deny_list() {
start_scenario "a fetched .golangci.yml with no deny list stops"
cat > "$dir/canonical/.golangci.yml" << 'EOF'
linters:
settings:
depguard:
rules:
test-support:
files:
- "**/*.go"
other:
deny:
- pkg: example.com/other
EOF
commit_and_push_fixture .golangci.yml .prettierrc
run_commit_upstream_policy_files
expect_failure
expect_output "no deny list in its test-support rule"
expect_nothing_written
}
test_own_golangci_setting_stops() {
start_scenario "a .golangci.yml setting the canonical file lacks stops"
cat >> "$dir/repo/.golangci.yml" << 'EOF'
# A comment of our own.
forbidigo:
forbid:
- pattern: model.Operator
EOF
commit_and_push_fixture .golangci.yml .prettierrc
run_commit_upstream_policy_files
expect_failure
expect_output ".golangci.yml has lines"
expect_output "forbidigo:"
expect_output "- pattern: model.Operator"
expect_output_without "A comment of our own."
expect_nothing_written
}
test_source_repo_stops() {
start_scenario "the repo the policy files come from stops"
mkdir "$dir/repo/prompts"
echo "policy" > "$dir/repo/prompts/REPO_POLICIES.md"
commit_and_push_fixture .prettierrc prompts/REPO_POLICIES.md
run_commit_upstream_policy_files
expect_failure
expect_output "this repo is the source of the policy files"
expect_nothing_written
}
test_staged_file_stays_out_of_commit() {
start_scenario "a file staged before the script starts stays out of its commit"
commit_and_push_fixture .prettierrc
echo "notes" > "$dir/repo/notes.txt"
git -C "$dir/repo" add notes.txt
run_commit_upstream_policy_files
expect_success
[ "$(files_in_commit)" = ".prettierrc " ] ||
fail "the commit holds more than the policy file"
[ "$(git -C "$dir/repo" diff --cached --name-only)" = "notes.txt" ] ||
fail "notes.txt is no longer staged"
}
test_commit_upstream_policy_files_takes_no_argument() {
start_scenario "script/commit-upstream-policy-files refuses an argument"
commit_and_push_fixture .prettierrc
run_commit_upstream_policy_files "$HASH"
expect_failure
expect_output "usage:"
expect_nothing_written
}
# expect_former_canonical_line_replaced <file> <line>: an earlier
# canonical commit held the line and the newest does not. This repo's
# file still has it, and the script replaces it.
expect_former_canonical_line_replaced() {
cp "$dir/canonical/$1" "$dir/newest"
{ echo "$2"; cat "$dir/newest"; } > "$dir/canonical/$1"
commit_canonical
cp "$dir/newest" "$dir/canonical/$1"
commit_canonical
{ echo "$2"; cat "$dir/repo/$1"; } > "$dir/merged"
mv "$dir/merged" "$dir/repo/$1"
commit_and_push_fixture "$1" .prettierrc
run_commit_upstream_policy_files
expect_success
if grep -q -F -x -e "$2" "$dir/repo/$1"; then
fail "$1 still has: $2"
fi
expect_new_commit_not_pushed
}
test_former_canonical_golangci_line_replaced() {
start_scenario "a .golangci.yml line an earlier canonical commit held is replaced"
expect_former_canonical_line_replaced .golangci.yml "version: 1"
}
test_former_canonical_gitignore_line_replaced() {
start_scenario "a .gitignore line above the closing comment that an earlier canonical commit held is replaced"
expect_former_canonical_line_replaced .gitignore "*.old"
}
test_former_canonical_script_line_replaced() {
start_scenario "a script/write-upstream-policy-files line an earlier canonical commit held is replaced"
expect_former_canonical_line_replaced \
script/write-upstream-policy-files "echo an older version"
expect_same_as_canonical script/write-upstream-policy-files
}
# A line another file's history removed does not count as held by this
# one.
test_line_removed_from_another_file_still_stops() {
start_scenario "a line only another canonical file once held stops"
{ echo "*.old"; cat "$dir/canonical/.dockerignore"; } > "$dir/merged"
cp "$dir/merged" "$dir/canonical/.dockerignore"
commit_canonical
tail -n +2 "$dir/merged" > "$dir/canonical/.dockerignore"
commit_canonical
{ echo "*.old"; cat "$dir/repo/.gitignore"; } > "$dir/merged"
mv "$dir/merged" "$dir/repo/.gitignore"
commit_and_push_fixture .gitignore .prettierrc
run_commit_upstream_policy_files
expect_failure
expect_output ".gitignore has entries"
expect_output "*.old"
expect_nothing_written
}
main() {
test_own_entries_kept
test_files_the_repo_lacks_are_written_whole
test_language_files_need_a_source_file
test_javascript_source_brings_its_styleguide
test_nothing_changed
test_target_lacking_write_upstream_policy_files_gets_both_scripts
test_commit_upstream_policy_files_replaces_itself
test_write_upstream_policy_files_with_path_commits_nothing
test_write_upstream_policy_files_without_script_directory
test_write_upstream_policy_files_without_argument_updates_own_repo
test_write_upstream_policy_files_bad_target
test_failed_fetch_stops
test_one_missing_file_stops_all
test_branch_response_without_hash_stops
test_own_entry_above_closing_comment
test_uncommitted_change_stops
test_untracked_script_stops
test_ignored_file_stops
test_own_script_stops
test_gitignore_lacks_canonical_last_line
test_dockerignore_lacks_canonical_last_line
test_editorconfig_lacks_canonical_last_line
test_editorconfig_lacks_canonical_last_line_no_own_line
test_fetched_golangci_without_deny_list
test_own_golangci_setting_stops
test_source_repo_stops
test_staged_file_stays_out_of_commit
test_commit_upstream_policy_files_takes_no_argument
test_former_canonical_golangci_line_replaced
test_former_canonical_gitignore_line_replaced
test_former_canonical_script_line_replaced
test_line_removed_from_another_file_still_stops
echo "commit-upstream-policy-files tests passed"
}
main "$@"
-567
View File
@@ -1,567 +0,0 @@
#!/bin/sh
# test/update-policy.sh: test script/update-policy on throwaway repos.
# Each scenario gets one temp directory holding a repo, its local bare
# origin, a local git repository of the canonical files in place of
# prompts, and a stub curl that serves them, so nothing touches the
# network.
set -eu
UPDATE_POLICY="$(cd "$(dirname "$0")/.." && pwd -P)/script/update-policy"
dir=""
trap 'rm -rf "$dir"' EXIT
fail() {
echo "FAIL: $scenario: $*" >&2
cat "$dir/output" >&2
exit 1
}
write_stub_curl() {
mkdir "$dir/bin"
cat > "$dir/bin/curl" << 'EOF'
#!/bin/sh
previous=""
fail_flag=""
for argument in "$@"; do
[ "$previous" = "--output" ] && output="$argument"
[ "$argument" = "--fail" ] && fail_flag=yes
previous="$argument"
done
url="$previous"
base="https://git.eeqj.de/sneak/prompts/raw/commit/$HASH/"
path="${url#"$base"}"
source="$(dirname "$0")/../canonical/$path"
if [ "$path" = "$url" ] || [ ! -f "$source" ]; then
[ -n "$fail_flag" ] && exit 22
echo "404 page not found" > "$output"
exit 0
fi
cp "$source" "$output"
EOF
chmod +x "$dir/bin/curl"
}
write_canonical_files() {
mkdir -p "$dir/canonical/prompts"
cat > "$dir/canonical/.gitignore" << 'EOF'
*.log
*.tmp
# Own entries go below this comment.
EOF
cat > "$dir/canonical/.dockerignore" << 'EOF'
**/*.log
**/*.tmp
# Own entries go below this comment.
EOF
cat > "$dir/canonical/.editorconfig" << 'EOF'
root = true
[*]
indent_size = 4
# Own sections go below this comment.
EOF
cat > "$dir/canonical/.golangci.yml" << 'EOF'
linters:
settings:
depguard:
rules:
test-support:
deny:
- pkg: net/http/httptest
desc: canonical
- pkg: net/http/cgi
desc: added canonically
other:
deny:
- pkg: example.com/other
EOF
echo '{ "tabWidth": 4 }' > "$dir/canonical/.prettierrc"
echo "new policy" > "$dir/canonical/prompts/REPO_POLICIES.md"
echo "new api policy" > "$dir/canonical/prompts/API_POLICIES.md"
}
# commit_canonical: commit the canonical files as they stand. The stub
# curl serves only the newest commit; every other commit gets a 404.
commit_canonical() {
git -C "$dir/canonical" add .gitignore .dockerignore .editorconfig \
.golangci.yml .prettierrc prompts
git -C "$dir/canonical" commit -q -m "Canonical"
HASH="$(git -C "$dir/canonical" rev-parse HEAD)"
}
# write_outdated_files: this repo's files, one canonical version behind,
# with the repo's own entries in the places the script keeps them. The
# comment above the closing comment in .gitignore is replaced, not kept.
write_outdated_files() {
cat > "$dir/repo/.gitignore" << 'EOF'
*.log
# An outdated comment.
# Own entries go below this comment.
/myapp
EOF
cat > "$dir/repo/.dockerignore" << 'EOF'
**/*.log
# Own entries go below this comment.
/myapp
EOF
cat > "$dir/repo/.editorconfig" << 'EOF'
root = true
[*]
# Own sections go below this comment.
[*.py]
indent_size = 2
EOF
cat > "$dir/repo/.golangci.yml" << 'EOF'
linters:
settings:
depguard:
rules:
test-support:
deny:
- pkg: net/http/httptest
desc: canonical
# Our own test helpers.
- pkg: example.com/app/internal/apptest
desc: own entry
other:
deny:
- pkg: example.com/other
EOF
echo '{ "tabWidth": 2 }' > "$dir/repo/.prettierrc"
echo "old policy" > "$dir/repo/REPO_POLICIES.md"
}
# start_scenario <name>: make a new temp directory holding a repo with
# the outdated files, not yet committed, and an empty origin.
start_scenario() {
scenario="$1"
rm -rf "$dir"
dir="$(mktemp -d)"
: > "$dir/output"
write_stub_curl
write_canonical_files
# git clone reads no repo's own config, so the setting that points
# git at the local canonical repository has to be in this file.
export GIT_CONFIG_GLOBAL="$dir/gitconfig"
git config --global user.name "Test"
git config --global user.email "test@example.com"
git config --global "url.$dir/canonical.insteadOf" \
"https://git.eeqj.de/sneak/prompts.git"
git init -q -b main "$dir/canonical"
export HASH
commit_canonical
git init -q --bare -b main "$dir/origin.git"
git init -q -b main "$dir/repo"
git -C "$dir/repo" remote add origin "$dir/origin.git"
mkdir "$dir/repo/script"
cp "$UPDATE_POLICY" "$dir/repo/script/update-policy"
printf '#!/bin/sh\necho example\n' > "$dir/repo/script/projectname"
chmod +x "$dir/repo/script/projectname"
write_outdated_files
}
# commit_and_push_fixture <paths...>: commit those paths, remove the
# rest of the outdated files, and record the commit as $before.
commit_and_push_fixture() {
(
cd "$dir/repo"
git add script "$@"
git commit -q -m "Fixture"
git push -q origin HEAD
git clean -q -f -x
)
before="$(git -C "$dir/repo" rev-parse HEAD)"
}
run_update_policy() {
status=0
(cd "$dir/repo" && PATH="$dir/bin:$PATH" script/update-policy "$@") \
> "$dir/output" 2>&1 || status=$?
}
reject_pushes() {
printf '#!/bin/sh\nexit 1\n' > "$dir/origin.git/hooks/pre-receive"
chmod +x "$dir/origin.git/hooks/pre-receive"
}
accept_pushes() {
rm "$dir/origin.git/hooks/pre-receive"
}
head_of_repo() {
git -C "$dir/repo" rev-parse HEAD
}
head_of_origin() {
git -C "$dir/origin.git" rev-parse refs/heads/main
}
expect_success() {
[ "$status" -eq 0 ] || fail "exited $status"
}
expect_failure() {
[ "$status" -ne 0 ] || fail "exited 0"
}
expect_output() {
grep -q -F -e "$1" "$dir/output" || fail "output lacks: $1"
}
expect_output_without() {
if grep -q -F -e "$1" "$dir/output"; then
fail "output has: $1"
fi
}
# expect_file <path>: the file must equal standard input.
expect_file() {
cat > "$dir/expected"
diff "$dir/expected" "$dir/repo/$1" >&2 || fail "$1 is wrong"
}
expect_absent() {
[ ! -e "$dir/repo/$1" ] || fail "$1 exists"
}
expect_nothing_written() {
[ -z "$(git -C "$dir/repo" status --porcelain)" ] ||
fail "the working tree changed"
[ "$(head_of_repo)" = "$before" ] || fail "the repo has a new commit"
[ "$(head_of_origin)" = "$before" ] || fail "origin has a new commit"
}
test_own_entries_kept() {
start_scenario "own entries kept in all four files"
commit_and_push_fixture .gitignore .dockerignore .editorconfig \
.golangci.yml .prettierrc REPO_POLICIES.md
run_update_policy "$HASH"
expect_success
expect_file .gitignore << 'EOF'
*.log
*.tmp
# Own entries go below this comment.
/myapp
EOF
expect_file .dockerignore << 'EOF'
**/*.log
**/*.tmp
# Own entries go below this comment.
/myapp
EOF
expect_file .editorconfig << 'EOF'
root = true
[*]
indent_size = 4
# Own sections go below this comment.
[*.py]
indent_size = 2
EOF
expect_file .golangci.yml << 'EOF'
linters:
settings:
depguard:
rules:
test-support:
deny:
- pkg: net/http/httptest
desc: canonical
- pkg: net/http/cgi
desc: added canonically
# Our own test helpers.
- pkg: example.com/app/internal/apptest
desc: own entry
other:
deny:
- pkg: example.com/other
EOF
expect_file .prettierrc << 'EOF'
{ "tabWidth": 4 }
EOF
expect_file REPO_POLICIES.md << 'EOF'
new policy
EOF
[ "$(head_of_origin)" = "$(head_of_repo)" ] ||
fail "the commit was not pushed"
[ "$(head_of_repo)" != "$before" ] || fail "no commit was made"
git -C "$dir/repo" log -1 --format=%s | grep -q -F -e "$HASH" ||
fail "the commit message does not name the hash"
}
test_files_not_carried_are_skipped() {
start_scenario "files the repo does not carry skipped"
mkdir "$dir/repo/docs"
mv "$dir/repo/REPO_POLICIES.md" "$dir/repo/docs/REPO_POLICIES.md"
commit_and_push_fixture docs/REPO_POLICIES.md
run_update_policy "$HASH"
expect_success
expect_file docs/REPO_POLICIES.md << 'EOF'
new policy
EOF
expect_absent REPO_POLICIES.md
expect_absent API_POLICIES.md
expect_absent docs/API_POLICIES.md
expect_absent .gitignore
expect_absent .dockerignore
expect_absent .editorconfig
expect_absent .golangci.yml
expect_absent .prettierrc
[ "$(head_of_origin)" = "$(head_of_repo)" ] ||
fail "the commit was not pushed"
}
test_nothing_changed() {
start_scenario "nothing changed"
cp "$dir/canonical/.prettierrc" "$dir/repo/.prettierrc"
commit_and_push_fixture .prettierrc
run_update_policy "$HASH"
expect_success
expect_output "nothing to commit"
expect_nothing_written
}
test_bad_commit_hash() {
for hash in not-a-hash 0000000000000000000000000000000000000000; do
start_scenario "bad commit hash $hash stops with nothing written"
commit_and_push_fixture .gitignore .prettierrc REPO_POLICIES.md
run_update_policy "$hash"
expect_failure
expect_nothing_written
done
# The last hash has the right form, so it is the one that reaches curl.
expect_output "cannot fetch"
}
test_push_failure() {
start_scenario "push failure exits non-zero"
commit_and_push_fixture .prettierrc
reject_pushes
run_update_policy "$HASH"
expect_failure
expect_output "push failed"
[ "$(head_of_origin)" = "$before" ] || fail "origin has a new commit"
}
# Each of these files, with the repo's own entries above the closing
# comment. The comment added with them must not be named.
test_own_entry_above_closing_comment() {
for file in .gitignore .dockerignore .editorconfig; do
start_scenario "own entries above the closing comment of $file stop"
{
printf '/myapp\n/.tool/\n\n# A comment of our own.\n'
cat "$dir/repo/$file"
} > "$dir/merged"
mv "$dir/merged" "$dir/repo/$file"
commit_and_push_fixture .gitignore .dockerignore .editorconfig \
.prettierrc REPO_POLICIES.md
run_update_policy "$HASH"
expect_failure
expect_output "$file has entries"
expect_output "/myapp"
expect_output "/.tool/"
expect_output_without "A comment of our own."
expect_nothing_written
done
}
test_retry_after_failed_push() {
start_scenario "a retry after a failed push delivers the commit"
commit_and_push_fixture .prettierrc
reject_pushes
run_update_policy "$HASH"
expect_failure
committed="$(head_of_repo)"
[ "$committed" != "$before" ] || fail "no commit was made"
[ "$(head_of_origin)" = "$before" ] || fail "origin has a new commit"
accept_pushes
run_update_policy "$HASH"
expect_success
[ "$(head_of_repo)" = "$committed" ] || fail "the retry made a new commit"
[ "$(head_of_origin)" = "$committed" ] ||
fail "the retry did not deliver the commit"
}
test_uncommitted_change_stops() {
start_scenario "a carried file with uncommitted changes stops"
commit_and_push_fixture .prettierrc REPO_POLICIES.md
echo "uncommitted" >> "$dir/repo/REPO_POLICIES.md"
run_update_policy "$HASH"
expect_failure
expect_output "REPO_POLICIES.md has uncommitted changes"
expect_file REPO_POLICIES.md << 'EOF'
old policy
uncommitted
EOF
[ "$(head_of_repo)" = "$before" ] || fail "the repo has a new commit"
[ "$(head_of_origin)" = "$before" ] || fail "origin has a new commit"
}
test_gitignore_lacks_canonical_last_line() {
start_scenario ".gitignore lacking the canonical last line stops"
printf '*.log\n/myapp\n' > "$dir/repo/.gitignore"
commit_and_push_fixture .gitignore .prettierrc
run_update_policy "$HASH"
expect_failure
expect_output ".gitignore lacks this line"
expect_nothing_written
}
test_fetched_golangci_without_deny_list() {
start_scenario "a fetched .golangci.yml with no deny list stops"
cat > "$dir/canonical/.golangci.yml" << 'EOF'
linters:
settings:
depguard:
rules:
test-support:
files:
- "**/*.go"
other:
deny:
- pkg: example.com/other
EOF
commit_and_push_fixture .golangci.yml .prettierrc
run_update_policy "$HASH"
expect_failure
expect_output "no deny list in its test-support rule"
expect_nothing_written
}
test_own_golangci_setting_stops() {
start_scenario "a .golangci.yml setting the canonical file lacks stops"
cat >> "$dir/repo/.golangci.yml" << 'EOF'
# A comment of our own.
forbidigo:
forbid:
- pattern: model.Operator
EOF
commit_and_push_fixture .golangci.yml .prettierrc
run_update_policy "$HASH"
expect_failure
expect_output ".golangci.yml has lines"
expect_output "forbidigo:"
expect_output "- pattern: model.Operator"
expect_output_without "A comment of our own."
expect_nothing_written
}
test_source_repo_stops() {
start_scenario "the repo the policy files come from stops"
printf '#!/bin/sh\necho prompts\n' > "$dir/repo/script/projectname"
commit_and_push_fixture .prettierrc REPO_POLICIES.md
run_update_policy "$HASH"
expect_failure
expect_output "this repo is the source of the policy files"
expect_nothing_written
}
test_staged_file_stays_out_of_commit() {
start_scenario "a file staged before the script starts stays out of its commit"
commit_and_push_fixture .prettierrc
echo "notes" > "$dir/repo/notes.txt"
git -C "$dir/repo" add notes.txt
run_update_policy "$HASH"
expect_success
[ "$(git -C "$dir/repo" show --name-only --format= HEAD)" = ".prettierrc" ] ||
fail "the commit holds more than the policy file"
[ "$(git -C "$dir/repo" diff --cached --name-only)" = "notes.txt" ] ||
fail "notes.txt is no longer staged"
}
test_short_commit_hash_refused() {
start_scenario "a hexadecimal hash shorter than 40 characters is refused"
commit_and_push_fixture .prettierrc
run_update_policy "${HASH%?}"
expect_failure
expect_output "not a full commit hash"
expect_nothing_written
}
# git asks the local canonical repository for the head of main, and the
# stub curl serves only that commit.
test_no_hash_uses_head_of_main() {
start_scenario "with no hash the script uses the head of main"
commit_and_push_fixture .prettierrc
run_update_policy
expect_success
expect_file .prettierrc << 'EOF'
{ "tabWidth": 4 }
EOF
git -C "$dir/repo" log -1 --format=%s | grep -q -F -e "$HASH" ||
fail "the commit message does not name the head of main"
[ "$(head_of_origin)" = "$(head_of_repo)" ] ||
fail "the commit was not pushed"
}
# expect_former_canonical_line_replaced <file> <line>: an earlier
# canonical commit held the line and the newest does not. This repo's
# file still has it, and the script replaces it and pushes.
expect_former_canonical_line_replaced() {
cp "$dir/canonical/$1" "$dir/newest"
{ echo "$2"; cat "$dir/newest"; } > "$dir/canonical/$1"
commit_canonical
cp "$dir/newest" "$dir/canonical/$1"
commit_canonical
{ echo "$2"; cat "$dir/repo/$1"; } > "$dir/merged"
mv "$dir/merged" "$dir/repo/$1"
commit_and_push_fixture "$1" .prettierrc
run_update_policy "$HASH"
expect_success
if grep -q -F -x -e "$2" "$dir/repo/$1"; then
fail "$1 still has: $2"
fi
[ "$(head_of_repo)" != "$before" ] || fail "no commit was made"
[ "$(head_of_origin)" = "$(head_of_repo)" ] ||
fail "the commit was not pushed"
}
test_former_canonical_golangci_line_replaced() {
start_scenario "a .golangci.yml line an earlier canonical commit held is replaced"
expect_former_canonical_line_replaced .golangci.yml "version: 1"
}
test_former_canonical_gitignore_line_replaced() {
start_scenario "a .gitignore line above the closing comment that an earlier canonical commit held is replaced"
expect_former_canonical_line_replaced .gitignore "*.old"
}
main() {
test_own_entries_kept
test_files_not_carried_are_skipped
test_nothing_changed
test_bad_commit_hash
test_push_failure
test_own_entry_above_closing_comment
test_retry_after_failed_push
test_uncommitted_change_stops
test_gitignore_lacks_canonical_last_line
test_fetched_golangci_without_deny_list
test_own_golangci_setting_stops
test_source_repo_stops
test_staged_file_stays_out_of_commit
test_short_commit_hash_refused
test_no_hash_uses_head_of_main
test_former_canonical_golangci_line_replaced
test_former_canonical_gitignore_line_replaced
echo "update-policy tests passed"
}
main "$@"