Skip to content

Commit fd765c9

Browse files
authored
Merge pull request #75 from GRACENOBLE/add-deploy-and-staging-sync
feat: add production deploy and automatic staging sync
2 parents bdebf09 + cccf3fc commit fd765c9

7 files changed

Lines changed: 372 additions & 2 deletions

File tree

‎.github/workflows/sync-staging.yml‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: Sync Staging
2+
3+
# Keeps the `staging` branch tracking `main` so hosting platforms configured
4+
# to auto-deploy from `staging` pick up every merge automatically. This
5+
# mirrors what `make deploy-prod` (scripts/deploy-prod.sh) does for
6+
# `production`, but runs unattended — no confirmation prompt — since staging
7+
# is meant to always match main.
8+
#
9+
# Uses a PAT (SYNC_STAGING_PAT), not the default GITHUB_TOKEN, for the push:
10+
# GitHub's built-in loop-prevention rule means pushes made with the default
11+
# GITHUB_TOKEN do NOT trigger other `on: push` workflows — so any CD workflow
12+
# with a `push: branches: [staging]` trigger would silently never fire, and
13+
# this workflow would update the staging ref without actually deploying
14+
# anything. A PAT belonging to a real account avoids that. Generate a PAT
15+
# (repo scope, push access to this repo) and add it as a repository secret
16+
# named SYNC_STAGING_PAT. Commit authorship is resolved from github.actor
17+
# below, not hardcoded, so this works unmodified for any fork.
18+
on:
19+
push:
20+
branches: [main]
21+
workflow_dispatch:
22+
23+
permissions:
24+
contents: write
25+
26+
jobs:
27+
sync:
28+
runs-on: ubuntu-latest
29+
steps:
30+
- uses: actions/checkout@v4
31+
with:
32+
ref: main
33+
fetch-depth: 0
34+
token: ${{ secrets.SYNC_STAGING_PAT }}
35+
36+
- name: Configure git identity
37+
run: |
38+
git config user.name "${{ github.actor }}"
39+
git config user.email "${{ github.actor }}@users.noreply.github.com"
40+
41+
- name: Sync staging onto main
42+
run: bash scripts/sync-staging.sh

‎Makefile‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,15 @@ lint: lint-backend lint-web lint-mobile
7373
tidy:
7474
cd backend && go mod tidy
7575
cd web && pnpm install
76+
77+
# ── Deploy ─────────────────────────────────────────────────────────────────────
78+
79+
# Rebases `production` onto `main` and force-pushes (with lease) to trigger a
80+
# deploy. Aborts if the working tree is dirty or the rebase conflicts; prompts
81+
# for confirmation before pushing unless CONFIRM=yes is set.
82+
deploy-prod:
83+
ifeq ($(OS),Windows_NT)
84+
pwsh -ExecutionPolicy Bypass -File scripts/deploy-prod.ps1
85+
else
86+
bash scripts/deploy-prod.sh
87+
endif

‎README.md‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,16 @@ fullstack-template/
114114
├── TEMPLATE_STATUS.md # Readiness gap tracker
115115
├── docs/
116116
│ └── adr/ # Architecture Decision Records
117+
├── .github/
118+
│ └── workflows/
119+
│ ├── backend-ci.yml / web-ci.yml / mobile-ci.yml # Per-layer CI on PRs
120+
│ ├── labeler.yml # Auto-labels PRs by changed path
121+
│ └── sync-staging.yml # Auto-syncs `staging` to `main` on every merge
117122
├── scripts/
118-
│ ├── dev.sh / dev.ps1 # Start all services in parallel
119-
│ └── setup.sh / setup.ps1 # First-run contributor setup
123+
│ ├── dev.sh / dev.ps1 # Start all services in parallel
124+
│ ├── setup.sh / setup.ps1 # First-run contributor setup
125+
│ ├── deploy-prod.sh / deploy-prod.ps1 # Rebase production onto main and force-push (with lease) — manual
126+
│ └── sync-staging.sh # Rebase staging onto main and force-push (with lease) — run by CI
120127
├── renovate.json # Automated dependency updates
121128
├── .claude/
122129
│ ├── agents/ # Specialized Claude subagents
@@ -240,6 +247,16 @@ pnpm test:watch # Vitest watch mode (use during TDD)
240247

241248
On Windows outside Git Bash, use `.\gradlew.bat` instead of `./gradlew`.
242249

