ADR-0010 — Branch strategy + environments + code review policy
ADR-0010 — Branch strategy + environments + code review policy
Status: Accepted (CEO ratifying 2026-05-22) Date: 2026-05-22 Owner: tech-architect Paired implementation: tech-architect (this ADR + infra files + CLAUDE.md edits) + CEO (GitLab branch protection rule application, see §10 below) Supersedes: none Amends scope: ADR-0009 §3.1, §7 — replaces the single-env “master = prod” implicit model; the same VPS now hosts two stacks Related canon: ADR-0009 (VPS topology + LE SAN cert + GitLab CI shape), ADR-0006 (mock-auth posture preserved on both envs), D-015 (paused-indefinitely production migration — staging here is NOT that production)
1. Context
ADR-0009 stood up Phase 14 on a single Hetzner CX22 with one environment, one branch (master), and zero code-review gate. Every commit landed on master, every push triggered the deploy pipeline straight into the public-facing closed-beta endpoint. That topology was correct for the bootstrap window — minimal moving parts, fast iteration, no CI overhead, no review ceremony when the cohort was the CEO solo.
Pre-launch maturity changes the cost-benefit:
- The cohort is widening. Asynchronous tester traffic is starting to land. Every direct-to-prod push is now a potential cohort-visible regression — a TypeError in
/walker/profilereaches actual walkers within ~5 minutes ofgit push. - The codebase is past the “trivial to read in one sitting” stage. Backend modules have cross-imports (combat + quest + walker), Prisma schema has multi-table foreign keys, the data package’s content layer is large enough that an unfamiliar diff is non-trivial to vet at a glance. Single-reviewer-of-record (CEO) without a structured CR step means rapid changes ship without a second pair of eyes.
- The orchestrator review pattern is now mature. Earlier ADRs and PR-style work showed that orchestrator-dispatched cross-checks (system-reviewer for canon, lead-level review for cross-domain conflicts) catch real issues — naming collisions (D-027 reviewer block on
class.pisarz), schema drift, language-policy violations. There is no longer a “we don’t know what good CR looks like for this codebase” excuse. - Hotfix discipline is needed. If the production endpoint serves a bad commit, the current rollback path is “git checkout previous SHA on VPS,
docker compose up”. Workable, but it routes the recovery through the same direct-push channel as the original mistake, with no second eyes between mistake and fix. A protectedmainbranch with a dedicated hotfix workflow tightens the loop.
What we do NOT need: a remote dev environment for ad-hoc developer experimentation. The CEO works on a local VM (pnpm dev), and that is sufficient for the dev tier. The VPS budget pays for two stacks (prod + staging); a third would push us into a different VPS tier with no observable benefit at cohort scale.
What we do need:
- Three branches —
main(prod-deployable),dev(staging-deployable, integration),feature/*(ephemeral per-feature). - Two environments —
prod(real testers, real data,mainbranch) andstaging(internal smoke + exit-scenario matrix,devbranch). The local VM remains the dev tier; no remote dev env. - Merge-request gating — direct push to
mainanddevis denied. Every change goes through an MR with at least one automated review. - Orchestrator-driven auto-review — every MR open / update triggers a claude-orchestrator review pass. CEO is the human approver.
- Branch protection in GitLab — enforced at the platform level, not just convention.
This ADR is the spec for that move.
2. Decision
WalkRPG adopts a 3-branch model (main + dev + feature/*) paired with a 2-environment topology (prod + staging) on the existing Hetzner CX22, orchestrator auto-review per MR open / update, and GitLab branch protection rules enforcing no-direct-push on the two protected branches.
Squash-merge keeps the commit log on protected branches linear and one-commit-per-feature.
The dev tier (local VM, pnpm dev) is unchanged. No remote dev environment is provisioned — this is a deliberate cost decision per §6.
3. Branch model
| Branch | Lifecycle | Protection | Source | Target | Merge mode |
|---|---|---|---|---|---|
main | Permanent, always-deployable | Protected: no direct push, no force-push, no delete | MR from dev (release flow) or hotfix/* (emergency) | Auto-deploys to prod | Squash |
dev | Permanent, integration | Protected: no direct push, no force-push, no delete | MR from feature/* | Auto-deploys to staging | Squash |
feature/<short-name> | Ephemeral, deleted after merge | None — force-push OK during MR iteration | Branch from dev | MR to dev | (merged via squash into dev) |
hotfix/<short-name> | Ephemeral, deleted after merge | None — force-push OK during MR iteration | Branch from main | MR to main AND follow-up MR to dev (sync) | (merged via squash into main; cherry-pick or follow-up MR carries the same fix into dev) |
Naming: feature/<kebab-case-short-description> — e.g., feature/walker-profile-pagination, feature/cluster-mastery-recipe-import. No Jira IDs (flat backlog).
Hotfix naming: hotfix/<kebab-case-short-description> — e.g., hotfix/auth-callback-500, hotfix/migrations-deadlock.
4. Environment topology
| Env | Branch | Auto-deploy trigger | Hostname(s) | Database | Volume | Purpose |
|---|---|---|---|---|---|---|
| prod | main | Push to main (post-merge) | api.walkrpg.morrisassert.dev, morrisassert.dev, www.morrisassert.dev, wiki.morrisassert.dev (CF-Access-gated), walkrpg.morrisassert.dev (503) | walkrpg_prod (compose stack name walkrpg) | ./pgdata-prod/ on VPS | Real testers, real data, public closed beta |
| staging | dev | Push to dev (post-merge) | api-staging.walkrpg.morrisassert.dev | walkrpg_staging (compose stack name walkrpg-staging) | ./pgdata-staging/ on VPS | Internal smoke testing, exit-scenario matrix runs, pre-prod verification |
| dev (local) | (any) | Manual (pnpm dev) | localhost:3000 | Local Postgres (compose dev or container) | local | Developer-local iteration. No remote endpoint. |
Wiki + portfolio are deployed once, on prod env only. The wiki is static canon; a staging variant would not surface enough signal to justify the cost. If we ever need a “preview wiki” for major canon edits, we revisit (B-level swap when triggered).
Why no remote dev env:
- The CEO’s local VM and
pnpm devcover the dev workflow. - A third remote env would push the VPS past its memory budget (see §6).
- The MR-to-staging flow gives the same “shared deployment for verification” benefit at lower cost.
5. Workflow
5.1 Feature flow (normal path)
- CEO checks out
dev, pulls latest, branches:git checkout -b feature/<name>. - Work happens. Auto-commit at
/workboundaries lands commits on thefeature/<name>branch (per CLAUDE.md Git conventions, unchanged for feature work). git push origin feature/<name>— first push sets upstream. CI runslint+teststages on every push (no build/deploy on feature branches).- When ready, CEO opens MR
feature/<name> → devin GitLab. - On MR open / update, the orchestrator auto-review fires (§6 below). Verdict: APPROVE / NEEDS_CHANGES / FLAG_LEAD.
- CEO reads orchestrator’s summary + inline comments. Iterates if needed (force-push to the feature branch is fine — protected branches are unaffected).
- CEO approves + clicks Merge. Squash merge.
devadvances by one commit. devauto-deploys to staging. Smoke test in staging (https://api-staging.walkrpg.morrisassert.dev/).- Feature branch deleted post-merge (manual at first; GitLab can be configured to auto-delete on merge — recommended).
5.2 Release flow (dev → main)
- CEO inspects staging stability (recent staging runs, no open bugs of consequence).
- CEO opens MR
dev → mainin GitLab. - Orchestrator auto-review fires (same hook as feature MRs — only the source/target combo differs).
- CEO reviews + approves + clicks Merge. Squash merge.
mainadvances by one commit (representing the batch of features that landed ondevsince the last release).mainauto-deploys to prod.- Post-deploy smoke test against prod (
H1from the runbook).
5.3 Hotfix flow (emergency on prod)
- CEO checks out
main, branches:git checkout -b hotfix/<name>. - Fix + commit + push.
- MR
hotfix/<name> → main. Orchestrator auto-review (expedited — orchestrator flags emergency in the summary if the diff is small + scoped). - CEO approves + merges. Squash.
mainadvances by one commit. mainauto-deploys to prod.- Sync
dev: open follow-up MRhotfix/<name> → devfrom the same branch (or cherry-pick the merged-squash commit onto a newfeature/sync-hotfix-<name>and MR that todev). Both paths work; the cherry-pick onto a feature branch is cleaner becausedevmay have diverged. devadvances; auto-deploys to staging. Staging tracks prod.
5.4 What stays unchanged from the current workflow
- Commit style
type(scope): description— same conventions. - Auto-commit at
/workboundaries — but commits land onfeature/<name>, NOT directly onmainordev. The orchestrator running/workfrom a feature branch commits to the same feature branch. Co-Authored-Bytrailer policy — unchanged where applicable.- Lint + test gates — unchanged. They now run on feature branches AND on MR pipelines.
6. Resource budget
The Hetzner CX22 has 4GB RAM (per ADR-0009 §4 — corrected from the brief’s “8GB” figure; the spec sheet is 4GB and that is what we provisioned). The two-stack budget:
| Component | Approx memory |
|---|---|
| prod stack (api + db + nginx + certbot + web + wiki-builder) | ~560 MB |
| staging stack (api + db only — nginx/wiki/portfolio shared with prod) | ~300 MB |
| OS + Docker daemon + fail2ban + sshd | ~400 MB |
| Buffer (kernel page cache, brief spikes during deploy) | ~600 MB |
| Total used | ~1.86 GB |
| Headroom | ~2.14 GB |
Comfortable on CX22 (4GB). Vertical-bump to CX32 (8GB) is the cost-controlled escape hatch if either stack grows past the headroom; that bump is a B-level autonomous swap within the same VPS class.
No third remote env because adding ~300 MB for a remote dev stack pushes the buffer below 600 MB, narrowing the deploy-time spike margin to uncomfortable. The local VM dev tier is the alternative, and it costs €0.
7. Code review policy
7.1 Orchestrator auto-review per MR
Every MR open or update event triggers an orchestrator review pass:
- Gather context: orchestrator reads the MR diff, related ADRs, CLAUDE.md conventions, and any failing CI jobs.
- Sub-agent dispatch: orchestrator dispatches the relevant leads inline depending on diff scope (tech-architect for schema/infra changes, narrative-designer for content changes, system-reviewer for canon-touching commits, etc.).
- Inline comments: orchestrator posts file-line-level comments via the GitLab API where it has specific concerns.
- Summary verdict: APPROVE, NEEDS_CHANGES, or FLAG_LEAD (the last routes to a specific lead for cross-domain conflict mediation per CLAUDE.md decisions policy).
For night 1, the orchestrator is invoked by CEO via claude /review <MR-URL> rather than a GitLab webhook. The webhook integration (GitLab webhook → claude trigger) is a follow-up — see §11.
7.2 CEO self-review as second check
After orchestrator returns its summary, CEO re-reads the diff with the summary in hand. CEO is the final approver — orchestrator’s APPROVE verdict counts as “one approval” toward the protected-branch rule, but only CEO can click Merge.
7.3 Squash merge
Squash merges keep the protected-branch commit log linear: one merge = one commit on dev/main, with the MR title as the commit message. The feature branch’s own commit history is preserved in the MR’s discussion (visible in GitLab UI), so granular /work-boundary commits are not lost — they just don’t pollute the protected log.
8. Branch protection rules (GitLab UI)
CEO applies these in GitLab → positive-walkers/walkrpg → Settings → Repository → Protected branches.
8.1 main branch
| Field | Value |
|---|---|
| Branch | main |
| Allowed to merge | Maintainers |
| Allowed to push and merge | No one (force-merge disabled, direct push disabled) |
| Allowed to force push | OFF |
| Code owner approval required | OFF (no CODEOWNERS file yet — follow-up) |
| Allow deletion | OFF |
| Require approval from a code owner | OFF |
| Require approval from anyone (free tier) | 1 approval required for merge |
8.2 dev branch
| Field | Value |
|---|---|
| Branch | dev |
| Allowed to merge | Maintainers |
| Allowed to push and merge | No one |
| Allowed to force push | OFF |
| Code owner approval required | OFF |
| Allow deletion | OFF |
| Require approval from anyone (free tier) | 1 approval required for merge |
8.3 feature/* and hotfix/*
| Field | Value |
|---|---|
| Wildcard pattern | feature/* |
| Allowed to push | Developers + Maintainers |
| Allowed to force push | ON (rebase iteration is normal) |
| Allow deletion | ON |
Same shape for hotfix/*.
Note on the “1 approval required”: GitLab free-tier projects allow Required Approvals only as a hard count, not as a code-owner check. Orchestrator counts as an approval if/when the GitLab API integration lands (§11); until then, CEO is both the approver and the merger (which GitLab does permit on the same MR — the approver-equals-merger restriction is a premium feature).
For night 1, the protection rule that bites is “no direct push” — that alone forces every change through an MR, which is the maturity step we want.
9. CI/CD shape (multi-env)
The pipeline shape extends ADR-0009 §7 with two deploy jobs:
lint (every push, every MR) ↓test (every push, every MR) ↓build (push to dev or main only) ↓deploy-staging (push to dev only)deploy-prod (push to main only)Build produces a single image tagged <short-sha> + branch name (dev or main). Both deploy jobs use the same SSH_DEPLOY_KEY / SSH_DEPLOY_HOST CI variables. Branch-conditioned rules: decide which deploy fires.
resource_group: is per-env (resource_group: production for deploy-prod, resource_group: staging for deploy-staging) so concurrent pushes to one env don’t race, but the two envs CAN deploy in parallel.
10. Migration from current master direct-push
CEO executes after this ADR ratifies. Steps are non-destructive — the master history is preserved verbatim under the new name.
10.1 Rename master → main (GitLab UI)
GitLab → positive-walkers/walkrpg → Settings → Repository → Default branch → change to main. GitLab supports the rename without rewriting history; existing commits stay attached, the default branch label moves.
If a literal main branch does not yet exist when you make the change: GitLab UI offers a one-click rename of the existing default branch. Use that — it renames master to main in place. Confirm the rename in the Branches list.
Local clones update their default with:
git fetch origingit branch -m master maingit branch -u origin/main maingit remote set-head origin -aGitLab is the source of truth; local rename is a follow-up housekeeping step.
10.2 Branch dev from main
From the new main:
git checkout maingit pull origin maingit checkout -b devgit push -u origin dev10.3 Apply branch protection rules
See §8.1 + §8.2 above. CEO applies in GitLab UI.
10.4 Update .gitlab-ci.yml
build:image rule $CI_COMMIT_BRANCH == "master" → $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "dev".
deploy: rule splits into deploy-prod (main) and deploy-staging (dev).
workflow: master references → main.
This update is part of the same commit set as the ADR.
10.5 Open MRs at migration time
Any open MRs targeted at master need to be retargeted at dev (for feature work) or main (for emergency-only). GitLab UI supports retarget without losing the MR thread.
For night 1 (this ADR), there are no open MRs — the migration window is clean.
11. Known limitations / follow-ups
11.1 No ephemeral preview env per MR
GitLab CI supports per-MR preview environments (review apps). We skip this for night 1 because each preview env would consume ~300 MB of VPS memory and the CX22 budget does not have a per-MR slot. For now, “preview” = pnpm dev against the staging API.
Re-evaluate when cohort growth or feature density makes the MR-preview-env benefit obvious. B-level swap.
11.2 No automatic database migration on staging deploy at first
The deploy-staging job CAN run prisma migrate deploy against walkrpg_staging as part of the deploy — and the night-1 implementation in .gitlab-ci.yml does invoke it. The “limitation” is that we have no automated check that the staging migration plan is forward-compatible with prod’s current state. Manual review during MR is the current gate.
Re-evaluate when the schema has a destructive migration that requires a two-phase prod deploy. Tracked under ops follow-up.
11.3 No automatic rollback
Rollback is manual via runbook §K6 (now extended to multi-env). Automated rollback (CI-driven revert + redeploy) is a follow-up — value low at cohort scale.
11.4 Orchestrator review is CEO-invoked, not webhook-driven
For night 1, CEO runs claude /review <MR-URL> after opening the MR. GitLab Webhook → claude trigger requires either a self-hosted webhook receiver (claude-runner-as-a-service) or a GitLab Action equivalent (GitLab has CI-based integrations but no native “trigger external HTTP on MR open” without a custom webhook target).
Follow-up: stand up a minimal webhook receiver (~50 LOC Node) on the VPS that POSTs to a claude-orchestrator entry point. B-level when triggered.
11.5 No CODEOWNERS file yet
GitLab supports CODEOWNERS-based approval routing on paid tiers; we’re on free tier, so the value is limited. We may still author a CODEOWNERS file to give orchestrator + manual reviewers a routing hint per path — B-level when narrative-designer / mechanics-designer want path-scoped review attention.
11.6 Verify-no-direct-push CI job
A pipeline job that fails if a commit lands directly on main or dev (i.e., the branch protection was bypassed via an admin override) is documented as a follow-up. Not strictly required because GitLab branch protection IS the gate; the CI job would be belt-and-braces.
11.7 Staging cert is the same SAN
The Let’s Encrypt SAN cert (per ADR-0009 §5) now covers six hostnames instead of five — api-staging.walkrpg.morrisassert.dev joins the list. Renewal is unchanged; certbot pulls the same fullchain.
If api-staging is ever removed from the SAN list (e.g., staging moves to a different host), the cert rotates on next renewal automatically.
12. Consequences
- Prod stability tightens. Every commit reaching prod has been through orchestrator review, staging deploy, and CEO sign-off. The cohort sees fewer regressions per unit time.
- Iteration latency increases. A feature now takes minimum two MRs (
feature → dev, thendev → main) to reach prod, plus one orchestrator review pass. At cohort scale this is the right tradeoff; pre-CR it was not. - GitLab CI minutes consumed roughly double. Each push to a feature branch still runs lint + test; each MR pipeline runs lint + test; each merge to
devruns build + deploy-staging; each merge tomainruns build + deploy-prod. Free tier (400 min/mo) remains comfortable at the current change cadence; monitor at ~300 min/mo for upgrade trigger. - VPS memory budget holds. Two stacks fit; the headroom is real.
- Hotfix discipline lands. A bad prod deploy has a defined recovery channel — branch from
main, MR, deploy, syncdev— rather than the ad-hoc “checkout previous SHA” pattern. - The “every commit ships” workflow ends. Pre-launch maturity comes with the cost of slower cycle time per individual change. This is intentional and reversible if cohort grows in unexpected directions (e.g., a tight pre-public-launch sprint may temporarily re-enable direct-push to
dev; protected branches can be relaxed via GitLab UI as a B-level swap, then reapplied). - One ADR-0009 amendment lands: §3.1 stack table is now per-env (prod + staging); §1 context is annotated that ADR-0010 supersedes the implicit single-env assumption.
13. When this ADR retires
ADR-0010 stays in effect through Phase 14 and into the production migration (paused per D-015). The 3-branch + 2-env shape is portable to a production-target host swap — only the deploy endpoints change.
The ADR’s protection-rule + workflow content survives a host migration. The infra-specific bits (compose file names, nginx server blocks, LE SAN list) update in the production-migration ADR at that time.
14. Open questions
- Should
masterrename be coordinated with portfolio + wiki sub-monorepo branches? All branches live in the same repo (monorepo), so the rename is one operation. No coordination needed. - Does CEO want auto-delete-source-branch enabled in GitLab settings? Recommend ON for
feature/*andhotfix/*. CEO confirms during §10.3 protection-rule application. - Does CEO want squash-only or also merge-commit allowed? Recommend squash-only (cleaner protected log). CEO confirms; squash-only is the default the §10.4 CI update assumes.