- PricingEngine::round_to_99(): ceil($price) - 0.01 undershoots whenever $price's cents are already .99 or higher — an exact integer (ceil() equals floor(), landing a full cent below $price) or, more subtly, any fractional price above X.99 itself (a division result, not something pre-rounded to 2 decimals, e.g. 20.995 -> old formula gave 20.99, below the input). Rewritten as floor()+0.99, bumped by 1 if still under $price. Verified against 8 cases including both boundary classes: every result now >= its input. - CoverSync::attach_cover(): wp_generate_attachment_metadata()'s return value was never checked. Assumed it'd return empty on failure — verified directly it does NOT: fed it 2000 bytes of garbage and got back ['filesize' => 2000], no width/height, since GD/Imagick couldn't decode it. Old code would report "attached" for a degraded image with no dimensions/srcset. Now checks for width+height specifically, and cleans up the orphaned attachment on failure so a re-run retries the product. Verified both the corrupt-image rejection (no orphan left, no thumbnail set) and that a real image still attaches normally. - Makefile: the per-invocation .env.$(ENV).compose file (holds every API key and both DB passwords, stripped of DB_PASSWORD/DB_ROOT_PASSWORD only) was never cleaned up, left at default 644 in the repo root after every `make` command. Now chmod 600 on creation and removed at the end of every target, preserving the underlying command's exit code through the cleanup. Verified both the happy path (file gone after, exit 0) and the failure path (bad ENV: file still cleaned up, real exit code still propagates through make). - docker-compose.yml: added a healthcheck to the wordpress service (bash's /dev/tcp against php-fpm's port 9000 — no HTTP endpoint to hit directly, and no `nc` in this image; verified it correctly succeeds once php-fpm is listening and fails against a closed port) and switched cron's and caddy's depends_on (across all three env overlays) from bare container-started to condition: service_healthy. Previously both could start against a wordpress container that had started but wasn't actually ready yet. Verified via a full down/up cycle: db+redis healthy, then wordpress starts and becomes healthy, only then do cron and caddy start. - .env.dev/.env.staging/.env.production chmod'd 600 (were 644) — same plaintext-credential content as secrets/<env>/, which is already 700/644 at the directory/file level respectively for a different reason (container UID readability); these have no such constraint, only the host CLI reads them. Also removed a stray .env.staging.compose left over from before the Makefile fix above existed. Noted the convention in .env.example so newly created env files follow it too.
Bookstore
WordPress + WooCommerce bookstore. Design reference: see docs/ for the
pre-launch plan and the technical design doc (bookstore-core schema,
interfaces, order state machine — the design doc is the source of truth for
why this repo is laid out the way it is).
This week's scope (Week 1 of the 5-week plan): environments, deploy
pipeline, and the WooCommerce/HPOS plugin scaffold. Catalog schema, pricing,
supplier adapters, and the order state machine are Week 2+ and live under
wp-content/plugins/bookstore-core/includes/, currently empty.
Layout
docker-compose.yml base services: db, redis, wordpress, cron
docker-compose.{dev,staging,production}.yml per-environment overrides (caddy, mailhog)
docker/php/ app image: WP core image + wp-cli, composer, redis ext
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/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
secrets/<env>/ DB passwords, written fresh by deploy.sh — gitignored, not manually edited
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
Same code runs in every environment; only .env.<env> and which
docker-compose.<env>.yml you layer in differ (design doc §01).
Local development
cp .env.example .env.dev # fill in DB_PASSWORD etc.; localhost values are fine
make up ENV=dev
make deploy ENV=dev # installs WP, WooCommerce, activates bookstore-core, enables HPOS
Site is at http://localhost:8080. make wp ENV=dev ARGS="plugin list" runs
any wp-cli command; make logs ENV=dev tails everything; make shell ENV=dev
drops into the app container.
Staging (this is what goes on your Docker server)
cp .env.example .env.staging
# fill in SITE_DOMAIN, SITE_URL, DB_*
# SITE_DOMAIN must be a HOSTNAME, not a bare IP — see below
make deploy ENV=staging
SITE_DOMAIN needs to be a hostname, not the VM's IP directly. TLS SNI
(how Caddy picks which certificate to present) isn't sent for literal IP
connections per spec, so HTTPS to a bare IP fails at the handshake itself
no matter what Caddy does. Pick any hostname (e.g. bookstore.lan), add it
to /etc/hosts (or the Windows equivalent) on whatever machine you're
browsing from, pointing at the VM's LAN IP, and use that hostname as both
SITE_DOMAIN and in SITE_URL.
Caddyfile.staging forces tls internal — Caddy's own self-signed CA,
issued locally with no external network calls — rather than letting Caddy
guess whether the name looks "public" (its automatic heuristic only
recognizes bare IPs and localhost as obviously-private; anything else,
including a made-up LAN hostname, it assumes might be real and tries
Let's Encrypt, which then fails). Your browser will show an untrusted-cert
warning once; click through it, or trust Caddy's root cert to skip that:
docker compose -p bookstore-staging exec caddy cat /data/caddy/pki/authorities/local/root.crt
Staging is noindex'd (blog_public=0 plus the X-Robots-Tag header in
Caddyfile.staging) so search engines won't index it — there's no basic-auth
wall on top of that, since this box is LAN-only and not reachable from
outside. If that ever changes (a public domain, port-forwarding, etc.),
basic-auth is worth adding back before that happens, not after. Mail never
leaves the box: it's caught by MailHog, viewable at :8025.
Theme: Blocksy + the Book Store starter site
deploy.sh installs and activates the Blocksy theme and the free
Blocksy Companion plugin from wordpress.org automatically — nothing to
do here, in any environment.
The Book Store starter site itself is a paid Companion Pro template (Business plan, $99/year — design doc Appendix A), so it can't be scripted against a public API the way the free theme can. One-time manual step per environment, in wp-admin:
- Purchase/retrieve the Blocksy Business license key.
- Blocksy → General → License → activate it.
- Blocksy → Extensions → Starter Sites (Companion Pro) → import Book Store.
After that, the starter site's content and Customizer settings persist in
the database like any other WordPress content — a redeploy or a fresh
deploy.sh run doesn't touch it or need to repeat it.
Deploy pipeline
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.
- Clone this repo onto the box (e.g. into
/srv/bookstore),cp .env.example .env.staging(or.env.production) and fill it in. - Add a host crontab entry (not inside a container —
crontab -eon the box itself):(*/2 * * * * /srv/bookstore/deploy/poll-deploy.sh staging >> /var/log/bookstore-deploy.log 2>&1production+mainon the production box.) poll-deploy.shfetches, compares against the last-deployed commit, and — only if it moved — checks out the branch and runsdeploy.sh. Silent otherwise, so it's safe to run every couple of minutes.- 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
originremote.
You can also just run ./deploy/deploy.sh <env> by hand at any time; the
poll script is only automation on top of the same idempotent script.
Backups
./deploy/backup.sh staging # or production
Dumps the database and archives wp-content/uploads, gzips both, and syncs
to $BACKUP_REMOTE via rclone if set (configure rclone config on the
server first — this repo doesn't manage remote credentials). Before launch,
run a real restore drill:
./deploy/restore.sh ./backups/production/db-<ts>.sql.gz ./backups/production/uploads-<ts>.tar.gz
restore.sh only ever targets staging — there's no production argument, so
a restore drill can't accidentally overwrite the live site.
What's deliberately not here yet
- Catalog tables, pricing engine, supplier adapters, order state machine — Week 2–4, see the design doc.
- A real TLS-terminating public domain — Caddy will auto-provision certs
once
SITE_DOMAINpoints at a real host with 80/443 reachable; until thendocker-compose.dev.ymlis the only one that works fromlocalhost. - CI test/lint automation — nothing here runs tests yet because there's no application code to test yet.