- ProductSync: bsc_work.wc_product_id could go stale if a product was ever
deleted out-of-band (wp-admin, a cleanup script). wc_get_product() then
correctly detects "no product" and creates a new one, but the repoint was
gated behind `if (!$existing_id)` — which was already true, so the stale
ID never got corrected. Every future sync repeated this, one duplicate
product per run. Fixed by making the repoint unconditional (cheap,
idempotent UPDATE either way). Reproduced the exact scenario against dev
(deleted a product out-of-band, ran sync-products twice) and confirmed:
one repoint, zero duplicates, product count and per-work product count
both correct across repeated runs.
- Commands.php (sync-hardcover-tags): wp_set_object_terms() with an empty
array clears the taxonomy rather than leaving it alone — verified
directly. Hardcover legitimately returns no moods/content-warnings for
plenty of books, so a --force re-sync could silently wipe existing tags,
including anything hand-tagged. Fixed by skipping the call per-category
when that category's array is empty. Verified via a direct eval test:
pre-existing genre tag survives a sync where genre comes back empty,
mood still gets set normally.
- docker-compose.yml: two stray root-owned secrets/db_password and
secrets/db_root_password directories were already sitting on disk —
Docker auto-creating a bind-mount source as a directory from an earlier
manual `docker compose` call that ran without ENVIRONMENT exported
(reproducing the exact bug already fixed once this session). Removed
the stray dirs and changed every `${ENVIRONMENT}` in a volume mount to
`${ENVIRONMENT:?ENVIRONMENT must be set}` so Compose now hard-fails
instead of silently defaulting to empty. Verified: unset ENVIRONMENT now
fails config validation with a clear error; set, it still works.
- HARDCOVER_API_TOKEN moved to the same _FILE secrets pattern already used
for DB_PASSWORD/DB_ROOT_PASSWORD (docker-compose.yml, deploy.sh,
HardcoverAdapter.php) — it was a live, consumed secret still going
through Compose's ${VAR} interpolation, exposed to the same
mangling bug already fixed for the DB passwords, plus visible via
`docker inspect`. deploy.sh now writes secrets/<env>/hardcover_api_token
(optionally empty). Verified via deploy.sh dev + wp eval: empty file ->
is_configured() false, a real token value -> true.
- backup.sh: mariadb-dump had no --single-transaction, so a dump against a
live site would either table-lock for its duration or produce a
non-atomic/inconsistent dump. Added; verified a real dump still runs
clean and produces a valid, restorable-looking .sql.gz.
125 lines
5.6 KiB
Bash
Executable File
125 lines
5.6 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Builds and brings up an environment, then makes sure WordPress/WooCommerce
|
|
# are installed and configured to match design doc §01/§03/§07. Safe to run
|
|
# repeatedly — every step is idempotent.
|
|
set -euo pipefail
|
|
|
|
ENVIRONMENT="${1:?Usage: deploy.sh <dev|staging|production>}"
|
|
export ENVIRONMENT
|
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
cd "$REPO_ROOT"
|
|
|
|
case "$ENVIRONMENT" in
|
|
dev|staging|production) ;;
|
|
*) echo "environment must be 'dev', 'staging', or 'production'" >&2; exit 1 ;;
|
|
esac
|
|
|
|
ENV_FILE=".env.${ENVIRONMENT}"
|
|
if [[ ! -f "$ENV_FILE" ]]; then
|
|
echo "missing $ENV_FILE — copy .env.example to $ENV_FILE and fill it in" >&2
|
|
exit 1
|
|
fi
|
|
# shellcheck source=lib/env.sh
|
|
source "$REPO_ROOT/deploy/lib/env.sh"
|
|
|
|
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)"
|
|
|
|
# DB_PASSWORD/DB_ROOT_PASSWORD are stripped from what Compose itself loads:
|
|
# Compose scans every value in --env-file for $identifier-looking patterns
|
|
# as part of building its own interpolation table, and warns "variable not
|
|
# set" for any it finds — even though nothing consumes these two via ${VAR}
|
|
# anymore (they only flow through secrets/, via env_get below, which reads
|
|
# the real, unfiltered $ENV_FILE directly). Filtering them out of Compose's
|
|
# copy is what makes that warning actually go away, permanently, regardless
|
|
# of what characters end up in either password.
|
|
COMPOSE_ENV_FILE="$(mktemp)"
|
|
trap 'rm -f "$COMPOSE_ENV_FILE"' EXIT
|
|
grep -Ev '^(DB_PASSWORD|DB_ROOT_PASSWORD)=' "$ENV_FILE" > "$COMPOSE_ENV_FILE"
|
|
|
|
# -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 ${COMPOSE_ENV_FILE}"
|
|
# wp-cli runs as www-data (who already owns wp-config.php etc.), not root —
|
|
# `docker exec` defaults to root only because the image never sets a
|
|
# non-root default user for exec sessions; the actual web-facing php-fpm
|
|
# workers already run as www-data regardless.
|
|
WP="$COMPOSE exec -T -u www-data wordpress wp"
|
|
|
|
echo "==> writing DB secret files (bypasses compose's \${VAR} interpolation entirely)"
|
|
SECRETS_DIR="secrets/${ENVIRONMENT}"
|
|
mkdir -p "$SECRETS_DIR"
|
|
chmod 700 "secrets" "$SECRETS_DIR" 2>/dev/null || true
|
|
DB_PASSWORD_VALUE="$(env_get "$ENV_FILE" DB_PASSWORD)"
|
|
DB_ROOT_PASSWORD_VALUE="$(env_get "$ENV_FILE" DB_ROOT_PASSWORD)"
|
|
: "${DB_PASSWORD_VALUE:?set DB_PASSWORD in ${ENV_FILE}}"
|
|
: "${DB_ROOT_PASSWORD_VALUE:?set DB_ROOT_PASSWORD in ${ENV_FILE}}"
|
|
printf '%s' "$DB_PASSWORD_VALUE" > "$SECRETS_DIR/db_password"
|
|
printf '%s' "$DB_ROOT_PASSWORD_VALUE" > "$SECRETS_DIR/db_root_password"
|
|
# Optional, unlike the two above — an environment without a Hardcover token
|
|
# just leaves HardcoverAdapter::is_configured() false. Written unconditionally
|
|
# (even empty) so the bind mount in docker-compose.yml always has a real file
|
|
# to point at, never a directory Docker auto-creates for a missing path (the
|
|
# exact bug ENVIRONMENT-unset guards elsewhere in this file exist to prevent).
|
|
HARDCOVER_API_TOKEN_VALUE="$(env_get "$ENV_FILE" HARDCOVER_API_TOKEN)"
|
|
printf '%s' "$HARDCOVER_API_TOKEN_VALUE" > "$SECRETS_DIR/hardcover_api_token"
|
|
# 644, not 600: the db/wordpress/cron containers read this as their own
|
|
# (non-host-matching) container UID, e.g. www-data — chmod 600 made it
|
|
# unreadable to them. The containing directory (700, above) is what
|
|
# actually keeps other host users out; these just need to be world-readable
|
|
# within that already-restricted directory.
|
|
chmod 644 "$SECRETS_DIR/db_password" "$SECRETS_DIR/db_root_password" "$SECRETS_DIR/hardcover_api_token"
|
|
|
|
echo "==> building and starting ${ENVIRONMENT}"
|
|
$COMPOSE up -d --build
|
|
|
|
echo "==> waiting for WordPress core files (the official image copies them in on first boot of an empty volume, which takes a few seconds)"
|
|
for i in $(seq 1 60); do
|
|
if $COMPOSE exec -T wordpress test -f /var/www/html/wp-settings.php >/dev/null 2>&1; then
|
|
break
|
|
fi
|
|
if [[ "$i" -eq 60 ]]; then
|
|
echo "wordpress core files did not appear in time" >&2
|
|
exit 1
|
|
fi
|
|
sleep 2
|
|
done
|
|
|
|
echo "==> ensuring WordPress is installed"
|
|
if ! $WP core is-installed; then
|
|
$WP core install \
|
|
--url="${SITE_URL:?set SITE_URL in ${ENV_FILE}}" \
|
|
--title="${SITE_TITLE:-Bookstore}" \
|
|
--admin_user="${WP_ADMIN_USER:?set WP_ADMIN_USER in ${ENV_FILE}}" \
|
|
--admin_password="${WP_ADMIN_PASSWORD:?set WP_ADMIN_PASSWORD in ${ENV_FILE}}" \
|
|
--admin_email="${WP_ADMIN_EMAIL:?set WP_ADMIN_EMAIL in ${ENV_FILE}}" \
|
|
--skip-email
|
|
fi
|
|
|
|
echo "==> installing/activating theme (Blocksy — free; Book Store starter site needs a manual license + import, see README)"
|
|
$WP theme install blocksy --activate
|
|
|
|
echo "==> installing/activating required plugins"
|
|
$WP plugin install woocommerce redis-cache blocksy-companion --activate
|
|
$WP plugin activate bookstore-core
|
|
$WP redis enable || true
|
|
|
|
echo "==> enabling WooCommerce HPOS (custom order tables)"
|
|
$WP option update woocommerce_custom_orders_table_enabled yes
|
|
$WP option update woocommerce_custom_orders_table_data_sync_enabled yes
|
|
|
|
if [[ "$ENVIRONMENT" != "production" ]]; then
|
|
echo "==> discouraging search engines (non-production)"
|
|
$WP option update blog_public 0
|
|
fi
|
|
|
|
echo "==> flushing caches and rewrite rules"
|
|
$WP cache flush
|
|
$WP rewrite flush
|
|
|
|
echo "==> deploy complete: ${ENVIRONMENT}"
|