#!/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 lacks 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 lacks 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 : 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 [end]: print each line of the local # file, above line end if given, that the fetched file lacks. Blank # lines and comments do not count. local_only_lines() { awk -v end="${3:-}" ' NR == FNR { fetched[$0]; next } (end == "" || FNR < end) && !/^[ \t]*(#|$)/ && !($0 in fetched) ' "$1" "$2" } # keep_lines_below_last : append to the fetched file # whatever follows its last line in the local file. Stop if a line above # that one is missing from the fetched file, 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 : 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 the result lacks, # 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 lacks; add each to the canonical file to keep it, or delete it: $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 carried="" for file in $VENDORED_FILES; do path="$(local_path "$file")" [ -n "$path" ] || continue if [ -n "$(git status --porcelain -- "$path")" ]; then die "$path has uncommitted changes" fi mkdir -p "$tmp/$(dirname "$path")" curl --fail --silent --show-error --max-time 60 \ --output "$tmp/$path" \ "$PROMPTS_URL/raw/commit/$commit/$file" || die "cannot fetch $file at $commit" carried="$carried $path" done [ -n "$carried" ] || die "this repo carries none of the vendored files" for path in $carried; do case "$path" in .gitignore | .editorconfig | .dockerignore) keep_lines_below_last "$tmp/$path" "$path" ;; .golangci.yml) keep_deny_entries "$tmp/$path" "$path" ;; esac done for path in $carried; do cp "$tmp/$path" "$path" git add -- "$path" done if git diff --cached --quiet -- $carried; then echo "update-policy: already at prompts $commit, nothing to commit" 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 "$@"