250+
### Deploy
251+
252+
```bash
253+
make deploy-prod # rebase the `production` branch onto `main` and force-push (with lease) to trigger a deploy
254+
```
255+
256+
`staging` syncs to `main` automatically on every merge via `.github/workflows/sync-staging.yml` — no manual step needed. `production` only advances when you run `make deploy-prod`.
257+
258+
See [RUNBOOK.md](RUNBOOK.md#triggering-a-deploy) for the full deploy workflow, prerequisites, and platform setup.
259+
243260
## Testing
244261

245262
Backend tests use [Testcontainers](https://testcontainers.com/) to spin up real PostgreSQL and Redis instances — database mocking is prohibited.

‎RUNBOOK.md‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Operational guide for deploying and maintaining the fullstack template in stagin
1010
- [Environment variables](#environment-variables)
1111
- [First-time production setup](#first-time-production-setup)
1212
- [Pre-launch checklist](#pre-launch-checklist)
13+
- [Triggering a deploy](#triggering-a-deploy)
1314
- [Deploying the backend](#deploying-the-backend)
1415
- [Deploying the web app](#deploying-the-web-app)
1516
- [Deploying the mobile app](#deploying-the-mobile-app)
@@ -155,6 +156,68 @@ Run through this before going live with any project based on this template.
155156

156157
---
157158

159+
## Triggering a deploy
160+
161+
This template uses two branches as deploy triggers, kept separate from `main`:
162+
163+
| Branch | Advances | Trigger |
164+
|---|---|---|
165+
| `staging` | Automatically, on every merge to `main` | GitHub Actions (`.github/workflows/sync-staging.yml`) |
166+
| `production` | Manually, whenever you choose | `make deploy-prod` |
167+
168+
Configure your hosting platforms (Vercel, Railway, Fly.io, etc.) to auto-deploy from these branches — `staging` for the staging environment, `production` for production — rather than from `main`. That way merges to `main` land safely, staging always reflects the latest `main`, and a production deploy only fires when you explicitly advance `production`.
169+
170+
### Staging (automatic)
171+
172+
`.github/workflows/sync-staging.yml` runs on every push to `main` (i.e. every merge) and calls `scripts/sync-staging.sh`, which:
173+
174+
1. Fetches `origin/main` and `origin/staging`.
175+
2. Checks out (or creates) a local `staging` branch reset to `origin/staging`.
176+
3. Rebases it onto `origin/main`.
177+
4. Force-pushes `staging` with `--force-with-lease`.
178+
179+
There is no confirmation prompt — unlike production, staging is meant to always match `main`. If the rebase hits a conflict (only possible if someone committed directly to `staging`), the job aborts the rebase and fails without pushing; fix it manually with the same commands `scripts/sync-staging.sh` prints on failure, or delete stray commits from `staging` so it stops diverging from `main`.
180+
181+
You can also trigger the workflow manually from the **Actions** tab (`workflow_dispatch`), or run `bash scripts/sync-staging.sh` locally.
182+
183+
**The workflow checks out with a personal access token (PAT), not the default `GITHUB_TOKEN`.** This matters because of GitHub's built-in loop-prevention rule: pushes made with the default `GITHUB_TOKEN` do **not** trigger other `on: push` workflows. If your CD workflow deploys on `push: branches: [staging]`, a `GITHUB_TOKEN`-authenticated push here would update the `staging` ref without ever firing that deploy — it would look like the sync succeeded but nothing would actually deploy. A PAT belonging to a real account avoids that.
184+
185+
To set it up:
186+
1. Generate a PAT with push access to this repo — a fine-grained token scoped to this repo with **Contents: Read and write** is enough; a classic token needs the `repo` scope.
187+
2. Add it as a repository secret named `SYNC_STAGING_PAT` (**Settings → Secrets and variables → Actions → New repository secret**).
188+
3. If `staging` is a protected branch, make sure the PAT's account is allowed to bypass (or is exempt from) those protection rules, otherwise the force-push will still be rejected.
189+
190+
The commit identity used for the sync (`git config user.name`/`user.email`) is resolved from `github.actor` — whoever triggered the workflow (typically whoever merged the PR) — not hardcoded, so this works the same for any fork of this template.
191+
192+
### Production (manual)
193+
194+
Advance `production` to match `main` with:
195+
196+
```bash
197+
make deploy-prod
198+
```
199+
200+
This runs `scripts/deploy-prod.sh` (macOS/Linux) or `scripts/deploy-prod.ps1` (Windows) via the `deploy-prod` Makefile target, which handles both platforms automatically. The script:
201+
202+
1. Aborts if the working tree is dirty — commit, stash, or discard changes first.
203+
2. Fetches `origin/main` and `origin/production`.
204+
3. Checks out (or creates) a local `production` branch reset to `origin/production`.
205+
4. Rebases it onto `origin/main`.
206+
5. Shows the commits about to ship and prompts `Continue? [y/N]` before pushing.
207+
6. Force-pushes `production` with `--force-with-lease` (never a bare `--force`), so the push fails instead of clobbering someone else's work if `production` moved remotely since the last fetch.
208+
209+
Set `CONFIRM=yes` to skip the interactive prompt (e.g. from CI):
210+
211+
```bash
212+
CONFIRM=yes make deploy-prod
213+
```
214+
215+
If the rebase hits conflicts, the script leaves the branch mid-rebase and prints next steps — resolve the conflicts, then run `git rebase --continue` followed by `git push --force-with-lease origin production`, or `git rebase --abort` to back out. The original branch is restored automatically on both success and abort.
216+
217+
`make deploy-prod` only advances the branch — it does not run database migrations or build anything. Apply pending migrations against production **before** running it; see [Database migrations](#database-migrations).
218+
219+
---
220+
158221
## Deploying the backend
159222

160223
The backend compiles to a single binary. Choose one deployment model:

‎scripts/deploy-prod.ps1‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# scripts/deploy-prod.ps1 — rebase production onto main and force-push (with lease)
2+
# Usage: .\scripts\deploy-prod.ps1 (or via: make deploy-prod)
3+
#
4+
# Fast-forwards `production` to include everything on `main` by rebasing it
5+
# onto origin/main, then force-pushes (with lease, never a bare --force) so
6+
# the push fails instead of clobbering anyone else's work if the remote
7+
# `production` moved since we last fetched.
8+
9+
$ErrorActionPreference = "Stop"
10+
11+
$Remote = "origin"
12+
$BaseBranch = "main"
13+
$DeployBranch = "production"
14+
15+
$status = git status --porcelain
16+
if ($status) {
17+
Write-Host "Working tree is not clean. Commit, stash, or discard changes before deploying." -ForegroundColor Red
18+
exit 1
19+
}
20+
21+
$originalBranch = git rev-parse --abbrev-ref HEAD
22+
$cleanExit = $false
23+
24+
try {
25+
Write-Host "Fetching from $Remote..." -ForegroundColor Yellow
26+
git fetch $Remote
27+
if ($LASTEXITCODE -ne 0) { throw "git fetch failed" }
28+
29+
git show-ref --verify --quiet "refs/remotes/$Remote/$DeployBranch"
30+
$deployBranchExists = ($LASTEXITCODE -eq 0)
31+
if ($deployBranchExists) {
32+
git show-ref --verify --quiet "refs/heads/$DeployBranch"
33+
if ($LASTEXITCODE -eq 0) {
34+
git checkout --quiet $DeployBranch
35+
} else {
36+
git checkout --quiet -b $DeployBranch "$Remote/$DeployBranch"
37+
}
38+
if ($LASTEXITCODE -ne 0) { throw "git checkout failed" }
39+
40+
# Make sure the local branch starts from exactly what's on the remote
41+
# before rebasing, so we never rebase stale local commits onto main.
42+
git reset --hard "$Remote/$DeployBranch"
43+
if ($LASTEXITCODE -ne 0) { throw "git reset failed" }
44+
} else {
45+
# Remote branch doesn't exist yet (e.g. first deployment).
46+
git checkout --quiet -b $DeployBranch "$Remote/$BaseBranch"
47+
if ($LASTEXITCODE -ne 0) { throw "git checkout failed" }
48+
}
49+
50+
Write-Host "Rebasing $DeployBranch onto $Remote/$BaseBranch..." -ForegroundColor Yellow
51+
git rebase "$Remote/$BaseBranch"
52+
if ($LASTEXITCODE -ne 0) {
53+
Write-Host "Rebase hit conflicts. Resolve them, then run:" -ForegroundColor Red
54+
Write-Host " git rebase --continue"
55+
Write-Host " git push --force-with-lease $Remote $DeployBranch"
56+
Write-Host "Or abort with: git rebase --abort"
57+
# Leave the branch mid-rebase for the user to resolve — do not clean up.
58+
exit 1
59+
}
60+
61+
Write-Host ""
62+
Write-Host "About to force-push (with lease) $DeployBranch to $Remote. This will trigger a production deploy." -ForegroundColor Yellow
63+
if ($deployBranchExists) {
64+
$commits = git log "$Remote/$DeployBranch..$DeployBranch" --oneline
65+
if (-not $commits) {
66+
Write-Host "No new commits -- $DeployBranch already matches $BaseBranch."
67+
} else {
68+
$commits | ForEach-Object { Write-Host $_ }
69+
}
70+
} else {
71+
Write-Host "Creating $DeployBranch on $Remote from $BaseBranch (first deployment)."
72+
}
73+
74+
if ($env:CONFIRM -ne "yes") {
75+
$reply = Read-Host "Continue? [y/N]"
76+
if ($reply -notmatch '^(y|yes)$') {
77+
Write-Host "Aborted. No push was made."
78+
exit 1
79+
}
80+
}
81+
82+
git push --force-with-lease $Remote $DeployBranch
83+
if ($LASTEXITCODE -ne 0) { throw "git push failed" }
84+
85+
Write-Host "Deployed: $DeployBranch pushed to $Remote." -ForegroundColor Green
86+
$cleanExit = $true
87+
}
88+
finally {
89+
if ($cleanExit) {
90+
git checkout --quiet $originalBranch 2>$null
91+
}
92+
}

‎scripts/deploy-prod.sh‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
#!/usr/bin/env bash
2+
# scripts/deploy-prod.sh — rebase production onto main and force-push (with lease)
3+
# Usage: ./scripts/deploy-prod.sh (or via: make deploy-prod)
4+
#
5+
# Fast-forwards `production` to include everything on `main` by rebasing it
6+
# onto origin/main, then force-pushes (with lease, never a bare --force) so
7+
# the push fails instead of clobbering anyone else's work if the remote
8+
# `production` moved since we last fetched.
9+
10+
set -euo pipefail
11+
12+
REMOTE="origin"
13+
BASE_BRANCH="main"
14+
DEPLOY_BRANCH="production"
15+
16+
RED='\033[1;31m'
17+
YELLOW='\033[1;33m'
18+
GREEN='\033[1;32m'
19+
RESET='\033[0m'
20+
21+
if [ -n "$(git status --porcelain)" ]; then
22+
echo -e "${RED}Working tree is not clean. Commit, stash, or discard changes before deploying.${RESET}" >&2
23+
exit 1
24+
fi
25+
26+
ORIGINAL_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
27+
cleanup() {
28+
git checkout --quiet "$ORIGINAL_BRANCH" 2>/dev/null || true
29+
}
30+
trap cleanup EXIT
31+
32+
echo -e "${YELLOW}Fetching from ${REMOTE}...${RESET}"
33+
git fetch "$REMOTE"
34+
35+
if git show-ref --verify --quiet "refs/remotes/$REMOTE/$DEPLOY_BRANCH"; then
36+
DEPLOY_BRANCH_EXISTS=true
37+
if git show-ref --verify --quiet "refs/heads/$DEPLOY_BRANCH"; then
38+
git checkout --quiet "$DEPLOY_BRANCH"
39+
else
40+
git checkout --quiet -b "$DEPLOY_BRANCH" "$REMOTE/$DEPLOY_BRANCH"
41+
fi
42+
# Make sure the local branch starts from exactly what's on the remote before
43+
# rebasing, so we never rebase stale local commits onto main by accident.
44+
git reset --hard "$REMOTE/$DEPLOY_BRANCH"
45+
else
46+
# Remote branch doesn't exist yet (e.g. first deployment).
47+
DEPLOY_BRANCH_EXISTS=false
48+
git checkout --quiet -b "$DEPLOY_BRANCH" "$REMOTE/$BASE_BRANCH"
49+
fi
50+
51+
echo -e "${YELLOW}Rebasing ${DEPLOY_BRANCH} onto ${REMOTE}/${BASE_BRANCH}...${RESET}"
52+
if ! git rebase "$REMOTE/$BASE_BRANCH"; then
53+
echo -e "${RED}Rebase hit conflicts. Resolve them, then run:${RESET}" >&2
54+
echo " git rebase --continue" >&2
55+
echo " git push --force-with-lease $REMOTE $DEPLOY_BRANCH" >&2
56+
echo "Or abort with: git rebase --abort" >&2
57+
# Leave the branch mid-rebase for the user to resolve — do not clean up.
58+
trap - EXIT
59+
exit 1
60+
fi
61+
62+
echo ""
63+
echo -e "${YELLOW}About to force-push (with lease) ${DEPLOY_BRANCH} to ${REMOTE}. This will trigger a production deploy.${RESET}"
64+
if [ "$DEPLOY_BRANCH_EXISTS" = true ]; then
65+
COMMITS="$(git log "$REMOTE/$DEPLOY_BRANCH..$DEPLOY_BRANCH" --oneline)"
66+
if [ -z "$COMMITS" ]; then
67+
echo "No new commits — ${DEPLOY_BRANCH} already matches ${BASE_BRANCH}."
68+
else
69+
echo "$COMMITS"
70+
fi
71+
else
72+
echo "Creating ${DEPLOY_BRANCH} on ${REMOTE} from ${BASE_BRANCH} (first deployment)."
73+
fi
74+
75+
if [ "${CONFIRM:-}" != "yes" ]; then
76+
read -r -p "Continue? [y/N] " reply || true
77+
case "$reply" in
78+
[yY][eE][sS]|[yY]) ;;
79+
*) echo "Aborted. No push was made."; exit 1 ;;
80+
esac
81+
fi
82+
83+
git push --force-with-lease "$REMOTE" "$DEPLOY_BRANCH"
84+
echo -e "${GREEN}Deployed: ${DEPLOY_BRANCH} pushed to ${REMOTE}.${RESET}"

0 commit comments

Comments
 (0)