diff --git a/.github/workflows/sync-staging.yml b/.github/workflows/sync-staging.yml new file mode 100644 index 0000000..d5a9f0f --- /dev/null +++ b/.github/workflows/sync-staging.yml @@ -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 diff --git a/Makefile b/Makefile index f02aabc..5fb0b6f 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 37c5a20..2eaad17 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/RUNBOOK.md b/RUNBOOK.md index ce7f4b2..042c2bd 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -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) @@ -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: diff --git a/scripts/deploy-prod.ps1 b/scripts/deploy-prod.ps1 new file mode 100644 index 0000000..f282553 --- /dev/null +++ b/scripts/deploy-prod.ps1 @@ -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 + } +} diff --git a/scripts/deploy-prod.sh b/scripts/deploy-prod.sh new file mode 100644 index 0000000..698bcf7 --- /dev/null +++ b/scripts/deploy-prod.sh @@ -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 + +git push --force-with-lease "$REMOTE" "$DEPLOY_BRANCH" +echo -e "${GREEN}Deployed: ${DEPLOY_BRANCH} pushed to ${REMOTE}.${RESET}" diff --git a/scripts/sync-staging.sh b/scripts/sync-staging.sh new file mode 100644 index 0000000..7ba821c --- /dev/null +++ b/scripts/sync-staging.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# scripts/sync-staging.sh — rebase staging onto main and force-push (with lease) +# +# Run automatically by .github/workflows/sync-staging.yml after every merge to +# main. Non-interactive by design — there is no confirmation prompt, unlike +# scripts/deploy-prod.sh which gates production deploys behind a manual step. +# +# Can also be run locally (git identity must already be configured). + +set -euo pipefail + +REMOTE="${REMOTE:-origin}" +BASE_BRANCH="main" +SYNC_BRANCH="staging" + +echo "Fetching from ${REMOTE}..." +git fetch "$REMOTE" + +if git show-ref --verify --quiet "refs/remotes/$REMOTE/$SYNC_BRANCH"; then + REMOTE_SYNC_EXISTS=true + if git show-ref --verify --quiet "refs/heads/$SYNC_BRANCH"; then + git checkout --quiet "$SYNC_BRANCH" + else + git checkout --quiet -b "$SYNC_BRANCH" "$REMOTE/$SYNC_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/$SYNC_BRANCH" +else + # Remote branch doesn't exist yet (e.g. first run of this template). + REMOTE_SYNC_EXISTS=false + git checkout --quiet -b "$SYNC_BRANCH" "$REMOTE/$BASE_BRANCH" +fi + +echo "Rebasing ${SYNC_BRANCH} onto ${REMOTE}/${BASE_BRANCH}..." +if ! git rebase "$REMOTE/$BASE_BRANCH"; then + echo "Rebase of ${SYNC_BRANCH} onto ${REMOTE}/${BASE_BRANCH} hit conflicts." >&2 + echo "This needs manual resolution — sync did not run:" >&2 + echo " git fetch $REMOTE" >&2 + echo " git checkout $SYNC_BRANCH && git reset --hard $REMOTE/$SYNC_BRANCH" >&2 + echo " git rebase $REMOTE/$BASE_BRANCH # resolve conflicts, then --continue" >&2 + echo " git push --force-with-lease $REMOTE $SYNC_BRANCH" >&2 + git rebase --abort + exit 1 +fi + +if [ "$REMOTE_SYNC_EXISTS" = true ]; then + COMMITS="$(git log "$REMOTE/$SYNC_BRANCH..$SYNC_BRANCH" --oneline)" + if [ -z "$COMMITS" ]; then + echo "No new commits — ${SYNC_BRANCH} already matches ${BASE_BRANCH}." + exit 0 + fi + echo "Syncing ${SYNC_BRANCH}:" + echo "$COMMITS" +else + echo "Creating ${SYNC_BRANCH} on ${REMOTE} from ${BASE_BRANCH} (first run)." +fi + +git push --force-with-lease "$REMOTE" "$SYNC_BRANCH" +echo "Synced: ${SYNC_BRANCH} pushed to ${REMOTE}."