Add script/update-policy to re-vendor policy files (closes #162)
check / check (push) Successful in 24s

One command fetches the policy files a repo vendors from `prompts`, keeps the repo's own entries, commits them by name and pushes. A line the canonical file has never held stops the script before anything is written, as does a failed fetch. The canonical `.dockerignore` gains the closing comment the script needs. `test/update-policy.sh` runs in the `test` phase of `Dockerfile`.

Judgement call: `.gitea/workflows/check.yml` and `.prettierrc` are replaced whole.
Limitation: a repo with its own `.golangci.yml` settings, such as `sneak/homoicon`, stops until those are dealt with.

Model: opus-5-5
This commit was merged in pull request #164.
This commit is contained in:
clawbot committed 2026-11-09 11:00:11 +01:00
1 parent d673785d33
commit 936ead8425
10 files changed
+846 -10

No files matched your search

+4
View File
@@ -76,3 +76,7 @@
**/.idea
**/.vscode
**/*.sublime-*
# This repository's own entries, such as its build outputs, go below
# this comment, and a re-vendor keeps them. Anchor a binary built at the
# root: `/myapp`, never `**/myapp`, which also matches `cmd/myapp/`.
+1 -1
View File
@@ -42,7 +42,7 @@ RUN script/bootstrap
COPY . .
RUN echo "No tests defined."
RUN timeout 90 test/update-policy.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
+4 -1
View File
@@ -1,4 +1,4 @@
.PHONY: bootstrap setup test lint fmt fmt-check check docker hooks
.PHONY: bootstrap setup test lint fmt fmt-check check docker hooks update-policy
# Makefile targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
@@ -30,3 +30,6 @@ docker:
hooks:
@script/install-precommit
update-policy:
@script/update-policy
+5 -2
View File
@@ -118,8 +118,8 @@ alpine. We provide:
`script/docker` for the image tag
- `script/test` —
`docker build --build-arg CACHEBUST="$(date +%s)-$$" --target test --output type=cacheonly .`,
building the `test` phase of the `Dockerfile` without writing an image (no
tests defined here)
building the `test` phase of the `Dockerfile`, which runs
`test/update-policy.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,6 +141,9 @@ alpine. We provide:
`script/check`
- `script/install-precommit` — installs the git pre-commit hook (our own
extension); `make hooks` shims to it
- `script/update-policy` — fetch the vendored policy files from this repo,
commit them and push (our own extension); it is for the repos that vendor them
and stops when run here
`make hooks` installs the pre-commit hook that runs `script/precommit`.
+6
View File
@@ -21,6 +21,12 @@ fmt-check, and commit.
# Completed Steps
- 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
+11 -2
View File
@@ -1,6 +1,6 @@
---
title: Existing Repo Checklist
last_modified: 2026-11-07
last_modified: 2026-11-09
---
Use this checklist when beginning work in a repo that may not yet conform to our
@@ -205,12 +205,21 @@ with your task.
- [ ] `Makefile` exists in root — reference
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`
- [ ] Has targets: `test`, `lint`, `fmt`, `fmt-check`, `check`, `docker`,
`hooks`
`hooks`, `update-policy`
- [ ] Target implementations live in `script/` (scripts-to-rule-them-all);
Makefile targets are thin shims calling them — model scripts at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`
- [ ] `script/precommit` exists and the pre-commit hook (installed by
`script/install-precommit`, shimmed by `make hooks`) runs it
- [ ] `script/update-policy` exists, identical to the model script, and
`make update-policy` shims to it. Before its first use, the repo's own
entries in `.gitignore`, `.editorconfig` and `.dockerignore` go below the
comment that ends each canonical file. 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.
- [ ] README has an **Entrypoints** section documenting the `script/`
entrypoints and linking the standard
- [ ] `script/lint` and `script/test` each run
+6 -1
View File
@@ -1,6 +1,6 @@
---
title: New Repo Checklist
last_modified: 2026-11-07
last_modified: 2026-11-09
---
Use this checklist when creating a new repository from scratch. Follow the steps
@@ -62,6 +62,8 @@ Template files can be fetched from:
`CLAUDE.md` or `.claude/`, and never separate memory files
- [ ] `Dockerfile` and `.dockerignore` — fetch `.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`
- The repo's own entries go at the end of the file, where a re-vendor keeps
them.
- Extend `.dockerignore` with the repo's own host-built artifacts, giving
every depth-independent pattern a `**/` prefix — but write a repo-root
binary anchored, `/myapp` and never `**/myapp`, which would also match
@@ -264,6 +266,9 @@ are thin shims calling them. Model scripts:
- [ ] `script/install-precommit` — installs the pre-commit hook that runs
`script/precommit`
- [ ] `make hooks` — shims to `script/install-precommit`
- [ ] `script/update-policy` / `make update-policy` — fetches the vendored
policy files from the `prompts` repo, commits them and pushes; identical
across repos
- [ ] README **Entrypoints** section documents the scripts and links the
standard
+20 -3
View File
@@ -1,6 +1,6 @@
---
title: Repository Policies
last_modified: 2026-11-07
last_modified: 2026-11-09
---
This document covers repository structure, tooling, and workflow standards. Code
@@ -93,6 +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)
A model Makefile is at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
@@ -109,6 +111,7 @@ style conventions are in separate documents:
- `script/fmt-check`
- `script/check`
- `script/docker`
- `script/update-policy`
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
@@ -182,12 +185,15 @@ style conventions are in separate documents:
host. On a pristine checkout with nothing installed, the run dies there,
after the containerised gates have passed.
Four further scripts are our own extensions to the standard:
Five 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/projectname` (literally that filename) simply outputs the
project's name.
@@ -927,6 +933,17 @@ 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
keeps only the repo's own entries:
- in `.gitignore`, `.editorconfig` and `.dockerignore`: the lines below the
comment that ends the canonical file
- in `.golangci.yml`: the entries the repo added to the `deny` list of the
`test-support` depguard rule
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must
_NEVER_ be modified by an agent. Fetch it from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it
@@ -1152,7 +1169,7 @@ style conventions are in separate documents:
- `Makefile`
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
`install-precommit`)
`install-precommit`, `update-policy`)
- `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml`
+222
View File
@@ -0,0 +1,222 @@
#!/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 "$@"
+567
View File
@@ -0,0 +1,567 @@
#!/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 "$@"