Skip to main content

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 main deploys everything, automatically. Both images build once, tagged sha-<12-hex-of-commit>; the staging stack at stage.<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

  • main is 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 from main (recent examples: feature/payroll-enterprise-grid, feature/multi-tenancy, feature/docs-overhaul) and lands via a GitHub pull request into main (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.
  • staging is a long-lived branch that deploys to the staging environment and stops theredeploy-production is guarded on refs/heads/main. Push to it when you want a change running at stage.<domain> without committing to shipping it. It builds its own sha- 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 staging to main (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 and deploy.sh always come from main. A staging push ships that branch's images against main's configuration. Infrastructure changes are only testable once they land on main — 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.
  • 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) — backend mvn test plus the frontend type-check and Vitest run. deploy.yml calls 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 touches docs/, 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. On main it also deploys docs.motorphenterprise.com, independently of the application deploy.
  • eslint runs continue-on-error while 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:

  1. Open https://<domain> (and https://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.
  2. 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.
  3. 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) and deploy/.env.*.example — the latter are the payloads of the PROD_ENV_FILE/STAGE_ENV_FILE GitHub Secrets, the actual configuration store for the VPS environments.
  • docker-compose.override.yml is a gitignored local shim — don't commit one (troubleshooting.md).
  • Adding RBAC permissions in a migration? Update frontend/src/constants/role-permissions.ts in the same PR (troubleshooting.md documents the failure mode).
  • Workflow files (.github/workflows/) only take effect once they exist on main — a workflow edited on a feature branch still runs its main version for workflow_dispatch until 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 outside docs/, which are rewritten to GitHub. To keep a page off the public site, put <!-- internal --> in its first few lines.