Contents
My repositories follow one working model: squash-only merges, one required check called ci-required, rulesets on the default branch and on release tags. On public repositories, all of that works on the Free plan. On a private repository under a personal account without Pro, the plain settings applied, the protections were refused, and one setting failed in a way that looks exactly like success.
What the plan buys
For a repository owned by a personal account:
| Protection | Private, Free | Private, Pro | Public, Free |
|---|---|---|---|
| Repo rulesets, branch protection | no (403) | yes | yes |
| Auto-merge | 200, not applied | yes | yes |
| Secret scanning | no | no | yes |
| Code scanning | no | no | yes |
| Push rulesets | no | no | no |
The Private, Free cells for rulesets and auto-merge, and the Public, Free cell for push rulesets, are what the API returned. Every other cell, including the whole Pro column, comes from GitHub's availability notes for rulesets, protected branches, auto-merge, secret scanning and code scanning.
The scanning rows are the easy ones to miss: Pro does not add them. For private repositories the docs list secret scanning for organizations on Team or Enterprise Cloud with GitHub Secret Protection (user-owned repositories only under Enterprise Managed Users), and code scanning for organization-owned repositories with GitHub Code Security. Push protection, which stops a secret at git push instead of reporting it afterwards, doesn't fill the gap. The per-repository kind requires GitHub Secret Protection, and the per-account kind, push protection for users, "stops you from pushing secrets to public repositories on GitHub".
The loud refusals
Without Pro, a private user-owned repository refuses rulesets outright, and not only when you create one. Reading them is refused too, so a setup script that lists existing rulesets before creating one fails on the read, before it reaches the write. From a private repository of mine, on 14 October 2026:
gh api repos/OWNER/REPO/rulesets
# gh: Upgrade to GitHub Pro or make this repository public to enable this feature. (HTTP 403)
gh api repos/OWNER/REPO/branches/main/protection
# same message, same 403
rules/branches/main, the endpoint that lists the active rules for a branch, returns the same 403. A Free organization gets the equivalent for org-level rulesets, which GitHub documents for Team and Enterprise plans: Upgrade to GitHub Team to enable this feature.
Push rulesets, the ones that cap file sizes and block paths like *.pem or .env, are gated twice. GitHub documents them for "the GitHub Team plan in internal and private repositories". In August, when I tried one on a public repository under my personal account, the 422 gave both reasons at once:
{"message":"Validation Failed","errors":[
"Source public repos cannot have push rules",
"Source only org-owned repos can have push rules"]}
No personal plan gets you there. On a personal account that stays a gap, and I write it down as one: a line in CONTRIBUTING.md does not replace a rule the server enforces.
Then there is rollback. The REST reference for repository rules says evaluate, the mode that reports would-be violations without enforcing them, "is only available with GitHub Enterprise". Below Enterprise there is no dry run, only enforcement: "disabled", and that switches off every rule in the ruleset, not just the check. Rulesets that target the same branch are aggregated, so putting required_status_checks in a ruleset of its own makes the switch narrow: disable that one, and pull requests get past a broken check while deletion and force-push protection stay on. With everything in one ruleset, the narrow fix is editing it to drop the check. Write the rollback into the runbook before the day you need it.
The quiet one
Auto-merge does not refuse anything. On 13 October 2026, on a second private repository of mine, also user-owned and without Pro, this came back with HTTP 200:
gh api -X PATCH repos/OWNER/REPO -F allow_squash_merge=true -F allow_auto_merge=true \
--jq '{allow_squash_merge, allow_auto_merge}'
# {"allow_auto_merge":false,"allow_squash_merge":true}
The squash setting applied. Auto-merge stayed false: no error, exit code 0. A setup script that trusts the exit code reports this repository as configured.
What still works
Much of the working model is plain repository settings, and those apply on any plan. Read back on the same day from the repository that returned the 403s:
{"allow_squash_merge": true, "allow_merge_commit": false, "allow_rebase_merge": false,
"delete_branch_on_merge": true, "allow_update_branch": true,
"squash_merge_commit_title": "PR_TITLE", "squash_merge_commit_message": "PR_BODY"}
The gate runs too. On that second repository, ci-required reports on pull requests, but without Pro nothing can make it required there, so a red run stops nobody. I keep it anyway. When the plan changes, the ruleset names one context instead of a list. And the first condition on GitHub's troubleshooting page for required status checks is that a required check "must have completed successfully in the chosen repository during the past seven days". A gate that has been running all along meets that on day one. Running it on a private repository spends Actions minutes, though, and a billing problem stops it in a way that looks like broken CI: every job fails within seconds, with no log.
The aggregator is short. This is trimmed and simplified from the CI of schriftsatz, my Markdown-to-PDF tool:
ci-required:
needs: [filters, pdf, policy, secrets, dryrun, megalinter]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Every dependency must have succeeded
env:
RESULTS: ${{ join(needs.*.result, ' ') }}
run: |
for r in $RESULTS; do
[ "$r" = "success" ] || { echo "::error::dependency result: $r"; exit 1; }
done
if: always() makes the job run even when a dependency failed. Without it the gate would be skipped, and the same troubleshooting page says such a job "may not block merging": it lists success, skipped and neutral as successful check statuses. Comparing against success makes skipped and cancelled dependencies fail the gate as well, two of the four values needs.<job_id>.result can take. Each layer of a six-PR stack in schriftsatz passed ci-required; the stack itself is in my notes on stacked pull requests.
Secret detection moves into that gate: gitleaks (MIT) as a step running gitleaks git --redact --no-banner . over a full-history checkout. I call the binary directly; gitleaks-action wants a free licence key for repositories owned by an organization. That is detection, not prevention: it runs after the push, so a hit means the secret is already on GitHub and needs rotating, and on a private Free repository it only turns a check red.
Read it back
Where rulesets do work, the same habit applies. current_user_can_bypass tells you whether you can still do what a ruleset restricts, without creating a ref to find out. The list endpoint leaves the field out, hence one call per ruleset, and the organization-level endpoints don't return it at all. On schriftsatz, which is public:
for id in $(gh api repos/cronai-labs/schriftsatz/rulesets --jq '.[].id'); do
gh api "repos/cronai-labs/schriftsatz/rulesets/$id" \
--jq '"\(.name)\t\(.current_user_can_bypass)"'
done
Run as me:
default-branch pull_requests_only
release-tag-creation always
release-tag-immutability never
Releases are still possible, nobody can move or delete a tag without first changing the ruleset, and I can bypass the default-branch rules only by merging a pull request, past a red check if I choose to, never by pushing to main.
Configuration is real when the API reads it back, not when the command exits 0. On a private repository the gate stays advisory until the account moves to Pro, and I would rather know that than have a setup script tell me otherwise.