From e9a98eabc05918d398d416a9a436aaa334a431b6 Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Fri, 17 Jul 2026 15:21:44 +0300 Subject: [PATCH 1/2] feat: add production deploy and automatic staging sync - make deploy-prod rebases `production` onto `main` and force-pushes (with lease) after a confirmation prompt, for manually triggering a production deploy. - .github/workflows/sync-staging.yml keeps `staging` in sync with `main` automatically on every merge via scripts/sync-staging.sh. - Document both flows in RUNBOOK.md and README.md. --- .github/workflows/sync-staging.yml | 31 ++++++++++++ Makefile | 12 +++++ README.md | 21 +++++++- RUNBOOK.md | 58 ++++++++++++++++++++++ scripts/deploy-prod.ps1 | 80 ++++++++++++++++++++++++++++++ scripts/deploy-prod.sh | 73 +++++++++++++++++++++++++++ scripts/sync-staging.sh | 50 +++++++++++++++++++ 7 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/sync-staging.yml create mode 100644 scripts/deploy-prod.ps1 create mode 100644 scripts/deploy-prod.sh create mode 100644 scripts/sync-staging.sh diff --git a/.github/workflows/sync-staging.yml b/.github/workflows/sync-staging.yml new file mode 100644 index 0000000..cba3b0f --- /dev/null +++ b/.github/workflows/sync-staging.yml @@ -0,0 +1,31 @@ +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. +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 + + - name: Configure git identity + run: | + git config user.name "GRACENOBLE" + git config user.email "gracenoble72@gmail.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..2e732e9 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,63 @@ 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 pushes with the default `GITHUB_TOKEN`, which needs write access: +- Repo **Settings → Actions → General → Workflow permissions** must be set to "Read and write permissions". +- If `staging` is a protected branch, add an exception (or a bypass rule) for `github-actions[bot]`/this workflow, otherwise the force-push will be rejected. + +### 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..350d469 --- /dev/null +++ b/scripts/deploy-prod.ps1 @@ -0,0 +1,80 @@ +# 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 $Remote/$BaseBranch and $Remote/$DeployBranch..." -ForegroundColor Yellow + git fetch $Remote $BaseBranch $DeployBranch + if ($LASTEXITCODE -ne 0) { throw "git fetch failed" } + + $branchExists = 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" } + + 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 + $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 $_ } + } + + 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..efb2682 --- /dev/null +++ b/scripts/deploy-prod.sh @@ -0,0 +1,73 @@ +#!/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 ${REMOTE}/${BASE_BRANCH} and ${REMOTE}/${DEPLOY_BRANCH}...${RESET}" +git fetch "$REMOTE" "$BASE_BRANCH" "$DEPLOY_BRANCH" + +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" + +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}" +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 + +if [ "${CONFIRM:-}" != "yes" ]; then + read -r -p "Continue? [y/N] " reply + 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..766a7d2 --- /dev/null +++ b/scripts/sync-staging.sh @@ -0,0 +1,50 @@ +#!/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 ${REMOTE}/${BASE_BRANCH} and ${REMOTE}/${SYNC_BRANCH}..." +git fetch "$REMOTE" "$BASE_BRANCH" "$SYNC_BRANCH" + +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" + +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 $BASE_BRANCH $SYNC_BRANCH" >&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 + +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" + +git push --force-with-lease "$REMOTE" "$SYNC_BRANCH" +echo "Synced: ${SYNC_BRANCH} pushed to ${REMOTE}." From cccf3fc8c20d5040d4618be57a4b1ef1f62a1a5d Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Fri, 17 Jul 2026 15:57:23 +0300 Subject: [PATCH 2/2] fix: address CodeRabbit review comments and document PAT-based staging sync - Resolve sync-staging.yml's git identity from github.actor instead of a hardcoded personal name/email, so forks of this template don't silently force-push commits attributed to someone else. - Switch to a PAT (SYNC_STAGING_PAT) for the staging sync push, since pushes made with the default GITHUB_TOKEN don't trigger other on:push workflows and would leave CD silently un-triggered. - Harden deploy-prod.sh/.ps1 and sync-staging.sh: fetch the whole remote and fall back to branching from main instead of crashing when the staging/production branch doesn't exist yet (first run of the template). - Make deploy-prod.sh's confirmation prompt tolerate EOF in non-interactive environments instead of crashing past the abort message. - Update RUNBOOK.md with the PAT setup steps and rationale. --- .github/workflows/sync-staging.yml | 15 ++++++++-- RUNBOOK.md | 11 ++++++-- scripts/deploy-prod.ps1 | 44 +++++++++++++++++++----------- scripts/deploy-prod.sh | 37 ++++++++++++++++--------- scripts/sync-staging.sh | 44 ++++++++++++++++++------------ 5 files changed, 100 insertions(+), 51 deletions(-) diff --git a/.github/workflows/sync-staging.yml b/.github/workflows/sync-staging.yml index cba3b0f..d5a9f0f 100644 --- a/.github/workflows/sync-staging.yml +++ b/.github/workflows/sync-staging.yml @@ -5,6 +5,16 @@ name: Sync Staging # 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] @@ -21,11 +31,12 @@ jobs: with: ref: main fetch-depth: 0 + token: ${{ secrets.SYNC_STAGING_PAT }} - name: Configure git identity run: | - git config user.name "GRACENOBLE" - git config user.email "gracenoble72@gmail.com" + 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/RUNBOOK.md b/RUNBOOK.md index 2e732e9..042c2bd 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -180,9 +180,14 @@ There is no confirmation prompt — unlike production, staging is meant to alway You can also trigger the workflow manually from the **Actions** tab (`workflow_dispatch`), or run `bash scripts/sync-staging.sh` locally. -The workflow pushes with the default `GITHUB_TOKEN`, which needs write access: -- Repo **Settings → Actions → General → Workflow permissions** must be set to "Read and write permissions". -- If `staging` is a protected branch, add an exception (or a bypass rule) for `github-actions[bot]`/this workflow, otherwise the force-push will be rejected. +**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) diff --git a/scripts/deploy-prod.ps1 b/scripts/deploy-prod.ps1 index 350d469..f282553 100644 --- a/scripts/deploy-prod.ps1 +++ b/scripts/deploy-prod.ps1 @@ -22,22 +22,30 @@ $originalBranch = git rev-parse --abbrev-ref HEAD $cleanExit = $false try { - Write-Host "Fetching $Remote/$BaseBranch and $Remote/$DeployBranch..." -ForegroundColor Yellow - git fetch $Remote $BaseBranch $DeployBranch + Write-Host "Fetching from $Remote..." -ForegroundColor Yellow + git fetch $Remote if ($LASTEXITCODE -ne 0) { throw "git fetch failed" } - $branchExists = git show-ref --verify --quiet "refs/heads/$DeployBranch" - if ($LASTEXITCODE -eq 0) { - git checkout --quiet $DeployBranch + 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 { - git checkout --quiet -b $DeployBranch "$Remote/$DeployBranch" + # 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" } } - 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" } Write-Host "Rebasing $DeployBranch onto $Remote/$BaseBranch..." -ForegroundColor Yellow git rebase "$Remote/$BaseBranch" @@ -52,11 +60,15 @@ try { Write-Host "" Write-Host "About to force-push (with lease) $DeployBranch to $Remote. This will trigger a production deploy." -ForegroundColor Yellow - $commits = git log "$Remote/$DeployBranch..$DeployBranch" --oneline - if (-not $commits) { - Write-Host "No new commits -- $DeployBranch already matches $BaseBranch." + 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 { - $commits | ForEach-Object { Write-Host $_ } + Write-Host "Creating $DeployBranch on $Remote from $BaseBranch (first deployment)." } if ($env:CONFIRM -ne "yes") { diff --git a/scripts/deploy-prod.sh b/scripts/deploy-prod.sh index efb2682..698bcf7 100644 --- a/scripts/deploy-prod.sh +++ b/scripts/deploy-prod.sh @@ -29,17 +29,24 @@ cleanup() { } trap cleanup EXIT -echo -e "${YELLOW}Fetching ${REMOTE}/${BASE_BRANCH} and ${REMOTE}/${DEPLOY_BRANCH}...${RESET}" -git fetch "$REMOTE" "$BASE_BRANCH" "$DEPLOY_BRANCH" +echo -e "${YELLOW}Fetching from ${REMOTE}...${RESET}" +git fetch "$REMOTE" -if git show-ref --verify --quiet "refs/heads/$DEPLOY_BRANCH"; then - git checkout --quiet "$DEPLOY_BRANCH" +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 - git checkout --quiet -b "$DEPLOY_BRANCH" "$REMOTE/$DEPLOY_BRANCH" + # Remote branch doesn't exist yet (e.g. first deployment). + DEPLOY_BRANCH_EXISTS=false + git checkout --quiet -b "$DEPLOY_BRANCH" "$REMOTE/$BASE_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" echo -e "${YELLOW}Rebasing ${DEPLOY_BRANCH} onto ${REMOTE}/${BASE_BRANCH}...${RESET}" if ! git rebase "$REMOTE/$BASE_BRANCH"; then @@ -54,15 +61,19 @@ fi echo "" echo -e "${YELLOW}About to force-push (with lease) ${DEPLOY_BRANCH} to ${REMOTE}. This will trigger a production deploy.${RESET}" -COMMITS="$(git log "$REMOTE/$DEPLOY_BRANCH..$DEPLOY_BRANCH" --oneline)" -if [ -z "$COMMITS" ]; then - echo "No new commits — ${DEPLOY_BRANCH} already matches ${BASE_BRANCH}." +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 "$COMMITS" + echo "Creating ${DEPLOY_BRANCH} on ${REMOTE} from ${BASE_BRANCH} (first deployment)." fi if [ "${CONFIRM:-}" != "yes" ]; then - read -r -p "Continue? [y/N] " reply + read -r -p "Continue? [y/N] " reply || true case "$reply" in [yY][eE][sS]|[yY]) ;; *) echo "Aborted. No push was made."; exit 1 ;; diff --git a/scripts/sync-staging.sh b/scripts/sync-staging.sh index 766a7d2..7ba821c 100644 --- a/scripts/sync-staging.sh +++ b/scripts/sync-staging.sh @@ -13,23 +13,30 @@ REMOTE="${REMOTE:-origin}" BASE_BRANCH="main" SYNC_BRANCH="staging" -echo "Fetching ${REMOTE}/${BASE_BRANCH} and ${REMOTE}/${SYNC_BRANCH}..." -git fetch "$REMOTE" "$BASE_BRANCH" "$SYNC_BRANCH" - -if git show-ref --verify --quiet "refs/heads/$SYNC_BRANCH"; then - git checkout --quiet "$SYNC_BRANCH" +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 - git checkout --quiet -b "$SYNC_BRANCH" "$REMOTE/$SYNC_BRANCH" + # 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 -# 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" 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 $BASE_BRANCH $SYNC_BRANCH" >&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 @@ -37,14 +44,17 @@ if ! git rebase "$REMOTE/$BASE_BRANCH"; then exit 1 fi -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 +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 -echo "Syncing ${SYNC_BRANCH}:" -echo "$COMMITS" - git push --force-with-lease "$REMOTE" "$SYNC_BRANCH" echo "Synced: ${SYNC_BRANCH} pushed to ${REMOTE}."