diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 706f8ae2..6b77604f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -74,17 +74,3 @@ 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/.github/workflows/hygiene.yml b/.github/workflows/hygiene.yml
new file mode 100644
index 00000000..64b8e34e
--- /dev/null
+++ b/.github/workflows/hygiene.yml
@@ -0,0 +1,20 @@
+name: hygiene
+# Its own workflow, on pull requests only, so a pull request's head carries exactly one check run
+# named "hygiene": the ruleset "master: hygiene required" waits on that name, and a job skipped by
+# an `if:` inside the push-triggered ci workflow would also count as passed.
+on:
+ pull_request:
+jobs:
+ hygiene:
+ 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/CLAUDE.md b/CLAUDE.md
index dee37bd6..7518d4a1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -25,7 +25,8 @@ commit or pull request attribution. Product design lives in `docs/`, not here.
- 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
+- The `hygiene` workflow runs `tools/check-commit-hygiene.sh` over the pull request's own commits and
+ master requires it green (ruleset "master: hygiene required"; repository admins can bypass). Run
it before pushing: `bash tools/check-commit-hygiene.sh origin/master HEAD`.
## The gates CI cannot run
diff --git a/docs/release-proofs.md b/docs/release-proofs.md
index ccb037bb..52e431e8 100644
--- a/docs/release-proofs.md
+++ b/docs/release-proofs.md
@@ -81,7 +81,7 @@ prints the three activities sorted and `other 0 []`; a fourth exported component
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.
+`tools/check-commit-hygiene.sh` (the `hygiene` workflow, required on master); 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]'`