Git Flow & Branch Protection
This is the agreed branching and release workflow for all @rtorcato/* repos.
The model is GitHub Flow, not Git Flow: there is one long-lived branch,
main, and it is always releasable. Every change lands through a short-lived
branch and a reviewed pull request. main is protected so nothing reaches it
except a green, reviewed PR — and the release bot.
The flow
- Branch off
mainwith a prefix:feat/,fix/,docs/,chore/, orrefactor/(seeCONTRIBUTING.md). - Open a PR into
main. CI runs lint, typecheck, build, and tests. - Get it green, then squash-merge. One conventional commit per change keeps
history linear and lets
semantic-releasecompute the next version. - The push to
maintriggerssemantic-release: it bumps the version, updatesCHANGELOG.md, tags, publishes to npm, and creates the GitHub release.
That's the whole loop. main is the trunk and the release branch at once.
Why there is no dev branch
A long-lived dev/develop branch is deliberately not used:
semantic-releasetreatsmainas the release trunk — it analyzes commits onmainand releases from it. Adev → maingate adds a second integration step and recurring merge conflicts (notably onpnpm-lock.yaml) with no safety gain. Safety comes from branch protection, not from an extra branch.- Prereleases don't need a permanent branch.
release.config.mjsalready mapsnext,beta, andalpha(anddev) to prerelease channels. When you actually need to stage an unreleased line, create one of those branches on demand; it produces-beta/-alphatags and is deleted when the line ships.
The risk of "everything goes to main" is real, but the fix is protecting
main, below — not maintaining a parallel branch.
Branch protection on main
main requires a pull request and passing checks; direct pushes, force-pushes,
and deletion are blocked.
| Setting | Value |
|---|---|
| Require a pull request before merging | ✅ |
| Required approving reviews | 0 for solo maintenance (the gate is "no direct human pushes"); raise it once there are other maintainers |
| Require status checks to pass | ✅ — see contexts below |
| Require linear history | ✅ (matches squash-merge) |
| Allow force pushes | ❌ |
| Allow deletions | ❌ |
Merge method: squash, and only squash
fix github-settings turns allow_merge_commit and allow_rebase_merge off
as well as turning squash on, so the merge button offers one option. Leaving the
other two enabled means the rule lives only in this document, and one mis-click
puts every intermediate branch commit on main:
- semantic-release reads them all. Squash yields one commit per PR whose
subject is the reviewed PR title. A merge commit lands subjects nobody
reviewed — a stray
fix:inside a docs-only PR cuts a release. - Agent worktrees leak.
ai-issue-loopconfirms work landed by finding the(#N)squash subject onmain; without it, cleanup silently finds nothing.
Required status checks
The required contexts are exactly the jobs that run on pull_request → main:
linttypecheckbuildtest (node 22)test (node 24)
commitlintis intentionally not a required check. It runs only onpushevents (github.event_name == 'push'), so it never reports a status on a PR. Marking it required would leave every PR waiting forever on a check that never arrives. Commit-message linting is still enforced — locally by the Huskycommit-msghook and onmainby the push-triggeredcommitlintjob.
The semantic-release exception
After a PR merges, the release job runs on main and pushes a
chore(release): … [skip ci] commit and a tag directly to main — it does
not open a PR for the version bump. Because that commit carries [skip ci], no
status checks run on it, so required-status-checks would otherwise block the
release push.
Branch protection must therefore let the release identity bypass the rules.
Use a repo ruleset for main mirroring the settings above and add the
release identity to its bypass list. (Alternatively, run the release job
under an admin's PAT / GitHub App token and leave "include administrators" off,
so that identity bypasses.) This bypass is for the release bot only — humans
always go through a PR.
Milestones
A milestone is a release gate, not a chore bucket. Milestone an issue only if
leaving it undone would block declaring that stage complete; refactors, CI,
dependency bumps and docs typos get no milestone. Otherwise the percentage
inverts — a v1.0 milestone reads 80% because it is full of chores while the
three items that actually define v1.0 stay open.
Corollary: ai-ready and milestone-worthy are near-mutually-exclusive.
ai-ready means mechanical and bounded; milestone-worthy means it shapes the
public API, which is precisely what should not run unattended.
doctor audits this as the Milestones check:
| Finding | Why it matters |
|---|---|
| 100% complete but still open | "Open" stops meaning "in flight", so the milestone list carries no signal |
| No issues at all | GitHub renders the bar as closed / total, so an empty milestone is a permanent 0% |
Titled backlog / post-N / someday | No completion criterion means it can never close — that is a label |
fix milestones closes only the first case. It never deletes a milestone and
never creates one, because both destroy or invent planning intent. A repo with
no milestones at all is skipped — that is a legitimate choice.
Single source of truth
- This standard,
CONTRIBUTING.md(branch prefixes, the ≤67-char PR-title rule), andreference/semantic-release.md(what the release job does) together describe the full workflow. - Issue-triggered automation has its own gating — see Public-Repo Issue Safety.
Rollout
- Apply the branch-protection ruleset on
main(settings above) with the release identity in the bypass list. - Verify: a red PR can't merge; a green PR squash-merges; a direct
git push origin mainis rejected; the next merge still releases. - Roll the same ruleset out to other public
@rtorcatorepos.