diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b77604f..706f8ae2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,3 +74,17 @@ jobs: - name: the Stage-B schedules loader's tests run: uv run --frozen pytest working-directory: tools/servicetag-schedules + hygiene: + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + ref: ${{ github.event.pull_request.head.sha }} # the pull request's own tip, not the merge ref + fetch-depth: 0 # the whole range, and origin/ + persist-credentials: false # this job needs no git credentials after the checkout + - name: the pull request's own commits follow CLAUDE.md + env: + BASE: origin/${{ github.base_ref }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bash tools/check-commit-hygiene.sh "$BASE" "$HEAD_SHA" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..9b8a940c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +See CLAUDE.md. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..dee37bd6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,50 @@ +# Rules for every session + +Every clone, session and agent working here follows these. They override any tool's default +commit or pull request attribution. Product design lives in `docs/`, not here. + +## Commits + +- Before the first commit in a clone or a session, set the owner's identity: + `git config user.name GonzRon` and + `git config user.email "$(git log -1 --format=%ae servicetag-v1.7.0)"` — the address of the + owner's own release commit (`git fetch origin tag servicetag-v1.7.0` first if the clone lacks it). + Never write the address itself into a file. +- It must be the author **and** the committer. `GIT_AUTHOR_*` and `GIT_COMMITTER_*` in the + environment beat the config: unset them. Check with `git var GIT_COMMITTER_IDENT`. +- A commit message is its subject line alone: no body, no `Co-Authored-By`, no `Claude-Session`, + no `Generated-by` or "Generated with", no session links. +- No tool or model name anywhere in a commit message, a branch name, a pull request title or a pull + request body. Every commit reads as the owner's alone. +- Subjects are casual, terse and lowercase-leaning, as in + `git log servicetag-v1.6.0..servicetag-v1.7.0`: `supply row can hide its chevron`, + `#16 plan: rev 1`, `delete asset sweeps its installed components' file bytes too`. + +## Pull requests + +- Name the branch for the work, `fix-…` or `issue-123-…`. Never start it with a tool's name. +- Merge with a merge commit whose subject carries no tool-named branch. Never squash or rebase + away the owner's history. +- CI's `hygiene` job runs `tools/check-commit-hygiene.sh` over the pull request's own commits. Run + it before pushing: `bash tools/check-commit-hygiene.sh origin/master HEAD`. + +## The gates CI cannot run + +- The connected suite runs only on `emulator-5554`, by the controller, before a release cut + (`docs/release-proofs.md` R2). A pull request that touches `app/src/androidTest` or a screen + says so in its description. +- CI compiles the instrumented sources and runs lint. Both stay green. + +## Never in the repository + +E-mail addresses, home paths, device serials other than `emulator-5554`, real endpoints, host +names or tokens. Use fictional ones only: `192.168.0.10`, `ha.example`, `example-…`, and names +under the reserved `.invalid` domain such as `manuals.example.invalid`. + +## Where the rest lives + +- `docs/versioning.md`: classify the change before choosing the number. +- `docs/release-proofs.md`: the release runbook. +- `docs/superpowers/planning-policy.md`: plans specify, implementers author. +- `docs/localization.md`: every owner-facing string is a resource; `UiLiteralGuardTest` holds it. +- Issue #104: the roadmap of record. diff --git a/docs/release-proofs.md b/docs/release-proofs.md index 06b4d35e..ccb037bb 100644 --- a/docs/release-proofs.md +++ b/docs/release-proofs.md @@ -79,7 +79,9 @@ prints the three activities sorted and `other 0 []`; a fourth exported component ## R6's greps -Each prints nothing, or the count stated. +Each prints nothing, or the count stated. The author, attribution and empty-body lines, and the +three content greps, are now also enforced on every pull request, over its own commits, by +`tools/check-commit-hygiene.sh` (CI's `hygiene` job); the controller still runs them all here. - no e-mail in added lines: `git diff ..HEAD | grep -nE '^\+.*[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'` (fixture URLs carry none) - no home path: `git diff ..HEAD | grep -nE '^\+.*/hom[e]/[a-z]'` diff --git a/tools/check-commit-hygiene.sh b/tools/check-commit-hygiene.sh new file mode 100755 index 00000000..d5c9e5ac --- /dev/null +++ b/tools/check-commit-hygiene.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# The commit rules in CLAUDE.md and R6's content greps (docs/release-proofs.md), over the commits in +# ... CI's hygiene job runs it on every pull request over the pull request's own commits. +# bash tools/check-commit-hygiene.sh +# Every commit: author and committer GonzRon (committer GitHub only on a merge, the merge button); +# no attribution, session or tool line in the message; a commit that is not a merge is its subject +# line alone; no merge subject names a claude branch. Every added line: no e-mail address but a +# reserved fictional one, no home path, no device serial but emulator-5554. +# Prints one line per violation and exits 1, or prints "commit hygiene ok: N commits". +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "usage: tools/check-commit-hygiene.sh " >&2 + exit 2 +fi +base=$1 +head=$2 +owner=GonzRon +attribution='co-authored-by|claude-session|generated[- ](by|with)|anthropic|claude\.ai|noreply@' +# "from GonzRon/claude/x" (the merge button) and "branch 'claude-x'" (a local merge) alike +claude_branch="(from [^ ]*/|'([^ ']*/)?)claude[/-]" + +cd "$(git rev-parse --show-toplevel)" +for rev in "$base" "$head"; do + git rev-parse --verify --quiet "$rev^{commit}" >/dev/null \ + || { echo "commit hygiene: no such commit: $rev" >&2; exit 2; } +done + +violations=0 +flag() { + echo "commit hygiene: $1" + violations=$((violations + 1)) +} + +commits=0 +range=$(git rev-list --reverse --parents "$base..$head") +while read -r sha _first_parent second_parent _; do + [ -n "$sha" ] || continue + commits=$((commits + 1)) + is_merge=false + [ -z "$second_parent" ] || is_merge=true + { IFS= read -r short; IFS= read -r author; IFS= read -r committer; IFS= read -r subject; } \ + <<< "$(git show -s --format='%h%n%an%n%cn%n%s' "$sha")" + message=$(git show -s --format=%B "$sha") + + [ "$author" = "$owner" ] || flag "$short author is '$author', not $owner: $subject" + if [ "$committer" != "$owner" ] && ! { $is_merge && [ "$committer" = GitHub ]; }; then + flag "$short committer is '$committer', not $owner: $subject" + fi + hit=$(printf '%s\n' "$message" | grep -iE "$attribution" | sed -n 1p || true) + [ -z "$hit" ] || flag "$short attribution in the message: $hit" + if ! $is_merge; then + lines=$(printf '%s\n' "$message" | grep -c . || true) + [ "$lines" -eq 1 ] || flag "$short has a body, $lines lines (subject line only): $subject" + elif printf '%s\n' "$subject" | grep -qiE "$claude_branch"; then + flag "$short merge names a claude branch: $subject" + fi +done <<< "$range" + +# R6's content greps over the lines the range adds. ... diffs from the merge base, so a +# base that moved on after the branch forked adds nothing here. Three paths are left out because +# they quote these very patterns: this script, CLAUDE.md and docs/release-proofs.md. +found=$(git diff --no-color --no-ext-diff "$base...$head" -- . \ + ':(exclude)tools/check-commit-hygiene.sh' ':(exclude)CLAUDE.md' ':(exclude)docs/release-proofs.md' | + awk ' + function report(what, text) { + sub(/^[ \t]+/, "", text) + printf "commit hygiene: %s:%d %s: %s\n", file, n, what, substr(text, 1, 120) + } + function check(text, rest, addr, domain, bad) { + rest = text + while (match(rest, /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+[.][A-Za-z][A-Za-z]+/)) { + addr = substr(rest, RSTART, RLENGTH) + domain = tolower(substr(addr, index(addr, "@") + 1)) + # the reserved fictional names (RFC 2606): *.invalid, *.example, example.com/org/net + if (domain !~ /(^|[.])(invalid|example)$/ && domain !~ /(^|[.])example[.](com|org|net)$/) bad = addr + rest = substr(rest, RSTART + RLENGTH) + } + if (bad != "") report("e-mail address " bad, text) + if (text ~ /\/home\/[a-z]/) report("home path", text) + if (text ~ /(adb -s |ANDROID_SERIAL=)/ && text !~ /emulator-5554/) report("device serial", text) + } + /^diff --git / { hunk = 0; next } + !hunk && /^[+][+][+] / { file = substr($0, 7); next } + /^@@ / { hunk = 1; match($0, /[+][0-9]+/); n = substr($0, RSTART + 1, RLENGTH - 1) + 0; next } + !hunk { next } + /^[+]/ { check(substr($0, 2)); n++; next } + /^ / { n++ } + ') +if [ -n "$found" ]; then + printf '%s\n' "$found" + violations=$((violations + $(printf '%s\n' "$found" | grep -c .))) +fi + +if [ "$violations" -gt 0 ]; then + echo "commit hygiene FAILED: $violations violations in $commits commits ($base..$head)" + exit 1 +fi +echo "commit hygiene ok: $commits commits"