Skip to content

TL-M0-14 · Protect main

Why: Nothing should reach main without a pull request and green checks, from a person or an agent. GitHub enforces that with a ruleset, which on a private repository needs GitHub Team, and the organization stays on GitHub Free for now (D26). Until it upgrades, path B stands in: a local guard refuses pushes to main from this Mac, and Vercel holds each production deploy until CI passes (TL-M0-21). Path A adds the ruleset before anyone other than zuki gets write access to the repository.

ID TL-M0-14 · Level L0 · Run order row 14

main accepts changes only through pull requests whose checks pass: guarded on this Mac now (path B), and enforced by GitHub once the organization is on GitHub Team (path A).

  • TL-M0-13 is Done, so GitHub knows the check names.
  • Path B ran on 2026-10-06. Path A is replaced by TL-M0-28, which creates the same ruleset with gh api (D28); don't run it.

B1 · A branch with the guard: a Lefthook script that refuses any push to main. Lefthook marks the script executable the first time it runs, so chmod does that before the commit.

Section titled “B1 · A branch with the guard: a Lefthook script that refuses any push to main. Lefthook marks the script executable the first time it runs, so chmod does that before the commit.”
git switch -c repo/push-guard
mkdir -p .lefthook/pre-push
cat > .lefthook/pre-push/no-push-to-main.sh <<'EOF'
#!/bin/sh
# TL-M0-14, path B: refuses a push to main from this Mac while GitHub can't.
# It stops accidents only; git push --no-verify skips it.
while read -r local_ref local_sha remote_ref remote_sha; do
if [ "$remote_ref" = "refs/heads/main" ]; then
echo "Pushing to main is blocked here. Open a pull request instead." >&2
exit 1
fi
done
EOF
cat >> lefthook.yml <<'EOF'
pre-push:
scripts:
"no-push-to-main.sh":
runner: sh
use_stdin: true
EOF
chmod +x .lefthook/pre-push/no-push-to-main.sh
pnpm lefthook install
git add .lefthook lefthook.yml
git commit -m "build: block direct pushes to main"

B2 · Push, open the pull request, and merge it when the checks pass

Section titled “B2 · Push, open the pull request, and merge it when the checks pass”
Terminal window
git push -u origin repo/push-guard
gh pr create --fill
gh pr checks --watch
gh pr merge --squash --delete-branch
git switch main
git pull

B3 · Try to push to main directly, then drop the test commit

Section titled “B3 · Try to push to main directly, then drop the test commit”
Terminal window
git commit --allow-empty -m "chore: test the push guard"
git push origin main
git reset --hard origin/main

Path A · once the organization is on GitHub Team

Section titled “Path A · once the organization is on GitHub Team”
Terminal window
gh repo view <GITHUB_OWNER>/opleet --web

A2 · In the browser: Settings › Rules › Rulesets › New ruleset › New branch ruleset. Name it main; Enforcement status Active; Target branches › Add target › Include default branch; tick Restrict deletions, Block force pushes, Require a pull request before merging (Required approvals 0 while there are two developers) and Require status checks to pass, adding checks and pr-title. Create it.

Section titled “A2 · In the browser: Settings › Rules › Rulesets › New ruleset › New branch ruleset. Name it main; Enforcement status Active; Target branches › Add target › Include default branch; tick Restrict deletions, Block force pushes, Require a pull request before merging (Required approvals 0 while there are two developers) and Require status checks to pass, adding checks and pr-title. Create it.”

A3 · Try to push to main directly, with --no-verify so the push gets past the local guard to GitHub, then drop the test commit

Section titled “A3 · Try to push to main directly, with --no-verify so the push gets past the local guard to GitHub, then drop the test commit”
Terminal window
git commit --allow-empty -m "chore: test the ruleset"
git push --no-verify origin main
git reset --hard origin/main
  • Block B1: Lefthook's “sync hooks” line lists pre-push next to commit-msg and pre-commit, and the commit passes commitlint.
  • Block B2: checks and pr-title pass, then “✓ Squashed and merged pull request …”.
  • Block B3: the push stops with “Pushing to main is blocked here. Open a pull request instead.” and “error: failed to push some refs”.
  • Block A3: the push is refused with “GH013: Repository rule violations”.

Path B: block B3 is the check. To confirm that main holds the merged pull request and not the test commit:

Terminal window
git fetch
git log -1 --format=%s origin/main

Shows “build: block direct pushes to main (#…)”.

Path A:

Terminal window
gh api repos/<GITHUB_OWNER>/opleet/rules/branches/main --jq '.[].type'

Lists deletion, non_fast_forward, pull_request and required_status_checks.

  • B3's push goes through: the pre-push hook isn't installed. Run pnpm lefthook install, check that ls .git/hooks lists pre-push, then run B3 again. The test commit that reached main is empty; leave it.
  • Lefthook prints “(skip) no matching push files”: the guard was put under commands: instead of scripts:. Lefthook skips commands when a push carries no new files, such as deleting main, so keep it under scripts: as in B1.
  • B2's checks fail: paste the output of gh run view --log-failed into a CTO session.
  • A1 or A2 asks you to upgrade: the organization isn't on GitHub Team yet. Stay on path B.
  • A3's push goes through: the ruleset isn't active. Run the reset in A3 anyway, then check the ruleset's Enforcement status.
  • Path B: open a pull request that deletes .lefthook/pre-push and the pre-push section at the end of lefthook.yml. Lefthook then does nothing on push.
  • Path A: Settings › Rules › Rulesets › main › Delete ruleset.

Done 2026-10-06 (zuki)