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).
Before
Section titled “Before”- 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.
Path B · now, on GitHub Free
Section titled “Path B · now, on GitHub Free”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-guardmkdir -p .lefthook/pre-pushcat > .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 fidoneEOFcat >> lefthook.yml <<'EOF'
pre-push: scripts: "no-push-to-main.sh": runner: sh use_stdin: trueEOFchmod +x .lefthook/pre-push/no-push-to-main.shpnpm lefthook installgit add .lefthook lefthook.ymlgit 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”git push -u origin repo/push-guardgh pr create --fillgh pr checks --watchgh pr merge --squash --delete-branchgit switch maingit pullB3 · 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”git commit --allow-empty -m "chore: test the push guard"git push origin maingit reset --hard origin/mainPath A · once the organization is on GitHub Team
Section titled “Path A · once the organization is on GitHub Team”A1 · Open the repository settings
Section titled “A1 · Open the repository settings”gh repo view <GITHUB_OWNER>/opleet --webA2 · 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”git commit --allow-empty -m "chore: test the ruleset"git push --no-verify origin maingit reset --hard origin/mainExpect
Section titled “Expect”- 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:
git fetchgit log -1 --format=%s origin/mainShows “build: block direct pushes to main (#…)”.
Path A:
gh api repos/<GITHUB_OWNER>/opleet/rules/branches/main --jq '.[].type'Lists deletion, non_fast_forward, pull_request and required_status_checks.
If it fails
Section titled “If it fails”- 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)