Get listed

Security-first team: merge gates on main, not a slogan

A railway merge gate with a torn coral banner.

A security-first team is a merge rule and a named owner, not a poster in the kitchen.

If two of forty-four public orgs that enabled branch protection also required a check before merge, the rest had a setting that did not bite. The control is the required check, not the green shield on the repo.

The usual mistake is adding a security champion title and no permission to block a PR that ships a raw query.

This page is the GitHub and process moves that make ‘security first’ visible in the next merge.

2 of 44 public orgs that had turned on branch protection also required a check before merge. peternovakdev’s 26 May 2026 Hacker News comment on a scan of 128 YC-backed tool companies. A slogan on the wiki did not appear in that count. A merge that can ignore CI did.

Education is not a merge rule. This page is the rule set for invoice-app. Pair it with the secure coding checklist for the control the reviewer still has to name, and with lockfile vs a hostile install for the job that is not a peer review. Session cookies live on session management.

A poster will not block main

A team that writes "we put security first" and leaves main open has a poster. Git will accept the push. GitHub will accept the merge if no rule forbids it. The junior hire, the weekend hotfix, the admin who clicks Merge anyway: those are the actors. A living wiki page does not sit on that path.

Those are real failures. They are also slogans if the fix is "remind people." The fix that survives a Friday deploy is a rule the host enforces, a job the merge waits on, and a role that is not Administrator for daily work.

GitHub’s About rulesets page on 22 August 2026. Rulesets are a named list of rules for customers on GitHub Team and GitHub Enterprise. Classic branch protection still exists and still layers with rulesets. Anyone with read access can view active rulesets. That last sentence is why you prefer a ruleset when your plan has it: the auditor can see the lock without an admin seat.

Merge waits on a person and a job. The poster is not on this path.
PUSH feature/export-csv
 |
 v
PR invoice-app #1842
 CODEOWNERS pings @invoice-leads
 |
 v
JOB review-gates required
 secret scan, hatch list, Semgrep
 red -> merge button dead
 |
 v
RULE main: pull request + owner approve
 no admin bypass, no force push
 |
 v
MAIN deploy from this commit only

Three switches that actually merge-block

On invoice-app you turn on three things. Names below are the GitHub labels on the protected-branch and ruleset pages. Do not invent a fourth slogan. Do not skip the third because the first two look green in the UI.

  1. Require a pull request before merging. Direct pushes to main die. A hotfix still opens a PR. That is the point.
  2. Require review from Code Owners. Any one listed owner can satisfy the rule. That is GitHub’s written behavior. Put two people on the sensitive paths if you need coverage when one is out, and accept that one approve still ships.
  3. Require status checks to pass and type the check name review-gates. A protection banner that does not name a check is the 42-org case. The job must exist, must be required, and must stay required after you rename the workflow file.

Also turn off force pushes and deletions on main. Do not add administrators to the bypass list for that ruleset. ruleset page: bypass can be a role, a team, or a GitHub App. A human admin bypass is how a Friday incident becomes an unsigned commit on production. If a break-glass path must exist, make it a GitHub App the on-call owns, log the bypass, and rotate the app after use. I am not giving you a silent human override.

Rulesets let you set enforcement to Active or Disabled without deleting the object. Leave it Active on main. Use Disabled only while you are writing the first review-gates job, and only for a day you put on the calendar.

CODEOWNERS is a request until the rule is on

GitHub’s About code owners page. The file requests a review. It does not block. The block is the switch in the last section. Owners need write access. A team owner must be visible and must itself have write, even if every member already has write. A missing team is a silent skip. Open the file in the GitHub UI; bad lines highlight. The REST list of CODEOWNERS errors is the machine check.

Last matching pattern wins. Own the file that owns the rest. GitHub’s own note says the most secure method is to put the file in .github/ and own that path.

