From 709a4996974bea5b2ba29b816be88c067f342347 Mon Sep 17 00:00:00 2001 From: Axiom Date: Mon, 12 Oct 2026 16:25:05 -0700 Subject: [PATCH] Deploy newsletter through restricted shop access --- .github/workflows/deploy.yml | 68 ++++++++++++++++++++++-- .github/workflows/pr-checks.yml | 1 - docs/deployment-pipeline.md | 14 +++-- docs/newsletter-popup-fix.md | 6 +-- scripts/deploy-file.sh | 2 +- scripts/derive-newsletter-remote-path.sh | 51 ------------------ tests/scripts/deploy-file.test.sh | 29 ++++------ 7 files changed, 89 insertions(+), 82 deletions(-) delete mode 100755 scripts/derive-newsletter-remote-path.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a467b11..26d5edc 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -30,6 +30,14 @@ jobs: THREEW_PROD_REMOTE_THEME_DIR: ${{ secrets.THREEW_PROD_REMOTE_THEME_DIR }} THREEW_PROD_DEPLOY_SCHEME: ${{ secrets.THREEW_PROD_DEPLOY_SCHEME }} THREEW_PROD_SSL_VERIFY: ${{ secrets.THREEW_PROD_SSL_VERIFY }} + THREEW_SHOP_FTP_HOST: ${{ secrets.THREEW_SHOP_FTP_HOST }} + THREEW_SHOP_FTP_HOST_IP: ${{ secrets.THREEW_SHOP_FTP_HOST_IP }} + THREEW_SHOP_FTP_PORT: ${{ secrets.THREEW_SHOP_FTP_PORT }} + THREEW_SHOP_FTP_USER: ${{ secrets.THREEW_SHOP_FTP_USER }} + THREEW_SHOP_FTP_PASS: ${{ secrets.THREEW_SHOP_FTP_PASS }} + THREEW_SHOP_DEPLOY_SCHEME: ${{ secrets.THREEW_SHOP_DEPLOY_SCHEME }} + THREEW_SHOP_SSL_VERIFY: ${{ secrets.THREEW_SHOP_SSL_VERIFY }} + THREEW_SHOP_REMOTE_NEWSLETTER_FILE: ${{ secrets.THREEW_SHOP_REMOTE_NEWSLETTER_FILE }} steps: - name: Check out repository uses: actions/checkout@v4 @@ -86,7 +94,15 @@ jobs: THREEW_PROD_FTP_USER \ THREEW_PROD_FTP_PASS \ THREEW_PROD_REMOTE_THEME_DIR \ - THREEW_PROD_DEPLOY_SCHEME; do + THREEW_PROD_DEPLOY_SCHEME \ + THREEW_SHOP_FTP_HOST \ + THREEW_SHOP_FTP_HOST_IP \ + THREEW_SHOP_FTP_PORT \ + THREEW_SHOP_FTP_USER \ + THREEW_SHOP_FTP_PASS \ + THREEW_SHOP_DEPLOY_SCHEME \ + THREEW_SHOP_SSL_VERIFY \ + THREEW_SHOP_REMOTE_NEWSLETTER_FILE; do if [[ -z "${!name:-}" ]]; then echo "Missing required secret-backed env: ${name}" >&2 missing=1 @@ -99,22 +115,64 @@ jobs: - name: Deploy theme with existing script run: ./scripts/deploy-theme.sh --target production - - name: Resolve newsletter popup asset path + - name: Configure restricted shop deployment target run: | - shop_file=$(./scripts/derive-newsletter-remote-path.sh "${THREEW_PROD_REMOTE_THEME_DIR}" shop) + if [[ "${THREEW_SHOP_DEPLOY_SCHEME}" != "ftps" ]]; then + echo "Restricted shop deployment requires FTPS" >&2 + exit 1 + fi + if [[ "${THREEW_SHOP_SSL_VERIFY}" != "yes" ]]; then + echo "Restricted shop deployment requires TLS certificate verification" >&2 + exit 1 + fi + if [[ "${THREEW_SHOP_FTP_PORT}" != "21" ]]; then + echo "Restricted shop FTPS must use explicit-TLS port 21" >&2 + exit 1 + fi + if [[ ! "${THREEW_SHOP_FTP_HOST}" =~ ^[A-Za-z0-9.-]+\.hstgr\.io$ ]]; then + echo "Shop FTP host must be a Hostinger TLS hostname" >&2 + exit 1 + fi + if [[ ! "${THREEW_SHOP_FTP_HOST_IP}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then + echo "Shop FTP host IP is invalid" >&2 + exit 1 + fi + IFS='.' read -r -a ip_octets <<< "${THREEW_SHOP_FTP_HOST_IP}" + for octet in "${ip_octets[@]}"; do + if (( 10#${octet} > 255 )); then + echo "Shop FTP host IP is invalid" >&2 + exit 1 + fi + done + if [[ "${THREEW_SHOP_REMOTE_NEWSLETTER_FILE}" != "newsletter-popup.js" ]]; then + echo "Restricted shop FTP account must target newsletter-popup.js at its root" >&2 + exit 1 + fi + printf '%s %s\n' "${THREEW_SHOP_FTP_HOST_IP}" "${THREEW_SHOP_FTP_HOST}" \ + | sudo tee -a /etc/hosts >/dev/null + if ! getent ahostsv4 "${THREEW_SHOP_FTP_HOST}" | awk '{print $1}' \ + | grep -Fxq "${THREEW_SHOP_FTP_HOST_IP}"; then + echo "Pinned shop FTP hostname did not resolve to its configured address" >&2 + exit 1 + fi local_sha256=$(sha256sum deploy/production/3w-newsletter-popup/assets/newsletter-popup.js | awk '{print $1}') - echo "NEWSLETTER_REMOTE_FILE_SHOP=${shop_file}" >> "${GITHUB_ENV}" echo "NEWSLETTER_LOCAL_SHA256=${local_sha256}" >> "${GITHUB_ENV}" - name: Deploy newsletter popup fix with rollback evidence env: + THREEW_PROD_FTP_HOST: ${{ secrets.THREEW_SHOP_FTP_HOST }} + THREEW_PROD_FTP_PORT: ${{ secrets.THREEW_SHOP_FTP_PORT }} + THREEW_PROD_FTP_USER: ${{ secrets.THREEW_SHOP_FTP_USER }} + THREEW_PROD_FTP_PASS: ${{ secrets.THREEW_SHOP_FTP_PASS }} + THREEW_PROD_DEPLOY_SCHEME: ${{ secrets.THREEW_SHOP_DEPLOY_SCHEME }} + THREEW_PROD_SSL_VERIFY: ${{ secrets.THREEW_SHOP_SSL_VERIFY }} BACKUP_FILE: ${{ runner.temp }}/newsletter-popup.js.before VERIFIED_FILE: ${{ runner.temp }}/newsletter-popup.js.after run: | ./scripts/deploy-file.sh \ --target production \ --local-file deploy/production/3w-newsletter-popup/assets/newsletter-popup.js \ - --remote-file "${NEWSLETTER_REMOTE_FILE_SHOP}" \ + --remote-file "${THREEW_SHOP_REMOTE_NEWSLETTER_FILE}" \ --backup-file "${BACKUP_FILE}" \ --verified-file "${VERIFIED_FILE}" \ --expected-remote-sha256 f3873bf2cdef7967a6d227a66d46ce6076a42ec6b660416e0b6a8379f1134cc3 \ diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 145e58e..52ba7f0 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -78,7 +78,6 @@ jobs: - name: Check deployment scripts and asset syntax run: | bash -n scripts/deploy-file.sh - bash -n scripts/derive-newsletter-remote-path.sh node --check deploy/production/3w-newsletter-popup/assets/newsletter-popup.js bash tests/scripts/deploy-file.test.sh diff --git a/docs/deployment-pipeline.md b/docs/deployment-pipeline.md index 2a65ef9..c86ac6e 100644 --- a/docs/deployment-pipeline.md +++ b/docs/deployment-pipeline.md @@ -52,7 +52,7 @@ The workflow performs quality gates before calling the script: The deploy script then builds the theme again and mirrors runtime-safe theme files through `lftp`, using the script's existing exclude rules. -The workflow also reruns the newsletter-popup browser regression and deploys the reviewed JavaScript override with `scripts/deploy-file.sh`. This is deliberately a one-file upload: it backs up and hash-checks the existing remote asset, rejects unsafe path segments, never mirrors or deletes the plugin directory, automatically rolls back failed read-back verification, verifies the uploaded bytes, and archives rollback evidence for 14 days. See `docs/newsletter-popup-fix.md`. +The workflow also reruns the newsletter-popup browser regression and deploys the reviewed JavaScript override with `scripts/deploy-file.sh` through a separate shop FTP account rooted at the plugin's `assets` directory. This is deliberately a one-file upload: it backs up and hash-checks the existing remote asset, rejects unsafe path segments, never mirrors or deletes the plugin directory, automatically rolls back failed read-back verification, verifies the uploaded bytes, and archives rollback evidence for 14 days. See `docs/newsletter-popup-fix.md`. ## Required GitHub configuration @@ -75,8 +75,16 @@ Required secrets: | `THREEW_PROD_REMOTE_THEME_DIR` | Remote theme directory, e.g. `public_html/wp-content/themes/3w-2025` | | `THREEW_PROD_DEPLOY_SCHEME` | `ftp`, `ftps`, or `sftp` | | `THREEW_PROD_SSL_VERIFY` | `yes` or `no`; prefer `yes` for FTPS | - -Do not add `.env`, passwords, deploy keys, or host credentials to git. +| `THREEW_SHOP_FTP_HOST` | Hostinger TLS hostname used by the restricted shop account | +| `THREEW_SHOP_FTP_HOST_IP` | Pinned shop FTP server address mapped to the TLS hostname on the runner | +| `THREEW_SHOP_FTP_PORT` | Shop FTPS port | +| `THREEW_SHOP_FTP_USER` | Restricted shop deployment username | +| `THREEW_SHOP_FTP_PASS` | Restricted shop deployment password | +| `THREEW_SHOP_DEPLOY_SCHEME` | Shop protocol; currently `ftps` | +| `THREEW_SHOP_SSL_VERIFY` | Shop TLS verification; must remain enabled | +| `THREEW_SHOP_REMOTE_NEWSLETTER_FILE` | Exact filename within the restricted account root; `newsletter-popup.js` | + +Do not add `.env`, passwords, deploy keys, or host credentials to git. Keep the shop FTP account rooted at `/public_html/wp-content/plugins/3w-newsletter-popup/assets` so workflow credentials cannot modify unrelated WordPress files. ## Notes diff --git a/docs/newsletter-popup-fix.md b/docs/newsletter-popup-fix.md index 6693b3c..4169708 100644 --- a/docs/newsletter-popup-fix.md +++ b/docs/newsletter-popup-fix.md @@ -24,7 +24,7 @@ The replacement is verified by `tests/e2e/newsletter-popup.spec.js` at desktop a ## Deployment and rollback -The production workflow reruns the desktop/mobile popup regression, deploys the theme as before, then uses `scripts/derive-newsletter-remote-path.sh` to derive the shop plugin path from relative, site-root-relative or absolute production theme directories. It validates the expected `wp-content/themes/3w-2025` path components and inserts only the bounded `shop` site directory. `scripts/deploy-file.sh` then: +The production workflow reruns the desktop/mobile popup regression and deploys the main-site theme as before. The popup upload uses a separate, least-privilege Hostinger FTP account rooted at the shop plugin's `assets` directory. The workflow requires FTPS with certificate verification, validates the pinned Hostinger TLS target and exact `newsletter-popup.js` remote filename, then runs `scripts/deploy-file.sh`, which: 1. downloads the current remote JS as a rollback artifact; 2. refuses to overwrite any hash except the reviewed original or this exact override (the latter makes retries idempotent); @@ -35,10 +35,10 @@ The production workflow reruns the desktop/mobile popup regression, deploys the The workflow then verifies the public asset through a cache-busting query and archives before/after files for 14 days. -To roll back, upload the workflow artifact `newsletter-popup.js.before` to: +To roll back through the restricted shop account, upload the workflow artifact `newsletter-popup.js.before` as `newsletter-popup.js`. Its full shop filesystem location is: ```text -wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js +/public_html/wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js ``` The production script is still referenced as `?ver=1.0.1` and Cloudflare caches it for seven days. The first workflow run is expected to turn red after a successful origin upload when that canonical URL is still stale. This failure deliberately leaves the verified fix at the origin. Purge only this exact URL and rerun the workflow (or wait for its TTL); the idempotent override hash is allowlisted for that rerun. A green deployment therefore cannot be mistaken for a customer-visible fix: diff --git a/scripts/deploy-file.sh b/scripts/deploy-file.sh index b28203d..f13a1de 100755 --- a/scripts/deploy-file.sh +++ b/scripts/deploy-file.sh @@ -87,7 +87,7 @@ done # Remote paths are interpolated into an lftp command stream. Limit the accepted # alphabet so a caller cannot turn a path into another command. -if [[ ! "$REMOTE_FILE" =~ ^[A-Za-z0-9._/-]+$ || "$REMOTE_FILE" != */* || "$REMOTE_FILE" == *"//"* ]]; then +if [[ ! "$REMOTE_FILE" =~ ^[A-Za-z0-9._/-]+$ || "$REMOTE_FILE" == *"//"* ]]; then echo "Remote file path contains unsupported characters: $REMOTE_FILE" >&2 exit 1 fi diff --git a/scripts/derive-newsletter-remote-path.sh b/scripts/derive-newsletter-remote-path.sh deleted file mode 100755 index 0caf0a8..0000000 --- a/scripts/derive-newsletter-remote-path.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env bash -# Derive the live newsletter-popup asset from the configured 3w-2025 theme path. -set -euo pipefail - -if [[ $# -lt 1 || $# -gt 2 || -z "$1" ]]; then - echo "Usage: derive-newsletter-remote-path.sh REMOTE_THEME_DIR [SITE_SUBDIRECTORY]" >&2 - exit 1 -fi - -THEME_DIR=${1%/} -SITE_SUBDIRECTORY=${2:-} -if [[ -n "$SITE_SUBDIRECTORY" && ! "$SITE_SUBDIRECTORY" =~ ^[A-Za-z0-9._-]+$ ]]; then - echo "Site subdirectory must be one safe path segment" >&2 - exit 1 -fi -if [[ ! "$THEME_DIR" =~ ^[A-Za-z0-9._/-]+$ || "$THEME_DIR" == *"//"* ]]; then - echo "Remote theme directory contains unsupported characters" >&2 - exit 1 -fi - -PATH_WITHOUT_ROOT=${THEME_DIR#/} -IFS='/' read -r -a PATH_SEGMENTS <<< "$PATH_WITHOUT_ROOT" -for path_segment in "${PATH_SEGMENTS[@]}"; do - if [[ -z "$path_segment" || "$path_segment" == "." || "$path_segment" == ".." ]]; then - echo "Remote theme directory contains an unsafe segment" >&2 - exit 1 - fi -done - -if [[ $(basename -- "$THEME_DIR") != "3w-2025" ]]; then - echo "Remote theme directory must end in the 3w-2025 theme" >&2 - exit 1 -fi -THEMES_DIR=$(dirname -- "$THEME_DIR") -if [[ $(basename -- "$THEMES_DIR") != "themes" ]]; then - echo "Remote theme directory must be under a themes directory" >&2 - exit 1 -fi -WP_CONTENT_DIR=$(dirname -- "$THEMES_DIR") -if [[ $(basename -- "$WP_CONTENT_DIR") != "wp-content" ]]; then - echo "Remote theme directory must be under wp-content/themes" >&2 - exit 1 -fi - -SITE_ROOT=$(dirname -- "$WP_CONTENT_DIR") -case "$SITE_ROOT" in - .) SITE_PREFIX="" ;; - /) SITE_PREFIX="/" ;; - *) SITE_PREFIX="${SITE_ROOT}/" ;; -esac -printf '%s%s%s\n' "$SITE_PREFIX" "${SITE_SUBDIRECTORY:+${SITE_SUBDIRECTORY}/}" "wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js" diff --git a/tests/scripts/deploy-file.test.sh b/tests/scripts/deploy-file.test.sh index 1f2fe1c..1975ab6 100755 --- a/tests/scripts/deploy-file.test.sh +++ b/tests/scripts/deploy-file.test.sh @@ -3,7 +3,6 @@ set -euo pipefail REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) SCRIPT="$REPO_ROOT/scripts/deploy-file.sh" -DERIVE_PATH_SCRIPT="$REPO_ROOT/scripts/derive-newsletter-remote-path.sh" TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT @@ -68,6 +67,17 @@ run_deploy run_deploy [[ $(sha256sum "$TMP/remote.js" | awk '{print $1}') == "$LOCAL_HASH" ]] +# A least-privilege account rooted at the target directory uses a bare filename. +env "${COMMON_ENV[@]}" "$SCRIPT" \ + --target staging \ + --local-file "$TMP/local.js" \ + --remote-file newsletter-popup.js \ + --backup-file "$TMP/backup.js" \ + --verified-file "$TMP/verified.js" \ + --expected-remote-sha256 "$ORIGINAL_HASH" \ + --expected-remote-sha256 "$LOCAL_HASH" +[[ $(sha256sum "$TMP/remote.js" | awk '{print $1}') == "$LOCAL_HASH" ]] + # Unknown remote bytes must fail before upload. printf 'unreviewed remote edit' > "$TMP/remote.js" if run_deploy >/dev/null 2>&1; then @@ -122,21 +132,4 @@ if env "${COMMON_ENV[@]}" "$SCRIPT" \ exit 1 fi -# Theme paths may be relative, site-root relative or absolute. -[[ $("$DERIVE_PATH_SCRIPT" wp-content/themes/3w-2025) == 'wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js' ]] -[[ $("$DERIVE_PATH_SCRIPT" public_html/wp-content/themes/3w-2025/) == 'public_html/wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js' ]] -[[ $("$DERIVE_PATH_SCRIPT" wp-content/themes/3w-2025 shop) == 'shop/wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js' ]] -[[ $("$DERIVE_PATH_SCRIPT" /home/site/public_html/wp-content/themes/3w-2025) == '/home/site/public_html/wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js' ]] -[[ $("$DERIVE_PATH_SCRIPT" /home/site/public_html/wp-content/themes/3w-2025 shop) == '/home/site/public_html/shop/wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js' ]] -[[ $("$DERIVE_PATH_SCRIPT" /wp-content/themes/3w-2025) == '/wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js' ]] -for unsafe_theme_path in \ - themes/3w-2025 \ - wp-content/themes/porto \ - public_html/wp-content/../themes/3w-2025; do - if "$DERIVE_PATH_SCRIPT" "$unsafe_theme_path" >/dev/null 2>&1; then - echo "Expected invalid theme path to fail: $unsafe_theme_path" >&2 - exit 1 - fi -done - echo 'deploy-file safety tests passed'