Push discipline¶
Goal: failures surface before the push. main contains only finished, checked features, one
commit per feature, signed and linear.
Flow¶
- Branch. Create a branch
<type>/<topic>from the currentmain. Never work onmain. - Work and commit. Small commits are allowed, also
fixup!commits; the pre-commit hook checks every change quickly, the commit-msg hook checks format and DCO (git commit -s). - Squash before the push. Rebase onto
main, squash intermediate states into meaningful Conventional Commits (git rebase -i mainin the terminal). No commit is called "fix CI", "typo" or "wip". - Local review before every push. An independent, read-only reviewer checks the
diff of the squashed branch against
mainaccording to red-team-gate.md and writes the report to.review/<sha>.md(git-ignored;<sha>is the commit that is pushed). The report stays local; the pull request names the reviewed commit and the verdict (step 6). The last line isUrteil: JAorUrteil: NEIN <reason>. If a finding changes the state, the reviewer checks the delta, and the report applies to the new commit.make reviewcreates the folder, shows the expected file name and optionally starts the CodeRabbit CLI (only withBOTHY_CODERABBIT=1, see below). At the end of a milestone, the overall review over the whole milestone is added. - Push. The pre-push hook first checks the report (
tools/hooks/review_gate.py: file present, last lineUrteil: JA), then it runsmake checkfor the parts the push touches (section "Affected-only pre-push"; a push to a build branchbuild/**runs only the fast gates, section "Build branches"). Red means: fix, do not bypass.git push --no-verifyis a process failure. Contributors from forks setREVIEW_SKIP=1; then a maintainer checks before the merge. - Pull request. One pull request per finished feature. The title is a
Conventional Commit header line, because on merge it becomes the header line of the commit on
main; CI checks it (PR_TITLEinmake commitlint). The body becomes the message of that commit, so title and body are written fresh, in your own words: what and why, how it was verified, which commit the independent review covered with which verdict and what was fixed. Never paste a raw review report, bot or agent output, local paths, user or machine names, temporary folder names or notes about tools. Section "Pull request title and body" below describes the gate. - CI.
ci.ymlruns the same make targets. The only required check:ci-ok. It is the last job of the run: it needs every other job, runs withalways()and combines their results (tools/ci/ci_ok.py), so it reports on every run; the jobplanmarks it pending for the head commit at the start of a pull request run. - Merge. "Squash and merge". GitHub signs the resulting commit; its message is the
pull request body (repository setting below).
mainstays linear.
Build branches¶
A build team does not run the full make check on its own machine while it works. It pushes
the ticket branch to a build branch and lets GitHub-hosted runners verify it, which are free
for this public repository (ADR 0039):
- Push.
git push origin <ticket-branch>:build/<ticket>, for examplegit push origin feat/M2-44-terminal-relay:build/M2-44, as often as needed (a force push of the build branch is fine, it is no shared branch). The pre-push hook sees the build branch (tools/hooks/build_push.py) and runs only the fast gates:denylistwith the private list,gitleaks,commitlint,codegen-checkwhen a generator input changed andlint, both for the components the diff againstorigin/maintouches. It needs no review report (tools/hooks/review_gate.py). A ticket branch on an integration head pushes its base ref first ("Base ref" below). - Wait. The push starts
.github/workflows/build-verify.yml: every target ofmake gatesand the platform jobs ofci.yml(postgres image on both architectures, apps on macOS, Windows and the Android emulators), split into parallel jobs, with the Go, pub, npm and uv caches. The jobbuild-verify-oksums them up; wait for it, for example withgh run watch <run-id> --exit-status(the id fromgh run list --workflow build-verify.yml --branch build/<ticket> --limit 1). A newer push to the same build branch cancels the older run. - Read failures.
gh run view <run-id> --log-failedprints the log of the failed steps. Fix them, reproduce locally only with the targeted test (one Go package, one Dart test file, one pytest module), commit and push the build branch again. - Pull request. When
build-verify-okis green, the ticket branch goes the usual way from step 4 of the flow above: independent review, push of the ticket branch (the pre-push hook runs the fullmake checkthere), pull request,ci-ok. Delete the build branch and its base ref afterwards (git push origin --delete build/<ticket> build-base/<ticket>).
Base ref. A ticket branch built on an integration head (for example a wave branch that
is not on main yet) differs from the old merge base with main in thousands of files, so
its plan runs every job. Push the integration head it was built on as
build-base/<ticket> first, in a push of its own, then the build branch:
git push origin <integration-head>:refs/heads/build-base/<ticket>
git push origin <ticket-branch>:build/<ticket>
The pre-push hook gives the base
push the same fast gates as a build branch (tools/hooks/build_push.py), so its commits are
scanned before they reach the remote. The plan of build-verify.yml then counts only the
files between the merge base of the pushed commit with build-base/<ticket> and the pushed
commit (tools/ci/build_base.py), and runs the jobs they need. The base must be the commit
the ticket branch was built on, an ancestor of it, so the jobs cover exactly the changes of
the ticket. Without the ref, or without common history with it, the plan measures from the
merge base with origin/main, which on an integration base means every job, and without
any base every job runs. Push the ref
again (--force is fine, it is no shared branch) before the build branch when the ticket
branch is rebased onto a newer integration head.
What the build verification does not do: it uses no secret, so it scans with the generic
deny rules only and runs no SonarQube analysis; both stay in ci.yml of the pull request.
It is no required check, and its result never stands in for ci-ok. Caches are scoped to
the branch that wrote them, so the first run of a new build branch starts cold. pre-commit
reports only the first ref of a push that is not a deletion, so push a build branch in a
command of its own, separate from every other branch, its build-base/<ticket> included.
Protection of main (ruleset)¶
| Rule | Setting | Reason |
|---|---|---|
| Require a pull request before merging | on; allowed merge method squash only | no direct pushes, one commit per feature |
| Require status checks to pass | ci-ok |
one check combines all jobs; a job the plan does not need may be skipped (gates.md, "Jobs a change needs") |
| Require linear history | on | readable history, simple bisects |
| Require signed commits | on | provenance of every commit on main |
| Block force pushes | on | history stays immutable |
| Restrict deletions | on | main cannot be deleted |
| Squash commit message (repository setting, not part of the ruleset) | title: pull request title, message: pull request body | the checked title becomes the commit header on main |
Rationale for squash instead of rebase merge: with a rebase merge, GitHub creates new commits that it cannot sign; signed commits and rebase merge through the interface are therefore mutually exclusive (https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/about-merge-methods-on-github, retrieved 2026-10-10) [verified]. GitHub signs the squash commit (https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets, retrieved 2026-10-10) [verified]. Rulesets are available for private repositories from GitHub Pro on [verified, research source https://raw.githubusercontent.com/github/docs/main/data/reusables/gated-features/repo-rules.md, retrieved 2026-10-10]. The rule "Required reviewers" for teams does not exist in personal repositories; in single-person operation, the independent review before every push (step 4) replaces the second review.
Affected-only pre-push¶
The pre-push hook (tools/hooks/push_plan.py) runs make check for the files the push
changes against its merge base with origin/main, the range of the pull request. CI stays the
authority: its jobs run as before, whatever the hook ran.
| Pushed files | What runs |
|---|---|
A shared input: Makefile, .pre-commit-config.yaml, the shared parts of tools/ (tools/bin/, tools/ci/, tools/hooks/, tools/node/, the tool locks and the root files of the tools project; the tool folders the component map assigns are listed in gates.md, "Jobs a change needs"), .github/, .semgrep/, quality/, root files other than Markdown (mkdocs.yml, sonar-project.properties, .gitleaks.toml), any path the component map does not know |
the full make check |
Files of a component (services/, apps/ and packages/, sdk/python/, sdk/typescript/, plugins/) and the inputs of its generated code (docs/spec/actions.yaml, docs/api/, the root files of the Dart workspace) |
the gates of these components (COMPONENTS, the same map as the CI job gates: tools/ci/components.py) |
| Anything that is not source code of a component (Go, Dart, TypeScript, Kotlin, Swift or Python files under a component directory): docs, infrastructure, manifests, lock files, configuration, test data | in addition the gates of the Python tools (AREAS=tools: format, lint, types, tests, coverage of tools/), because their tests read many of these files; without them make test still runs the few tools tests that read component sources (TOOLS_SOURCE_TESTS in the Makefile) |
| Every push | commitlint, gitleaks, denylist, semgrep, the docs gates, the supply-chain scans, the API and contract checks and the file hygiene hooks of make lint on the changed files (LINT_FROM_REF, LINT_TO_REF) |
PUSH_FULL=1 git push runs the full make check; so do a tag push and a branch without a
merge base with origin/main. make check without arguments stays the full check. The mapping is
held by tools/hooks/tests/test_push_plan.py. A renamed file counts with its old and its new path.
Failures after the push¶
A red CI run after a green make check means: local and CI are not the same. That
is a failure of the toolchain, not bad luck. With the affected-only pre-push this includes a
gate the hook left out: then the path belongs to a wider row of the table above (fix the map
in tools/hooks/push_plan.py or tools/ci/components.py with a test). Flow:
- Determine the cause and note it in the pull request.
- Close the gap locally (gate, test, pin), in the same or in a separate pull request.
- A commit "fix CI" or "typo" on
mainis a process failure: short analysis as an entry indocs/DECISIONS.md(date, cause, new rule).
Findings after the push are acceptable only from checkers that cannot run locally:
SonarQube Cloud (for example Dart rules) and CodeRabbit. Every such finding becomes
a local rule: Semgrep rule, test, linter setting or profile rule in
quality/sonar/.
Pull request title and body¶
On "Squash and merge", the title becomes the header and the body the message of the commit
on main, and a commit on main is public and is never rewritten without the owner. So the
text of the pull request goes through the same deny list as every commit:
- CI. Both deny-list jobs of
ci.yml(denylistwith the private list,denylist-forkwith the generic rules) get title and body from the event payload as the environment variablesPR_TITLEandPR_BODY, never interpolated into a script. A finding fails the job and with itci-ok.ci.ymlalso runs onpull_requesttypeedited, so every change of title or body is checked again before the merge; an edit restarts the whole run, becauseci-okmust cover the current text and the current code together. - No stale green.
ci-okis the last job and needs every other job, and a job withneedsgets its check run only when those jobs are done, so after an edit the older greenci-okof the same head commit would stay the latest result for the whole new run. So the first step of the jobplancreates a check runci-okwith statusin_progressfor the head commit (checks: write), the latestci-okof that commit, and the jobci-okcompletes it with its own conclusion. A fork or a dependency bot's run gets a token that cannot write checks; it skips the mark. - Rules. The body is checked like a commit message: every line gets the private and
the generic rules. A
Signed-off-byorCo-authored-byline of the exact formKey: Name <local@domain>(a plain address) gets the private list as a whole and the generic rules on the line without the local part of the address, so the name and the domain are still checked; a trailer line with any other text is ordinary text and gets all rules. In the body,Signed-off-bypasses only with a full identity fromallow-authors.txt(Name <email>); inCo-authored-byor in the text an allowed identity is a finding, as in a commit. - Locally.
make denylist(and somake checkand the pre-push hook) reads the same variables. Before the push, check the planned squash message:PR_TITLE="feat(x): ..." PR_BODY_FILE=<file> make denylist, orPR_BODYwith the text itself. APR_BODY_FILEthat cannot be read fails the scan. - Bots. CodeRabbit writes its summary into the walkthrough comment, not into the body
(
high_level_summary_in_walkthroughin.coderabbit.yaml, schema at https://coderabbit.ai/integrations/schema.v2.json, retrieved 2026-10-12), so bot text does not reachmainand does not restart CI.
What the gate does not cover, so denylist-main.yml stays the check after the merge (it
scans what actually reached main):
- the time between an edit and the first step of the job
planof the new run (GitHub delivers the event, and the job waits for a free runner); - an edit of a fork or dependency bot pull request: its run cannot mark
ci-okpending, so the older result stays the latest until the newci-okends (wait for it before the merge); - a manual
workflow_dispatchofci.ymlon the pull request branch or "Re-run all jobs" of a run from before the edit: both run without the current text (maintainers only); - a run that waits for a maintainer's approval (first contribution from a fork) has no
ci-okyet, so the older result stays the latest until the maintainer approves; - a message typed into the merge dialog instead of the pull request body.
Dependency bot pull requests¶
Dependabot (dependabot[bot]) and Renovate (renovate[bot]) open pull requests from branches
of this repository, but a run that Dependabot starts gets no repository secrets: GitHub treats
it like a run from a fork
(https://docs.github.com/en/code-security/dependabot/troubleshooting-dependabot/troubleshooting-dependabot-on-github-actions,
retrieved 2026-10-13) [verified]. Bot commits also carry no DCO sign-off of their author.
So that such a pull request passes for the right reasons, and only such a pull request
(D-DEPS-3 in DECISIONS):
- Secret checks.
tools/ci/pr_mode.py(jobplanofci.yml) reduces the run when the pull request author and the actor of the run are both one of the two bot logins, exactly as GitHub reports them in the event. The run is then handled like a fork: the jobdenylist (generic rules, fork)scans with the generic rules,sonaris skipped, and the run shows the notice "Reduced checks" with the reason. A run that a person starts on the same pull request (an edit of title or body, a push to the bot branch) gets the private list and SonarQube with the secrets, and a missing secret fails it. - Sign-off. CI passes the pull request author to
make commitlint(PR_AUTHOR). In a pull request by one of the bots, a commit whose author e-mail is exactly that bot's noreply address needs noSigned-off-by(BOT_AUTHORSintools/hooks/commit_msg.py); the check prints one line per exempt commit. The Conventional Commits rules still apply to these commits and to the title. Every other commit, also a maintainer's commit on the bot branch, still needs its sign-off; a bot address in a person's pull request or in a fork exempts nothing, because only GitHub sets the pull request author. LocallyPR_AUTHORis unset, somake checkexempts nothing. - Before the merge. The maintainer replaces the bot's body with a short text of their own
(what changed and how it was verified) that ends with their
Signed-off-byline, and checks that the title is a Conventional Commit header. The squash commit onmaintakes title and body, so it carries the maintainer's sign-off, and the edit starts a run with every check, whichci-okmust pass before the merge. After the merge,denylist-main.ymlscans the pushed commit with the private list as for every push.
What this does not cover: nothing enforces the maintainer's rewrite of the body; as with a
fork pull request, a merge straight after a reduced run has not seen the private list or
SonarQube before the merge, and only denylist-main.yml checks afterwards. Anyone with write
access can push a commit with a forged bot author address to a bot branch, and that commit
then needs no sign-off; the push is a run of a person, so it gets every other check, and the
squash commit on main carries the maintainer's sign-off from the body.
Deny-list hit on main¶
denylist-main.yml checks every push to main with the private list (D-W-34). If it
fails, a term of the list has reached main, for example from a
fork contribution or from a commit message changed during the squash (see the list of
what the pull request text gate does not cover). Flow:
- Look at the hit locally with
make denylist DENYLIST_RANGE=<before>..<after> DENYLIST_FLAGS=--reveal(locally only, never in CI). - The repository is public (ADR 0039), so the term is published: remove it, inform the owner,
analysis as an entry in
docs/DECISIONS.mdas for a process failure. Only the owner decides whether the public history is rewritten.
CodeRabbit CLI and data outflow¶
The CodeRabbit CLI checks local changes through the CodeRabbit service; the
documentation names a check of the reachability of the backend (cr doctor) and no
purely local execution (https://docs.coderabbit.ai/cli/overview, retrieved 2026-10-10)
[derived]. So the diff leaves the host. That is why it is off until
BOTHY_CODERABBIT=1 is set. In the private state, the owner decides whether private
code may go to the service (open question). In the public state, the code is
public anyway; CodeRabbit then also rates every pull request
(.coderabbit.yaml). According to the vendor, the free tier of the CLI is limited
("Basic static analysis with limited daily usage", same source) [verified].
The pull request review reports real problems only (CRT-1 in docs/DECISIONS.md): the
profile is chill, a general instruction asks for bugs, security issues, broken contracts
and problems that break CI and no style, wording or formatting nits, and a source URL with
retrieval date is asked for only in docs/spec/**, docs/security/** and docs/adr/**.
CodeRabbit stores the learnings of review replies for this repository (CRT-1/3), so an answer
that explains a settled rule is not needed again on the next pull request.
A comment that names a Blocker or a reproduced Major is fixed before the merge; any other
comment gets a short answer and its thread is resolved.