#.github/CODEOWNERS
# invoice-app owners must have write
/.github/CODEOWNERS @invoice-org/invoice-leads
/.github/workflows/ @invoice-org/invoice-leads
/src/auth/ @invoice-org/invoice-leads
/src/routes/invoice.js @invoice-org/invoice-oncall
/src/webhooks/ @invoice-org/billing-oncall
hatches.json @invoice-org/invoice-leads
tools/check_hatches.py @invoice-org/invoice-leads

Do not put * @invoice-org/everyone as the only line and call the file done. That is a group chat. Own auth, webhooks, workflows, and the inventory file by name. Own invoice.js by the team that can break a tenant. A later, more specific line beats a broad one. Keep the leads on the file that lists the leads.

Name the jobs main is allowed to wait on

A required check that does not exist will sit yellow forever, and someone will remove the requirement. Name the job, commit the workflow, then attach the name. Identifiers on this page stay review-gates, hatches.json, and check_hatches.py.

Semgrep 1.174.0 published on 20 August 2026. As of 21 March 2026, GitHub release. Pin the image your org already uses. Gitleaks 8.30.1 published is the secret grep if you do not have GitHub secret scanning on the repo. As of 22 August 2026, GitHub’s push-protection page: repository push protection is off by default and needs GitHub Secret Protection. User-level push protection is on by default for public pushes from your account. That user default is not your team’s repo lock. Turn the repo feature on, or run Gitleaks as the named job.

#.github/workflows/review-gates.yml
name: review-gates
on:
 pull_request:
 branches: [main]
jobs:
 review-gates:
 runs-on: ubuntu-latest
 steps:
 - uses: actions/checkout@v5
 - name: hatches-present
 run: python3 tools/check_hatches.py
 - name: gitleaks
 uses: gitleaks/gitleaks-action@v2
 env:
 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
 - name: semgrep
 run: |
 docker run --rm -v "$PWD:/src" \
 semgrep/semgrep:1.174.0 \
 semgrep ci --config p/owasp-top-ten \
 --baseline-commit "${{ github.event.pull_request.base.sha }}"
# tools/check_hatches.py
import json, pathlib, sys

hatches = json.loads(pathlib.Path("hatches.json").read_text())["hatches"]

def has_symbol(hatch):
 path = pathlib.Path(hatch["file"])
 if not path.is_file():
 print("missing file", hatch["id"], hatch["file"])
 return False
 text = path.read_text()
 return hatch["symbol"] in text

missing = [h["id"] for h in hatches if not has_symbol(h)]
unowned = [h["id"] for h in hatches if not h.get("ownerId")]
if missing or unowned:
 print("missing", missing, "unowned", unowned)
 sys.exit(1)

If check_hatches.py fails, the merge does not start. That function is the named fallback: a red PR, not a skip, not a Slack shrug. The inventory file is how you notice a new webhook that nobody owns. The Semgrep line is the floor. It will not read canInvoice. The owner still does that in the review the code-owner switch forced.

Daily work does not use Administrator

A developer who holds the cloud admin role for daily work, then leaks the key, is the hole. The control is a role that can deploy invoice-app and nothing else, plus a secret that never sits in a shared doc.

On GitHub, daily push uses a personal account or a fine-grained PAT scoped to one repo. Actions uses GITHUB_TOKEN with the default contents write it already has for that workflow. Do not mint a classic PAT with repo and admin:org and paste it into a README. On AWS, the deploy role can ecs:UpdateService on the invoice cluster. It cannot iam:CreateUser. I am not walking an IAM policy novel. The test is: the key in the runner cannot create a new admin.

Shared default passwords in a Google Doc are still how a team skips a secret store. Put the database secret in the host’s secret store. Each person gets their own SSO seat. When someone leaves, you revoke the seat the same day, and you rotate the one deploy role they could assume. A cleanup wiki page is not the revoke.

