Skip to content

Push discipline

Goal: failures surface before the push. main contains only finished, checked features, one commit per feature, signed and linear.

Flow

  1. Branch. Create a branch <type>/<topic> from the current main. Never work on main.
  2. 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).
  3. Squash before the push. Rebase onto main, squash intermediate states into meaningful Conventional Commits (git rebase -i main in the terminal). No commit is called "fix CI", "typo" or "wip".
  4. Local review before every push. An independent, read-only reviewer checks the diff of the squashed branch against main according 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 is Urteil: JA or Urteil: NEIN <reason>. If a finding changes the state, the reviewer checks the delta, and the report applies to the new commit. make review creates the folder, shows the expected file name and optionally starts the CodeRabbit CLI (only with BOTHY_CODERABBIT=1, see below). At the end of a milestone, the overall review over the whole milestone is added.
  5. Push. The pre-push hook first checks the report (tools/hooks/review_gate.py: file present, last line Urteil: JA), then it runs make check for the parts the push touches (section "Affected-only pre-push"; a push to a build branch build/** runs only the fast gates, section "Build branches"). Red means: fix, do not bypass. git push --no-verify is a process failure. Contributors from forks set REVIEW_SKIP=1; then a maintainer checks before the merge.
  6. 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_TITLE in make 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.
  7. CI. ci.yml runs the same make targets. The only required check: ci-ok. It is the last job of the run: it needs every other job, runs with always() and combines their results (tools/ci/ci_ok.py), so it reports on every run; the job plan marks it pending for the head commit at the start of a pull request run.
  8. Merge. "Squash and merge". GitHub signs the resulting commit; its message is the pull request body (repository setting below). main stays 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):

  1. Push. git push origin <ticket-branch>:build/<ticket>, for example git 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: denylist with the private list, gitleaks, commitlint, codegen-check when a generator input changed and lint, both for the components the diff against origin/main touches. 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).
  2. Wait. The push starts .github/workflows/build-verify.yml: every target of make gates and the platform jobs of ci.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 job build-verify-ok sums them up; wait for it, for example with gh run watch <run-id> --exit-status (the id from gh run list --workflow build-verify.yml --branch build/<ticket> --limit 1). A newer push to the same build branch cancels the older run.
  3. Read failures. gh run view <run-id> --log-failed prints 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.
  4. Pull request. When build-verify-ok is 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 full make check there), 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:

  1. Determine the cause and note it in the pull request.
  2. Close the gap locally (gate, test, pin), in the same or in a separate pull request.
  3. A commit "fix CI" or "typo" on main is a process failure: short analysis as an entry in docs/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 (denylist with the private list, denylist-fork with the generic rules) get title and body from the event payload as the environment variables PR_TITLE and PR_BODY, never interpolated into a script. A finding fails the job and with it ci-ok. ci.yml also runs on pull_request type edited, so every change of title or body is checked again before the merge; an edit restarts the whole run, because ci-ok must cover the current text and the current code together.
  • No stale green. ci-ok is the last job and needs every other job, and a job with needs gets its check run only when those jobs are done, so after an edit the older green ci-ok of the same head commit would stay the latest result for the whole new run. So the first step of the job plan creates a check run ci-ok with status in_progress for the head commit (checks: write), the latest ci-ok of that commit, and the job ci-ok completes 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-by or Co-authored-by line of the exact form Key: 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-by passes only with a full identity from allow-authors.txt (Name <email>); in Co-authored-by or in the text an allowed identity is a finding, as in a commit.
  • Locally. make denylist (and so make check and 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, or PR_BODY with the text itself. A PR_BODY_FILE that cannot be read fails the scan.
  • Bots. CodeRabbit writes its summary into the walkthrough comment, not into the body (high_level_summary_in_walkthrough in .coderabbit.yaml, schema at https://coderabbit.ai/integrations/schema.v2.json, retrieved 2026-10-12), so bot text does not reach main and 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 plan of 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-ok pending, so the older result stays the latest until the new ci-ok ends (wait for it before the merge);
  • a manual workflow_dispatch of ci.yml on 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-ok yet, 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 (job plan of ci.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 job denylist (generic rules, fork) scans with the generic rules, sonar is 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 no Signed-off-by (BOT_AUTHORS in tools/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. Locally PR_AUTHOR is unset, so make check exempts 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-by line, and checks that the title is a Conventional Commit header. The squash commit on main takes title and body, so it carries the maintainer's sign-off, and the edit starts a run with every check, which ci-ok must pass before the merge. After the merge, denylist-main.yml scans 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:

  1. Look at the hit locally with make denylist DENYLIST_RANGE=<before>..<after> DENYLIST_FLAGS=--reveal (locally only, never in CI).
  2. The repository is public (ADR 0039), so the term is published: remove it, inform the owner, analysis as an entry in docs/DECISIONS.md as 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.