Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/sync-staging.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Sync Staging

# Keeps the `staging` branch tracking `main` so hosting platforms configured
# to auto-deploy from `staging` pick up every merge automatically. This
# mirrors what `make deploy-prod` (scripts/deploy-prod.sh) does for
# `production`, but runs unattended — no confirmation prompt — since staging
# is meant to always match main.
#
# Uses a PAT (SYNC_STAGING_PAT), not the default GITHUB_TOKEN, for the push:
# GitHub's built-in loop-prevention rule means pushes made with the default
# GITHUB_TOKEN do NOT trigger other `on: push` workflows — so any CD workflow
# with a `push: branches: [staging]` trigger would silently never fire, and
# this workflow would update the staging ref without actually deploying
# anything. A PAT belonging to a real account avoids that. Generate a PAT
# (repo scope, push access to this repo) and add it as a repository secret
# named SYNC_STAGING_PAT. Commit authorship is resolved from github.actor
# below, not hardcoded, so this works unmodified for any fork.
on:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: write

jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.SYNC_STAGING_PAT }}

- name: Configure git identity
run: |
git config user.name "${{ github.actor }}"
git config user.email "${{ github.actor }}@users.noreply.github.com"

- name: Sync staging onto main
run: bash scripts/sync-staging.sh
12 changes: 12 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,15 @@ lint: lint-backend lint-web lint-mobile
tidy:
cd backend && go mod tidy
cd web && pnpm install

# ── Deploy ─────────────────────────────────────────────────────────────────────

# Rebases `production` onto `main` and force-pushes (with lease) to trigger a
# deploy. Aborts if the working tree is dirty or the rebase conflicts; prompts
# for confirmation before pushing unless CONFIRM=yes is set.
deploy-prod:
ifeq ($(OS),Windows_NT)
pwsh -ExecutionPolicy Bypass -File scripts/deploy-prod.ps1
else
bash scripts/deploy-prod.sh
endif
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,16 @@ fullstack-template/
├── TEMPLATE_STATUS.md # Readiness gap tracker
├── docs/
│ └── adr/ # Architecture Decision Records
├── .github/
│ └── workflows/
│ ├── backend-ci.yml / web-ci.yml / mobile-ci.yml # Per-layer CI on PRs
│ ├── labeler.yml # Auto-labels PRs by changed path
│ └── sync-staging.yml # Auto-syncs `staging` to `main` on every merge
├── scripts/
│ ├── dev.sh / dev.ps1 # Start all services in parallel
│ └── setup.sh / setup.ps1 # First-run contributor setup
│ ├── dev.sh / dev.ps1 # Start all services in parallel
│ ├── setup.sh / setup.ps1 # First-run contributor setup
│ ├── deploy-prod.sh / deploy-prod.ps1 # Rebase production onto main and force-push (with lease) — manual
│ └── sync-staging.sh # Rebase staging onto main and force-push (with lease) — run by CI
├── renovate.json # Automated dependency updates
├── .claude/
│ ├── agents/ # Specialized Claude subagents
Expand Down Expand Up @@ -240,6 +247,16 @@ pnpm test:watch # Vitest watch mode (use during TDD)

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

### Deploy

```bash
make deploy-prod # rebase the `production` branch onto `main` and force-push (with lease) to trigger a deploy
```

`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`.

