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
1 change: 1 addition & 0 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ jobs:
- run: bash scripts/release-ready.sh --test
- run: bash scripts/stamp-version.sh --test
- run: bash scripts/release-markers.sh --test
- run: bash scripts/conventions.sh --test

review:
needs: [conventions, actionlint, scripts]
Expand Down
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ Rules for any coding agent working in `DiluxOne/.github`.
CONTRIBUTING.md ("Saying that AI was involved"), e.g.
`🤖 AI-generated · Claude Opus 5.5 (Anthropic)`. Never "Generated with …".
- Never push to `main`, create or move tags, or change organisation settings.
- Before a pull request exists, run `scripts/local-review.sh` (CONTRIBUTING.md,
"Review before the pull request") and fix what it finds. Do not push, open a
pull request or re-run a workflow unless the maintainer asked for it: every
push to an open pull request is a paid review.
- Keep `README.md`, `CONTRIBUTING.md`, the review profiles, the workflow
templates and the workflow header comments in step with what the workflows
do, in the same PR.
15 changes: 13 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ Thanks for helping. This is the default guide for every DiluxOne repository; a r

1. Create a branch from `main`: in your fork if you are an outside contributor, in the repository itself if you are a maintainer.
2. Make the change, with tests, and update any doc that describes what you changed.
3. Open a pull request against `main` and fill in the template (see "Writing the pull request" below).
4. Wait for CI. Pull requests are squash-merged: the PR title becomes the commit title on `main` and the PR description becomes its body.
3. Review it locally before you push (see "Review before the pull request" below): every review on GitHub costs money and a round of waiting, so the pull request should arrive clean.
4. Open a pull request against `main` and fill in the template (see "Writing the pull request" below).
5. Wait for CI. Pull requests are squash-merged: the PR title becomes the commit title on `main` and the PR description becomes its body.

Nothing reaches `main` without a green pull request, maintainers included.

Expand Down Expand Up @@ -51,6 +52,16 @@ The description becomes the commit body on `main`, so it is part of the project'

