Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<base branch>
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"
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
See CLAUDE.md.
50 changes: 50 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion docs/release-proofs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <base>..HEAD | grep -nE '^\+.*[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'` (fixture URLs carry none)
- no home path: `git diff <base>..HEAD | grep -nE '^\+.*/hom[e]/[a-z]'`
Expand Down
99 changes: 99 additions & 0 deletions tools/check-commit-hygiene.sh
Original file line number Diff line number Diff line change
@@ -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
# <base>..<head>. CI's hygiene job runs it on every pull request over the pull request's own commits.
# bash tools/check-commit-hygiene.sh <base> <head>
# 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 <base> <head>" >&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. <base>...<head> 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"