Push protection on the repo is how a .env that still holds AWS_SECRET_ACCESS_KEY dies before history. If your plan does not include Secret Protection, the Gitleaks step above is the stand-in. gitleaks/gitleaks-action@v2 needs GITLEAKS_LICENSE on org and private repos. A missing license is a red job for a reason this page now names. A bypass reason of "I’ll fix it later" leaves an open alert. Treat that alert like a live key. Rotate, then close.

The pull request form is a gate

Reviewers rubber-stamp a blank description. Give them a form that names the hatch and the control. GitHub issue forms work on PRs if you keep a markdown template that the author cannot submit empty. Required checkboxes in a markdown template are social. The real gate is still the job and the owner approve. The form is how the owner spends the two minutes you bought.

<!--.github/pull_request_template.md -->
## Hatch
- hatchId: <!-- http-invoice-export -->
- file: <!-- src/routes/invoice.js -->
- new interpreter or path? yes / no

## Control I named
- [ ] object check is canInvoice, not the path id
- [ ] no new Sequelize.literal / whereRaw / $queryRawUnsafe
- [ ] secret is not in the diff
- [ ] lockfile changed only when I meant to bump

## Retest
- test file: <!-- test/get-invoice-idor.test.js -->

If the hatch is new, hatches.json must change in the same PR. check_hatches.py fails when the symbol is missing. That is how a new export route cannot sneak past a reviewer who only read the handler. The checklist is what they tick. This page is what blocks the merge when they do not.

Claim Gate on invoice-app Proof
Lock the Git branchPR + owner approve + named checkgh api ruleset or protection
Do not share admin keysScoped deploy role, no org-admin PATIAM simulate, token scopes
Stop default passwordsSSO seat, secret store, same-day revokeIdP offboarding ticket
Write proceduresThis template + CODEOWNERSPR 1842 still in git

Prove invoice-app yourself

You are proving main refused a merge that skipped the job or the owner. You are not attacking GitHub. Use a repo you own.

  1. Commit .github/CODEOWNERS, review-gates.yml, and tools/check_hatches.py on a throwaway clone of invoice-app.
  2. Turn on the three switches for main. Type review-gates as the required check. Do not add yourself to bypass.
  3. Open a PR that deletes the canInvoice symbol from src/routes/invoice.js but leaves the hatch row in place. Expect check_hatches.py to exit 1 and the merge button to stay dead.
  4. Restore the symbol. Approve as a non-owner. Expect merge to stay dead. Approve as @invoice-org/invoice-oncall. Expect the button to enable only after the job is green.
  5. From a laptop, try git push origin main with a direct commit. Expect the host to refuse.
# read the lock. classic protection or a ruleset, both are fine
gh api repos/:owner/invoice-app/branches/main/protection
gh api repos/:owner/invoice-app/rulesets

# CODEOWNERS errors must be empty
gh api repos/:owner/invoice-app/codeowners/errors

If protection JSON has no required_status_checks contexts, you are in the 42-org bucket. If require_code_owner_reviews is false, the file is a mailing list. If enforce_admins is false, an administrator can still ship a Friday commit that skipped the job. Identifiers stay invoice-app, review-gates, and check_hatches.py.

Questions we keep getting

Does a security champion replace these switches?

No. A champion who cannot block main is a mailing list. Give that person write on the paths they own, put them in CODEOWNERS, and keep the named job required. Office hours are extra.

We are two people. Is owner-review theater?

Two people is why you still want the job. A self-approve bypass is how a tired owner ships an unlocked webhook. If GitHub lets the author satisfy the owner rule, turn that off. The check still has to go green.

Can I put the ruleset in the repo as code?

The API can create a ruleset. Terraform and gh api both work. The UI is enough for one repo. What you must commit is CODEOWNERS, the workflow, and check_hatches.py. A wiki screenshot of the UI is not the lock.

Aphinya Dechalert

Aphinya Dechalert / About Author

Aphinya is a skilled technical writer with field experiences in software development, agile, and JavaScript full stack with AWS and Google cloud. She is a developer advocate and community builder, helping others navigate their journeys and careers as developers.