Git workflow: local → stage → production
This documents the workflow as actually practiced in this repo — not an aspirational model. There are no release branches; there IS a staging environment, and the path from a local edit to production runs through it.
Two branches deploy: staging deploys to stage.<domain> and stops,
main deploys to staging and then promotes to production automatically
once staging turns healthy. Everything else deploys nothing.
The path a change takes
LOCAL GITHUB VPS
┌────────────────┐ ┌──────────────────────────┐ ┌──────────────────────────────┐
│ docker compose │ │ feature/<name> ── PR ──► │ │ │
│ up -d --build │ │ CI: CodeQL, gitleaks │ │ │
│ + mvn test │ │ │ merge │ │ │
│ + npm test │ │ ▼ │ │ stage.<domain> │
│ + playwright │ │ main ── deploy.yml ──┼─► │ (own Postgres, Mailpit, │
└────────────────┘ │ build images once │ │ Polar sandbox) │
│ GHCR sha-<12> │ │ │ health gate passes │
│ env from Secrets │ │ ▼ (automatic) │
│ │ │ <domain> ← SAME image tag, │
│ │ │ health-gated, auto-rollback │
└──────────────────────────┘ └──────────────────────────────┘
- Local — the dev stack (docker-compose.yml, app on 5173) is where work happens. Tests run here, not in CI (testing/README.md); treat a local green suite as the merge gate CI doesn't give you — it is the only behavior gate in the whole path.
- Merge to
maindeploys everything, automatically. Both images build once, taggedsha-<12-hex-of-commit>; the staging stack atstage.<domain>gets them first (its own database, mail captured in Mailpit, billing on Polar's sandbox); and once staging's health gate passes, the same run promotes the same tag to production. Both env files are rewritten from GitHub Secrets on every run (deployment/vps-guide.md §5, §10). - The staging health gate is what protects production: a staging boot failure, broken migration, or config fail-fast auto-rolls staging back, turns the run red, and stops the pipeline — production stays untouched. What it cannot catch is a behavior bug that boots fine; that's the local suites' job.
- Manual buttons exist only for exceptions: Promote to Production re-ships an explicit tag without a rebuild; Rollback flips an environment to a previous tag. Neither is part of routine deployment.
The consequence to internalize: merging to main IS deploying to
production, a few health-gated minutes later. Merge only what you'd
ship; keep half-done work on its branch.
Branching
mainis the integration branch and the deployable truth. Every merge deploys it to staging; promotion sends the same commit's images to production.- Feature work happens on
feature/<short-kebab-name>branches cut frommain(recent examples:feature/payroll-enterprise-grid,feature/multi-tenancy,feature/docs-overhaul) and lands via a GitHub pull request intomain(merge commits, e.g. PR #25, #26). Long-running efforts stay on one branch with sequential commits — the multi-tenancy retrofit's 15-commit branch is the pattern. stagingis a long-lived branch that deploys to the staging environment and stops there —deploy-productionis guarded onrefs/heads/main. Push to it when you want a change running atstage.<domain>without committing to shipping it. It builds its ownsha-tagged images but never moves:latest.- Keep it short-lived in content: it is a place to try things, not a
parallel integration branch. Two branches that must be kept in sync is
a real failure mode — reset
stagingtomain(git checkout staging && git reset --hard main && git push --force-with-lease) whenever it has served its purpose, rather than letting the two diverge. - It does not carry infrastructure changes. The VPS checkout tracks
main(git pull --ff-only origin main), so compose files, the Caddyfile anddeploy.shalways come frommain. Astagingpush ships that branch's images against main's configuration. Infrastructure changes are only testable once they land onmain— which is precisely the class of change most likely to break a deploy, so treat those with extra care rather than assuming staging covered it.
- Keep it short-lived in content: it is a place to try things, not a
parallel integration branch. Two branches that must be kept in sync is
a real failure mode — reset
- Promotion still moves an artifact (the image tag), not a branch: production receives the exact images staging just ran.
- Small, low-risk changes (doc typos, comment fixes) are sometimes
committed directly to
main. Remember that this now triggers a staging deploy; prefer a branch + PR when the change touches behavior, migrations, or more than a couple of files. - Dependabot opens weekly
dependabot/*branches for Maven (backend), npm (frontend), and GitHub Actions updates. Review and merge them like any PR — and note that merging one deploys it to staging like any other merge.
Commit messages
The repo moved to conventional-commit prefixes with the multi-tenancy work
(2026-07): feat(scope): / fix(scope): / docs(scope):, lower-case,
imperative, with a subject that says what the change means rather than
which files it touches. Real examples from history:
feat(tenancy): let PostgreSQL enforce isolation underneath the application
fix(tenancy): say when PLATFORM_ADMIN_PASSWORD is being ignored
feat(deploy): origin TLS, GHCR images, and a rollback that means something
docs(tenancy): record provisioning, the platform operator, and the rate split
Older history uses plain imperative subjects without prefixes ("Add self-hosted ALTCHA proof-of-work verification…") — don't rewrite those, but new commits follow the conventional style. Detail goes in the body; the subject stays ≤ ~72 chars.
Pull requests
- Target
main. Keep a PR to one coherent topic; the payroll-grid migration landing as one PR of sequential commits (#26) is the pattern to copy. - CI must be green before merge. Every push/PR runs:
- Tests (
tests.yml) — backendmvn testplus the frontend type-check and Vitest run.deploy.ymlcalls this same workflow before it builds an image, so a PR check and a deploy can never disagree about what "green" means, and a red suite stops a deploy outright. - CodeQL — static analysis for Java (backend) and JS/TS
- gitleaks — secret scanning over full history
- Playwright smoke — boots the real stack (db + backend + frontend),
seeds it, and runs
e2e/smoke.spec.ts. The full suite is manual (workflow_dispatch) and is not a merge gate: it has documented known-failing tests (testing/e2e-playwright.md). - Docs (
docs.yml) — only when the change touchesdocs/,docs-site/, or the API surface. It generates the OpenAPI documents, builds the documentation site, and fails on an unresolvable link or on curated-out content reaching the output. Onmainit also deploys docs.motorphenterprise.com, independently of the application deploy.
- Tests (
- eslint runs
continue-on-errorwhile its existing errors are worked down, so it will not fail your PR — run it locally (testing/README.md).
What happens after you merge
Merging is not the end of the job — the deploy you triggered is. The Deploy run does staging, gates on its healthcheck, then promotes to production automatically. When it goes green:
- Open
https://<domain>(andhttps://stage.<domain>) and exercise what you changed — this is now post-deploy verification, so do it promptly; a behavior bug you find here is already live. - Email flows (signup codes, welcome mail) are rehearsable on staging —
its Mailpit catches everything:
ssh -L 8025:localhost:8025 deploy@VPS, then http://localhost:8025. - If the change includes a Flyway migration, it ran against staging's database first and production's minutes later — check the boot logs if it did anything interesting.
A red run means an environment auto-rolled-back (staging red = production was never touched; production red = production rolled back to the previous tag while staging runs the new one). Fix forward on a branch like any other bug, or use the Rollback workflow if the bad tag is live. If a change deserves human soak time on staging before production, that's the one case for hand-driving: revert the merge quickly, or ship it when you're ready to watch it.
Things to know before you merge
- Merging = migrating staging. Flyway migrations in your PR run against staging's database on merge, and against production's on promote. Migrations are append-only once merged: never edit an applied migration, take the next free version number (backend/entities-and-migrations.md). Rollback flips images, never the schema (deployment/vps-guide.md §10) — and with automatic promotion, a risky migration reaches production minutes after the merge. If one deserves human soak time on staging first, coordinate the merge accordingly.
- Never commit secrets.
.env,.env.stage, and.env.<client>are gitignored; gitleaks will flag leaked tokens in any case. Templates live in.env.example(dev) anddeploy/.env.*.example— the latter are the payloads of thePROD_ENV_FILE/STAGE_ENV_FILEGitHub Secrets, the actual configuration store for the VPS environments. docker-compose.override.ymlis a gitignored local shim — don't commit one (troubleshooting.md).- Adding RBAC permissions in a migration? Update
frontend/src/constants/role-permissions.tsin the same PR (troubleshooting.md documents the failure mode). - Workflow files (
.github/workflows/) only take effect once they exist onmain— a workflow edited on a feature branch still runs itsmainversion forworkflow_dispatchuntil merged. - Editing documentation? Write plain Markdown in
docs/and nothing else: no front matter, no MDX. The published site is generated from these files (docs-site/README.md), and its build fails on a link it cannot resolve — including links to files outsidedocs/, which are rewritten to GitHub. To keep a page off the public site, put<!-- internal -->in its first few lines.