See [RUNBOOK.md](RUNBOOK.md#triggering-a-deploy) for the full deploy workflow, prerequisites, and platform setup.

## Testing

Backend tests use [Testcontainers](https://testcontainers.com/) to spin up real PostgreSQL and Redis instances — database mocking is prohibited.
Expand Down
63 changes: 63 additions & 0 deletions RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Operational guide for deploying and maintaining the fullstack template in stagin
- [Environment variables](#environment-variables)
- [First-time production setup](#first-time-production-setup)
- [Pre-launch checklist](#pre-launch-checklist)
- [Triggering a deploy](#triggering-a-deploy)
- [Deploying the backend](#deploying-the-backend)
- [Deploying the web app](#deploying-the-web-app)
- [Deploying the mobile app](#deploying-the-mobile-app)
Expand Down Expand Up @@ -155,6 +156,68 @@ Run through this before going live with any project based on this template.

---

## Triggering a deploy

This template uses two branches as deploy triggers, kept separate from `main`:

| Branch | Advances | Trigger |
|---|---|---|
| `staging` | Automatically, on every merge to `main` | GitHub Actions (`.github/workflows/sync-staging.yml`) |
| `production` | Manually, whenever you choose | `make deploy-prod` |

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`.

### Staging (automatic)

`.github/workflows/sync-staging.yml` runs on every push to `main` (i.e. every merge) and calls `scripts/sync-staging.sh`, which:

1. Fetches `origin/main` and `origin/staging`.
2. Checks out (or creates) a local `staging` branch reset to `origin/staging`.
3. Rebases it onto `origin/main`.
4. Force-pushes `staging` with `--force-with-lease`.

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`.

You can also trigger the workflow manually from the **Actions** tab (`workflow_dispatch`), or run `bash scripts/sync-staging.sh` locally.

**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.

To set it up:
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.
2. Add it as a repository secret named `SYNC_STAGING_PAT` (**Settings → Secrets and variables → Actions → New repository secret**).
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.

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.

### Production (manual)

Advance `production` to match `main` with:

```bash
make deploy-prod
```

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:

1. Aborts if the working tree is dirty — commit, stash, or discard changes first.
2. Fetches `origin/main` and `origin/production`.
3. Checks out (or creates) a local `production` branch reset to `origin/production`.
4. Rebases it onto `origin/main`.
5. Shows the commits about to ship and prompts `Continue? [y/N]` before pushing.
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.

Set `CONFIRM=yes` to skip the interactive prompt (e.g. from CI):

```bash
CONFIRM=yes make deploy-prod
```

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.

`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).

---

## Deploying the backend

The backend compiles to a single binary. Choose one deployment model:
Expand Down
92 changes: 92 additions & 0 deletions scripts/deploy-prod.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# scripts/deploy-prod.ps1 — rebase production onto main and force-push (with lease)
# Usage: .\scripts\deploy-prod.ps1 (or via: make deploy-prod)
#
# Fast-forwards `production` to include everything on `main` by rebasing it
# onto origin/main, then force-pushes (with lease, never a bare --force) so
# the push fails instead of clobbering anyone else's work if the remote
# `production` moved since we last fetched.

$ErrorActionPreference = "Stop"

$Remote = "origin"
$BaseBranch = "main"
$DeployBranch = "production"

$status = git status --porcelain
if ($status) {
Write-Host "Working tree is not clean. Commit, stash, or discard changes before deploying." -ForegroundColor Red
exit 1
}

$originalBranch = git rev-parse --abbrev-ref HEAD
$cleanExit = $false

try {
Write-Host "Fetching from $Remote..." -ForegroundColor Yellow
git fetch $Remote
if ($LASTEXITCODE -ne 0) { throw "git fetch failed" }

git show-ref --verify --quiet "refs/remotes/$Remote/$DeployBranch"
$deployBranchExists = ($LASTEXITCODE -eq 0)
if ($deployBranchExists) {
git show-ref --verify --quiet "refs/heads/$DeployBranch"
if ($LASTEXITCODE -eq 0) {
git checkout --quiet $DeployBranch
} else {
git checkout --quiet -b $DeployBranch "$Remote/$DeployBranch"
}
if ($LASTEXITCODE -ne 0) { throw "git checkout failed" }

# Make sure the local branch starts from exactly what's on the remote
# before rebasing, so we never rebase stale local commits onto main.
git reset --hard "$Remote/$DeployBranch"
if ($LASTEXITCODE -ne 0) { throw "git reset failed" }
} else {
# Remote branch doesn't exist yet (e.g. first deployment).
git checkout --quiet -b $DeployBranch "$Remote/$BaseBranch"
if ($LASTEXITCODE -ne 0) { throw "git checkout failed" }
}

Write-Host "Rebasing $DeployBranch onto $Remote/$BaseBranch..." -ForegroundColor Yellow
git rebase "$Remote/$BaseBranch"
if ($LASTEXITCODE -ne 0) {
Write-Host "Rebase hit conflicts. Resolve them, then run:" -ForegroundColor Red
Write-Host " git rebase --continue"
Write-Host " git push --force-with-lease $Remote $DeployBranch"
Write-Host "Or abort with: git rebase --abort"
# Leave the branch mid-rebase for the user to resolve — do not clean up.
exit 1
}

Write-Host ""
Write-Host "About to force-push (with lease) $DeployBranch to $Remote. This will trigger a production deploy." -ForegroundColor Yellow
if ($deployBranchExists) {
$commits = git log "$Remote/$DeployBranch..$DeployBranch" --oneline
if (-not $commits) {
Write-Host "No new commits -- $DeployBranch already matches $BaseBranch."
} else {
$commits | ForEach-Object { Write-Host $_ }
}
} else {
Write-Host "Creating $DeployBranch on $Remote from $BaseBranch (first deployment)."
}

if ($env:CONFIRM -ne "yes") {
$reply = Read-Host "Continue? [y/N]"
if ($reply -notmatch '^(y|yes)$') {
Write-Host "Aborted. No push was made."
exit 1
}
}

git push --force-with-lease $Remote $DeployBranch
if ($LASTEXITCODE -ne 0) { throw "git push failed" }

Write-Host "Deployed: $DeployBranch pushed to $Remote." -ForegroundColor Green
$cleanExit = $true
}
finally {
if ($cleanExit) {
git checkout --quiet $originalBranch 2>$null
}
}
84 changes: 84 additions & 0 deletions scripts/deploy-prod.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# scripts/deploy-prod.sh — rebase production onto main and force-push (with lease)
# Usage: ./scripts/deploy-prod.sh (or via: make deploy-prod)
#
# Fast-forwards `production` to include everything on `main` by rebasing it
# onto origin/main, then force-pushes (with lease, never a bare --force) so
# the push fails instead of clobbering anyone else's work if the remote
# `production` moved since we last fetched.

set -euo pipefail

REMOTE="origin"
BASE_BRANCH="main"
DEPLOY_BRANCH="production"

RED='\033[1;31m'
YELLOW='\033[1;33m'
GREEN='\033[1;32m'
RESET='\033[0m'

if [ -n "$(git status --porcelain)" ]; then
echo -e "${RED}Working tree is not clean. Commit, stash, or discard changes before deploying.${RESET}" >&2
exit 1
fi

ORIGINAL_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
cleanup() {
git checkout --quiet "$ORIGINAL_BRANCH" 2>/dev/null || true
}
trap cleanup EXIT

echo -e "${YELLOW}Fetching from ${REMOTE}...${RESET}"
git fetch "$REMOTE"

if git show-ref --verify --quiet "refs/remotes/$REMOTE/$DEPLOY_BRANCH"; then
DEPLOY_BRANCH_EXISTS=true
if git show-ref --verify --quiet "refs/heads/$DEPLOY_BRANCH"; then
git checkout --quiet "$DEPLOY_BRANCH"
else
git checkout --quiet -b "$DEPLOY_BRANCH" "$REMOTE/$DEPLOY_BRANCH"
fi
# Make sure the local branch starts from exactly what's on the remote before
# rebasing, so we never rebase stale local commits onto main by accident.
git reset --hard "$REMOTE/$DEPLOY_BRANCH"
else
# Remote branch doesn't exist yet (e.g. first deployment).
DEPLOY_BRANCH_EXISTS=false
git checkout --quiet -b "$DEPLOY_BRANCH" "$REMOTE/$BASE_BRANCH"
fi

echo -e "${YELLOW}Rebasing ${DEPLOY_BRANCH} onto ${REMOTE}/${BASE_BRANCH}...${RESET}"
if ! git rebase "$REMOTE/$BASE_BRANCH"; then
echo -e "${RED}Rebase hit conflicts. Resolve them, then run:${RESET}" >&2
echo " git rebase --continue" >&2
echo " git push --force-with-lease $REMOTE $DEPLOY_BRANCH" >&2
echo "Or abort with: git rebase --abort" >&2
# Leave the branch mid-rebase for the user to resolve — do not clean up.
trap - EXIT
exit 1
fi

echo ""
echo -e "${YELLOW}About to force-push (with lease) ${DEPLOY_BRANCH} to ${REMOTE}. This will trigger a production deploy.${RESET}"
if [ "$DEPLOY_BRANCH_EXISTS" = true ]; then
COMMITS="$(git log "$REMOTE/$DEPLOY_BRANCH..$DEPLOY_BRANCH" --oneline)"
if [ -z "$COMMITS" ]; then
echo "No new commits — ${DEPLOY_BRANCH} already matches ${BASE_BRANCH}."
else
echo "$COMMITS"
fi
else
echo "Creating ${DEPLOY_BRANCH} on ${REMOTE} from ${BASE_BRANCH} (first deployment)."
fi

if [ "${CONFIRM:-}" != "yes" ]; then
read -r -p "Continue? [y/N] " reply || true
case "$reply" in
[yY][eE][sS]|[yY]) ;;
*) echo "Aborted. No push was made."; exit 1 ;;
esac
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.

git push --force-with-lease "$REMOTE" "$DEPLOY_BRANCH"
echo -e "${GREEN}Deployed: ${DEPLOY_BRANCH} pushed to ${REMOTE}.${RESET}"
Loading
Loading