CI fails a pull request whose "What changes" or "Why" is empty (bots' are exempt).

## Review before the pull request

[`scripts/local-review.sh`](scripts/local-review.sh) runs, on your machine, what the pull request will be checked on: the conventions ([`scripts/conventions.sh`](scripts/conventions.sh), the checks CI makes), the risk floor ([`scripts/policy.py`](scripts/policy.py)), and the Claude review on the brief CI builds ([`scripts/review-brief.sh`](scripts/review-brief.sh): the review profiles, the repository's `AGENTS.md` and `docs/architecture.md`, the description and the diff), through the Claude Code CLI on your own account. From the repository, with a checkout of this one:

```bash
bash ../.github/scripts/local-review.sh --body-file pr.md # the description you will paste
```

The docs check CI also runs (relative links resolve, no retired product name) is not part of it. It reviews the branch against `origin/main`; the title is the branch's only commit, or `--title`. It ends with "Ready for a pull request" or with what to fix; `--no-claude` stops at the brief, for another reviewer or agent to read. A repository may wrap it in its own target (for a plugin, `make pre-pr`, which also runs the test suites). CI still reviews the pull request: a branch that came out clean here should pass there in one round.

## What CI checks

Every pull request runs the conventions check (branch name, PR title, every commit and the description) and the project's quality gates.
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ Call them pinned to `@v2`; a breaking change ships as `v2`. A stack suffix
| [`claude-review.yml`](.github/workflows/claude-review.yml) | The review described above. Outputs `risk`, `complexity`, `floor`, `trusted`, `blocking`. | `profile`, `central-ref`, `max-auto-reviews` |
| [`review-reply.yml`](.github/workflows/review-reply.yml) | Answers `@dilux-bot` mentions from members and collaborators, with the strong model. | `profile`, `central-ref` |
| [`auto-merge.yml`](.github/workflows/auto-merge.yml) | Turns GitHub's auto-merge on or off from the review's outputs and commits the description verbatim. `pull-request-edited.yml` runs it on `edited` too. | the five review outputs |
| [`scripts/conventions.sh`](scripts/conventions.sh) | Not a workflow: the conventions a pull request is held to (branch, title, every commit header, no session trailer, description sections, no "Generated with" footer). The checks `conventions.yml` makes on a pull request, for `local-review.sh` to make before one; `--test` for its own tests. | env: `BRANCH`, `TITLE`, `BODY`, `BASE`, `HEAD_REF`, `MAX_HEADER`, `SECTIONS`, `LABELS`, `AUTHOR_TYPE` |
| [`scripts/review-brief.sh`](scripts/review-brief.sh) | Not a workflow: the review brief, the one file the Claude review reads (profiles, `AGENTS.md`, `docs/architecture.md`, policy floor, description, diff). The same brief `claude-review.yml` builds, for `local-review.sh`. | env: see the script's header |
| [`scripts/local-review.sh`](scripts/local-review.sh) | Not a workflow: the pull request's checks before it exists, on a contributor's machine: conventions, policy floor, brief, and the review through the local Claude Code CLI. See CONTRIBUTING.md, "Review before the pull request". | `--base`, `--title`, `--body-file`, `--profile`, `--no-claude` |
| [`scripts/release-ready.sh`](scripts/release-ready.sh) | Not a workflow: whether a readme's newest changelog entry is ready (exit 0) or held by a first line `Unreleased.` (exit 1); `--test` for its own tests. The release job's hold. | the readme |
| [`scripts/release-markers.sh`](scripts/release-markers.sh) | Not a workflow: `check` holds the three version markers to a real version (the last one released, or the next one in the pull request that releases it and on `main` after it merged; the checks' readme job and the release job run it); `prepare` turns a tree into its release pull request (removes the `Unreleased.` line, stamps the markers); `--test` for its own tests. | `check <dir> <main-file> <constant> <last> <next> <pending>`, `prepare <dir> <version> <main-file> [<constant>]` |
| [`scripts/stamp-version.sh`](scripts/stamp-version.sh) | Not a workflow: stamps a plugin tree with a version (the `Version:` header, the constant, `Stable tag:`, a `= Unreleased =` heading; with a build, a `Build:` header line) and fails when a marker did not take it. The release and the development build stamp with it; `--test` for its own tests. | `<dir> <version> <main-file> [<constant>] [<build>]` |
Expand Down
112 changes: 112 additions & 0 deletions scripts/conventions.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# The conventions every pull request is held to: the branch name, the title
# and every commit header (Conventional Commits, at most MAX_HEADER long), no
# session links in commits, the description's required sections filled in,
# and the AI line instead of a "Generated with" footer: what the conventions
# workflow checks on a pull request, for scripts/local-review.sh to check
# before one is opened.
#
# Environment: BRANCH, TITLE, BODY, BASE and HEAD_REF (the commits between
# them are checked), MAX_HEADER (default 100), SECTIONS (comma-separated
# headings, default "What changes,Why"; empty skips), LABELS (comma-separated,
# optional), AUTHOR_TYPE (Bot skips the sections). Run from the repository.
#
# conventions.sh check (exit 1 on any broken rule)
# conventions.sh --test self-test against a scratch repository
set -euo pipefail

check() {
MAX_HEADER=${MAX_HEADER:-100}
SECTIONS=${SECTIONS-What changes,Why}
LABELS=${LABELS:-}
AUTHOR_TYPE=${AUTHOR_TYPE:-User}
TYPES='feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert'
HEADER_RE="^(${TYPES})(\([a-z0-9._/-]+\))?!?: [^ ].*[^.]$"
BRANCH_RE="^((${TYPES})/[a-z0-9][a-z0-9._-]*|dependabot/.+)$"
fail=0
error() { echo "::error::$1"; fail=1; }
check_header() {
if ! [[ "$2" =~ $HEADER_RE ]]; then
error "$1 is not a Conventional Commit header (\"type(scope): subject\", type one of ${TYPES//|/, }, no trailing period): \"$2\""
elif (( ${#2} > MAX_HEADER )); then
error "$1 is ${#2} characters; the limit is ${MAX_HEADER}: \"$2\""
fi
}
[[ "$BRANCH" =~ $BRANCH_RE ]] || error "Branch \"$BRANCH\" must be <type>/<kebab-case-description>, type one of ${TYPES//|/, }."
check_header "The pull request title" "$TITLE"
# The type:* label is the review's reading of the diff (or a person's
# override); the title, which becomes the commit on main, must say the
# same. The review corrects the title itself; this catches a later
# hand edit that undoes it.
typelabel=$(tr ',' '\n' <<<"$LABELS" | grep -m1 '^type:' | sed 's/^type://' || true)
TYPE_RE='^([a-z]+)(\([^)]*\))?(!?): '
if [ -n "$typelabel" ] && [[ "$TITLE" =~ $TYPE_RE ]]; then
token=${BASH_REMATCH[1]}; bang=${BASH_REMATCH[3]}
if [ "$typelabel" = breaking ]; then
[ "$bang" = '!' ] || error "The pull request is labelled type:breaking, so its title needs the \"!\" of a breaking change: \"$token$bang:\"."
elif [ "$token" != "$typelabel" ]; then
error "The pull request is labelled type:$typelabel but its title says \"$token:\"; the title must carry the labelled type (the review sets it; change the label if the review is wrong)."
fi
fi
while read -r sha; do
[ -z "$sha" ] && continue
check_header "Commit ${sha:0:7}" "$(git log -1 --format=%s "$sha")"
if git log -1 --format=%B "$sha" | grep -qiE '^Claude-Session:'; then
error "Commit ${sha:0:7} carries a Claude-Session trailer; session links stay out of the history."
fi
done < <(git rev-list --no-merges "${BASE}..${HEAD_REF}")
# The description becomes the commit body on main: its required
# sections must say something. Bots (Dependabot) write their own.
section() { printf '%s\n' "$BODY" | awk -v h="$1" 'index($0, h) && /^## / { on = 1; next } /^## / { on = 0 } on' | sed 's/<!--.*-->//g' | tr -d '[:space:]'; }
# AI involvement is disclosed in one sober line, not a product ad
# (CONTRIBUTING.md, "Saying that AI was involved").
if printf '%s\n' "$BODY" | grep -qiE 'generated (with|by) \[?(claude|copilot|chatgpt|cursor|codex)'; then
error "Replace the \"Generated with …\" footer with the AI line, e.g. \"🤖 AI-assisted · Claude Opus 5.5 (Anthropic)\"."
fi
if [ "$AUTHOR_TYPE" != Bot ] && [ -n "$SECTIONS" ]; then
IFS=, read -ra required <<< "$SECTIONS"
for h in "${required[@]}"; do
[ -n "$(section "$h")" ] || error "The description's \"$h\" section is empty."
done
fi
if [ "$fail" -ne 0 ]; then
echo "See CONTRIBUTING.md: \"Branch names\", \"Commit messages and PR titles\", \"Writing the pull request\" and \"Saying that AI was involved\"."
return 1
fi
echo "Conventions OK."
}

# One case: a scratch repository whose branch adds one commit with this
# message; expect 0 (passes) or 1 (fails).
test_case() {
local name=$1 want=$2 branch=$3 title=$4 message=$5 body=$6 labels=${7:-} got dir
dir=$(mktemp -d)
(
cd "$dir" && git init -q && git config user.email t@t && git config user.name t
git commit -q --allow-empty -m "chore: base" && git checkout -q -b topic
git commit -q --allow-empty -m "$message"
BRANCH=$branch TITLE=$title BODY=$body LABELS=$labels BASE=$(git rev-parse HEAD~1) HEAD_REF=$(git rev-parse HEAD) check >/dev/null 2>&1
) && got=0 || got=1
rm -rf "$dir"
if [ "$got" = "$want" ]; then echo "ok $name"; else echo "FAIL $name (want $want, got $got)"; return 1; fi
}

if [ "${1:-}" = "--test" ]; then
good=$'## 📝 What changes\n\nA thing.\n\n## 💡 Why\n\nA reason.\n\n🤖 AI-assisted · Claude Opus 5.5 (Anthropic)'
long="fix(sync): $(printf 'x%.0s' $(seq 1 95))"
fail=0
test_case "passes: everything right" 0 fix/a-thing "fix(sync): a thing" "fix(sync): a thing" "$good" || fail=1
test_case "passes: the matching type label" 0 fix/a-thing "fix(sync): a thing" "fix(sync): a thing" "$good" "type:fix,risk:low" || fail=1
test_case "fails: branch without a type" 1 a-thing "fix(sync): a thing" "fix(sync): a thing" "$good" || fail=1
test_case "fails: title with a trailing period" 1 fix/a-thing "fix(sync): a thing." "fix(sync): a thing" "$good" || fail=1
test_case "fails: title over 100 characters" 1 fix/a-thing "$long" "fix(sync): a thing" "$good" || fail=1
test_case "fails: a commit that is not a header" 1 fix/a-thing "fix(sync): a thing" "Fixed a thing" "$good" || fail=1
test_case "fails: a Claude-Session trailer" 1 fix/a-thing "fix(sync): a thing" $'fix(sync): a thing\n\nClaude-Session: https://claude.ai/code/x' "$good" || fail=1
test_case "fails: an empty Why" 1 fix/a-thing "fix(sync): a thing" "fix(sync): a thing" $'## 📝 What changes\n\nA thing.\n\n## 💡 Why\n\n<!-- say why -->' || fail=1
test_case "fails: a Generated with footer" 1 fix/a-thing "fix(sync): a thing" "fix(sync): a thing" "$good"$'\n🤖 Generated with [Claude Code](https://claude.com/claude-code)' || fail=1
test_case "fails: title type unlike the label" 1 fix/a-thing "fix(sync): a thing" "fix(sync): a thing" "$good" "type:feat" || fail=1
[ "$fail" -eq 0 ] && echo "all tests passed"
exit "$fail"
fi

check
Loading
Loading