Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 63 additions & 5 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 \
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 11 additions & 3 deletions docs/deployment-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
6 changes: 3 additions & 3 deletions docs/newsletter-popup-fix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion scripts/deploy-file.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 0 additions & 51 deletions scripts/derive-newsletter-remote-path.sh

This file was deleted.

29 changes: 11 additions & 18 deletions tests/scripts/deploy-file.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Loading