From b43852e733c1df2bc633ae5e7a0b78fdfb06b5fd Mon Sep 17 00:00:00 2001 From: Twooey Date: Thu, 27 Aug 2026 10:10:12 -0400 Subject: [PATCH] Switch deploy pipeline to Gitea polling; fix env-parsing and volume-isolation bugs - Replace the bare-repo post-receive hook with deploy/poll-deploy.sh: Gitea and the Docker hosts are separate machines, so each box polls its branch via host crontab instead of needing an exposed webhook receiver. - Add Blocksy theme + Blocksy Companion auto-install to deploy.sh (free tier; the paid Book Store starter site still needs a manual license step). - Fix deploy.sh/backup.sh/restore.sh sourcing .env files as bash: a bcrypt hash's `$2a$14$...` shape breaks under `set -u`. Replaced with deploy/lib/env.sh, a literal (non-executing) KEY=VALUE reader. - Fix docker compose itself mangling the same kind of value: both `environment: ${VAR}` and `env_file:` run values through Compose's interpolation, which silently blanks `$identifier`-shaped substrings. The staging basic-auth hash is now rendered directly into the Caddyfile by deploy.sh, bypassing Compose's variable system entirely. - Fix dev/staging/production silently sharing one Compose project (and therefore one db_data volume) by pinning an explicit -p per environment. - cron and wordpress now share one environment anchor so they can't drift apart again (cron was silently missing WORDPRESS_CONFIG_EXTRA before). Co-Authored-By: Claude Sonnet 5 --- .env.example | 8 ++++++++ .gitignore | 1 + Makefile | 2 +- README.md | 33 +++++++++++++++++++++------------ deploy/backup.sh | 11 ++++++----- deploy/deploy.sh | 30 +++++++++++++++++++++++++----- deploy/hooks/post-receive | 26 -------------------------- deploy/lib/env.sh | 18 ++++++++++++++++++ deploy/poll-deploy.sh | 33 +++++++++++++++++++++++++++++++++ deploy/restore.sh | 6 +----- docker-compose.production.yml | 4 ++-- docker-compose.staging.yml | 12 +++++++----- docker/caddy/Caddyfile.staging | 10 +++++++++- 13 files changed, 132 insertions(+), 62 deletions(-) delete mode 100755 deploy/hooks/post-receive create mode 100755 deploy/lib/env.sh create mode 100755 deploy/poll-deploy.sh diff --git a/.env.example b/.env.example index 2502ac4..2811820 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,14 @@ # values for that environment. The filled-in files are gitignored — never # commit them. Same code everywhere; only these values differ (see # design doc §01, Environments & deployment). +# +# CAVEAT for DB_PASSWORD, DB_ROOT_PASSWORD, and the API keys below: docker +# compose passes these into containers via ${VAR} interpolation, which will +# silently mangle a value containing `$` followed by a letter (it tries to +# resolve it as another variable and blanks it out if unset). Avoid `$` in +# these specific values, or double it ($$) if you must use one. This does +# NOT apply to STAGING_BASIC_AUTH_HASH below — that one's wired through +# env_file instead specifically so a bcrypt hash's `$` signs are safe. # --- Site --- # (WP_ENVIRONMENT_TYPE is NOT set here — it's hardcoded per environment in diff --git a/.gitignore b/.gitignore index 4c4fc3a..9fe5cc0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ !/.env.example /backups/ +/docker/caddy/.generated/ vendor/ node_modules/ *.log diff --git a/Makefile b/Makefile index 76c39b6..d6e46cd 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ ENV ?= dev -COMPOSE = docker compose -f docker-compose.yml -f docker-compose.$(ENV).yml --env-file .env.$(ENV) +COMPOSE = docker compose -p bookstore-$(ENV) -f docker-compose.yml -f docker-compose.$(ENV).yml --env-file .env.$(ENV) .PHONY: up down ps logs shell wp deploy backup diff --git a/README.md b/README.md index abd82b9..74732cc 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ docker/php/ app image: WP core image + wp-cli, composer, red docker/caddy/ one Caddyfile per environment docker/cron/ Action Scheduler driver (system cron has no host to run on in Docker) deploy/deploy.sh idempotent bring-up + WP/WooCommerce config -deploy/hooks/post-receive git-server hook: push to `staging`/`main` deploys +deploy/poll-deploy.sh host-crontab script: redeploys when its branch moves on Gitea deploy/backup.sh, restore.sh off-host backup; restore is staging-only, on purpose wp-content/plugins/bookstore-core/ the one plugin that owns business logic wp-content/mu-plugins/ local-mail-catcher.php — routes mail to MailHog outside production @@ -78,19 +78,28 @@ the database like any other WordPress content — a redeploy or a fresh ## Deploy pipeline -This assumes your own bare Git server, not a hosted CI. The mechanism: +The Git server (Gitea) and the staging/production Docker hosts are separate +machines, so instead of a webhook receiver listening for an inbound POST +from Gitea, each box just polls its branch and redeploys when it moves — +no inbound port to expose or secure. -1. A bare repo lives on the server (e.g. `/srv/git/bookstore.git`). -2. `deploy/hooks/post-receive` is copied into `.git/hooks/` and - made executable. Edit `DEPLOY_ROOT` at the top of it first. -3. Pushing to `staging` checks that branch out into - `$DEPLOY_ROOT/staging` and runs `deploy.sh staging`; pushing to `main` - deploys `$DEPLOY_ROOT/production` the same way. -4. `deploy.sh` is idempotent — it's safe to re-run and safe to be the thing - the hook calls on every push. +1. Clone this repo onto the box (e.g. into `/srv/bookstore`), `cp + .env.example .env.staging` (or `.env.production`) and fill it in. +2. Add a host crontab entry (not inside a container — `crontab -e` on the + box itself): + ``` + */2 * * * * /srv/bookstore/deploy/poll-deploy.sh staging >> /var/log/bookstore-deploy.log 2>&1 + ``` + (`production` + `main` on the production box.) +3. `poll-deploy.sh` fetches, compares against the last-deployed commit, and + — only if it moved — checks out the branch and runs `deploy.sh`. Silent + otherwise, so it's safe to run every couple of minutes. +4. The box needs its own git credentials to fetch from Gitea (a read-only + access token works well) — set those up once via a credential helper or + an embedded token in that box's `origin` remote. -You can also just run `./deploy/deploy.sh ` by hand from a checked-out -copy on the server; the hook is only automation on top of the same script. +You can also just run `./deploy/deploy.sh ` by hand at any time; the +poll script is only automation on top of the same idempotent script. ## Backups diff --git a/deploy/backup.sh b/deploy/backup.sh index 6c49165..7179a1d 100755 --- a/deploy/backup.sh +++ b/deploy/backup.sh @@ -8,16 +8,17 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" ENV_FILE=".env.${ENVIRONMENT}" -set -a -# shellcheck disable=SC1090 -source "$ENV_FILE" -set +a +# shellcheck source=lib/env.sh +source "$REPO_ROOT/deploy/lib/env.sh" TIMESTAMP="$(date +%Y%m%d-%H%M%S)" +BACKUP_DIR="$(env_get "$ENV_FILE" BACKUP_DIR)" +BACKUP_REMOTE="$(env_get "$ENV_FILE" BACKUP_REMOTE)" +BACKUP_RETENTION_DAYS="$(env_get "$ENV_FILE" BACKUP_RETENTION_DAYS)" BACKUP_DIR="${BACKUP_DIR:-./backups}/${ENVIRONMENT}" mkdir -p "$BACKUP_DIR" -COMPOSE="docker compose -f docker-compose.yml -f docker-compose.${ENVIRONMENT}.yml --env-file ${ENV_FILE}" +COMPOSE="docker compose -p bookstore-${ENVIRONMENT} -f docker-compose.yml -f docker-compose.${ENVIRONMENT}.yml --env-file ${ENV_FILE}" echo "==> dumping database" $COMPOSE exec -T db sh -c "exec mysqldump -u\"\$MARIADB_USER\" -p\"\$MARIADB_PASSWORD\" \"\$MARIADB_DATABASE\"" \ diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 5bbbcb0..191edbe 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -18,12 +18,32 @@ if [[ ! -f "$ENV_FILE" ]]; then echo "missing $ENV_FILE — copy .env.example to $ENV_FILE and fill it in" >&2 exit 1 fi -set -a -# shellcheck disable=SC1090 -source "$ENV_FILE" -set +a +# shellcheck source=lib/env.sh +source "$REPO_ROOT/deploy/lib/env.sh" -COMPOSE="docker compose -f docker-compose.yml -f docker-compose.${ENVIRONMENT}.yml --env-file ${ENV_FILE}" +SITE_URL="$(env_get "$ENV_FILE" SITE_URL)" +SITE_TITLE="$(env_get "$ENV_FILE" SITE_TITLE)" +WP_ADMIN_USER="$(env_get "$ENV_FILE" WP_ADMIN_USER)" +WP_ADMIN_PASSWORD="$(env_get "$ENV_FILE" WP_ADMIN_PASSWORD)" +WP_ADMIN_EMAIL="$(env_get "$ENV_FILE" WP_ADMIN_EMAIL)" + +# -p pins the Compose project name to the environment (default is the +# directory name, which every environment shares — that made staging quietly +# reuse dev's db_data volume/credentials the first time this ran). +COMPOSE="docker compose -p bookstore-${ENVIRONMENT} -f docker-compose.yml -f docker-compose.${ENVIRONMENT}.yml --env-file ${ENV_FILE}" + +if [[ "$ENVIRONMENT" == "staging" ]]; then + echo "==> rendering Caddyfile.staging (basic-auth hash bypasses compose entirely)" + mkdir -p docker/caddy/.generated + AUTH_USER="$(env_get "$ENV_FILE" STAGING_BASIC_AUTH_USER)" + AUTH_HASH="$(env_get "$ENV_FILE" STAGING_BASIC_AUTH_HASH)" + : "${AUTH_USER:?set STAGING_BASIC_AUTH_USER in ${ENV_FILE}}" + : "${AUTH_HASH:?set STAGING_BASIC_AUTH_HASH in ${ENV_FILE} — see .env.example for how to generate it}" + TEMPLATE="$(cat docker/caddy/Caddyfile.staging)" + TEMPLATE="${TEMPLATE//__STAGING_BASIC_AUTH_USER__/$AUTH_USER}" + TEMPLATE="${TEMPLATE//__STAGING_BASIC_AUTH_HASH__/$AUTH_HASH}" + printf '%s\n' "$TEMPLATE" > docker/caddy/.generated/Caddyfile.staging +fi echo "==> building and starting ${ENVIRONMENT}" $COMPOSE up -d --build diff --git a/deploy/hooks/post-receive b/deploy/hooks/post-receive deleted file mode 100755 index 29374ec..0000000 --- a/deploy/hooks/post-receive +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -# Install this at .git/hooks/post-receive on your own Git server -# (chmod +x it) and adjust DEPLOY_ROOT below. Pushing to `staging` deploys -# to the staging checkout; pushing to `main` deploys to production. -# -# git remote add origin @:/srv/git/bookstore.git -# git push origin staging -# -set -euo pipefail - -DEPLOY_ROOT="/srv/bookstore" - -while read -r oldrev newrev refname; do - branch="${refname#refs/heads/}" - - case "$branch" in - staging) target="$DEPLOY_ROOT/staging"; env="staging" ;; - main) target="$DEPLOY_ROOT/production"; env="production" ;; - *) echo "post-receive: ignoring push to $branch"; continue ;; - esac - - echo "post-receive: deploying $branch -> $env ($target)" - mkdir -p "$target" - git --work-tree="$target" --git-dir="$(pwd)" checkout -f "$branch" - ( cd "$target" && ./deploy/deploy.sh "$env" ) -done diff --git a/deploy/lib/env.sh b/deploy/lib/env.sh new file mode 100755 index 0000000..54f3a14 --- /dev/null +++ b/deploy/lib/env.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Reads one KEY=VALUE out of a .env-style file WITHOUT ever executing the +# file as shell. `source`-ing a .env file breaks the moment a value contains +# characters bash treats specially — e.g. a Caddy bcrypt hash +# ($2a$14$...) looks like positional-parameter expansions ($2, $14, ...) to +# bash and blows up under `set -u`. docker compose's own --env-file parser +# already treats these files as literal key=value data; this matches that. +env_get() { + local file="$1" key="$2" line val + line="$(grep -m1 -E "^${key}=" "$file" 2>/dev/null || true)" + val="${line#*=}" + if [[ "$val" == \"*\" && "$val" == *\" ]]; then + val="${val#\"}"; val="${val%\"}" + elif [[ "$val" == \'*\' && "$val" == *\' ]]; then + val="${val#\'}"; val="${val%\'}" + fi + printf '%s' "$val" +} diff --git a/deploy/poll-deploy.sh b/deploy/poll-deploy.sh new file mode 100755 index 0000000..a3cea91 --- /dev/null +++ b/deploy/poll-deploy.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Meant to run via the HOST's system crontab on each environment's box (not +# inside a container) — Gitea lives on a separate machine, so rather than +# exposing an inbound webhook receiver on staging/production, each box just +# polls its branch and redeploys when it moves. Silent when there's nothing +# new, so it's safe to run every couple of minutes from cron. +# +# Example crontab line (staging box): +# */2 * * * * /srv/bookstore/deploy/poll-deploy.sh staging >> /var/log/bookstore-deploy.log 2>&1 +set -euo pipefail + +ENVIRONMENT="${1:?Usage: poll-deploy.sh }" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +case "$ENVIRONMENT" in + staging) BRANCH=staging ;; + production) BRANCH=main ;; + *) echo "environment must be 'staging' or 'production'" >&2; exit 1 ;; +esac + +BEFORE="$(git rev-parse HEAD)" +git fetch origin "$BRANCH" --quiet +AFTER="$(git rev-parse "origin/${BRANCH}")" + +if [[ "$BEFORE" == "$AFTER" ]]; then + exit 0 +fi + +echo "$(date -Is) deploying ${ENVIRONMENT} (${BRANCH}): ${BEFORE:0:7} -> ${AFTER:0:7}" +git checkout -f "$BRANCH" +git reset --hard "origin/${BRANCH}" +"$REPO_ROOT/deploy/deploy.sh" "$ENVIRONMENT" diff --git a/deploy/restore.sh b/deploy/restore.sh index e301b6a..bba037c 100755 --- a/deploy/restore.sh +++ b/deploy/restore.sh @@ -10,12 +10,8 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" ENV_FILE=".env.staging" -set -a -# shellcheck disable=SC1090 -source "$ENV_FILE" -set +a -COMPOSE="docker compose -f docker-compose.yml -f docker-compose.staging.yml --env-file ${ENV_FILE}" +COMPOSE="docker compose -p bookstore-staging -f docker-compose.yml -f docker-compose.staging.yml --env-file ${ENV_FILE}" echo "!! this OVERWRITES the staging database and uploads !!" read -r -p "type 'restore' to continue: " confirm diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 781bdd4..072b038 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -16,8 +16,8 @@ services: ports: - "80:80" - "443:443" - environment: - SITE_DOMAIN: ${SITE_DOMAIN} + env_file: + - .env.production volumes: - ./docker/caddy/Caddyfile.production:/etc/caddy/Caddyfile:ro - wp_core:/var/www/html:ro diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index bd19f5b..111117c 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -16,12 +16,14 @@ services: ports: - "80:80" - "443:443" - environment: - SITE_DOMAIN: ${SITE_DOMAIN} - STAGING_BASIC_AUTH_USER: ${STAGING_BASIC_AUTH_USER} - STAGING_BASIC_AUTH_HASH: ${STAGING_BASIC_AUTH_HASH} + # SITE_DOMAIN is safe to pass through normally (no `$` in a domain name). + # STAGING_BASIC_AUTH_USER/HASH are NOT passed via compose at all — see + # the comment in docker/caddy/Caddyfile.staging for why; deploy.sh + # renders them directly into the mounted file below instead. + env_file: + - .env.staging volumes: - - ./docker/caddy/Caddyfile.staging:/etc/caddy/Caddyfile:ro + - ./docker/caddy/.generated/Caddyfile.staging:/etc/caddy/Caddyfile:ro - wp_core:/var/www/html:ro - wp_uploads:/var/www/html/wp-content/uploads:ro - wp_themes:/var/www/html/wp-content/themes:ro diff --git a/docker/caddy/Caddyfile.staging b/docker/caddy/Caddyfile.staging index 649f008..9bfd741 100644 --- a/docker/caddy/Caddyfile.staging +++ b/docker/caddy/Caddyfile.staging @@ -3,8 +3,16 @@ # Launch gate: "No staging URLs are publicly indexed." Belt-and-suspenders # with wp_option blog_public=0, which deploy.sh sets on staging. + # + # The two tokens below are substituted by deploy.sh directly (bash string + # replacement), not by Caddy's {$VAR} or docker compose's ${VAR} — a + # bcrypt hash contains `$identifier`-looking substrings that Compose's + # own interpolation will silently corrupt if this value ever passes + # through it (confirmed: both `environment:` and `env_file:` are + # affected). This file is a tracked template; deploy.sh renders it into + # docker/caddy/.generated/Caddyfile.staging, which is what's mounted. basic_auth { - {$STAGING_BASIC_AUTH_USER} {$STAGING_BASIC_AUTH_HASH} + __STAGING_BASIC_AUTH_USER__ __STAGING_BASIC_AUTH_HASH__ } header X-Robots-Tag "noindex, nofollow, noarchive"