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
76 changes: 74 additions & 2 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ concurrency:

jobs:
deploy:
name: Build and deploy theme
name: Build and deploy production assets
runs-on: ubuntu-latest
environment: production
defaults:
Expand All @@ -39,7 +39,9 @@ jobs:
with:
node-version: 20
cache: npm
cache-dependency-path: wp-content/themes/3w-2025/package-lock.json
cache-dependency-path: |
package-lock.json
wp-content/themes/3w-2025/package-lock.json

- name: Install lftp
run: |
Expand All @@ -65,6 +67,17 @@ jobs:
- name: Check theme PHP files
run: find wp-content/themes/3w-2025 -name '*.php' -print0 | xargs -0 -n1 php -l

- name: Install newsletter regression dependencies
run: npm ci

- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium

- name: Run newsletter popup regression
run: |
npx playwright test tests/e2e/newsletter-popup.spec.js --reporter=line
bash tests/scripts/deploy-file.test.sh

- name: Verify deployment secrets
run: |
missing=0
Expand All @@ -85,3 +98,62 @@ jobs:

- name: Deploy theme with existing script
run: ./scripts/deploy-theme.sh --target production

- name: Resolve newsletter popup asset path
run: |
theme_dir="${THREEW_PROD_REMOTE_THEME_DIR%/}"
suffix="/wp-content/themes/3w-2025"
if [[ "${theme_dir}" != *"${suffix}" ]]; then
echo "Remote theme directory must end with ${suffix}; refusing to derive a plugin path" >&2
exit 1
fi
site_root="${theme_dir%${suffix}}"
echo "NEWSLETTER_REMOTE_FILE=${site_root}/wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js" >> "${GITHUB_ENV}"

- name: Deploy newsletter popup fix with rollback evidence
env:
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}" \
--backup-file "${BACKUP_FILE}" \
--verified-file "${VERIFIED_FILE}" \
--expected-remote-sha256 f3873bf2cdef7967a6d227a66d46ce6076a42ec6b660416e0b6a8379f1134cc3 \
--expected-remote-sha256 b6af9603cef2cf5cc82541ef256a720a157c2c2e9456eef55ca059711c6e207b

- name: Verify newsletter popup bytes at the origin
run: |
expected=$(sha256sum deploy/production/3w-newsletter-popup/assets/newsletter-popup.js | awk '{print $1}')
curl --fail --silent --show-error --retry 4 \
"https://shop.3wdistributing.com/wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js?deploy=${GITHUB_SHA}" \
--output "${RUNNER_TEMP}/newsletter-popup.js.public"
actual=$(sha256sum "${RUNNER_TEMP}/newsletter-popup.js.public" | awk '{print $1}')
if [[ "${actual}" != "${expected}" ]]; then
echo "Public cache-busted asset verification failed: expected ${expected}, got ${actual}" >&2
exit 1
fi

curl --fail --silent --show-error \
"https://shop.3wdistributing.com/wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js?ver=1.0.1" \
--output "${RUNNER_TEMP}/newsletter-popup.js.canonical"
canonical=$(sha256sum "${RUNNER_TEMP}/newsletter-popup.js.canonical" | awk '{print $1}')
if [[ "${canonical}" != "${expected}" ]]; then
echo "The canonical ?ver=1.0.1 URL is still cached. Purge that exact Cloudflare URL, then rerun the workflow." >&2
exit 1
fi

- name: Archive newsletter popup rollback evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: newsletter-popup-deploy-${{ github.run_id }}
path: |
${{ runner.temp }}/newsletter-popup.js.before
${{ runner.temp }}/newsletter-popup.js.after
${{ runner.temp }}/newsletter-popup.js.public
${{ runner.temp }}/newsletter-popup.js.canonical
if-no-files-found: warn
retention-days: 14
29 changes: 29 additions & 0 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,35 @@ jobs:
- name: Check theme PHP files
run: find wp-content/themes/3w-2025 -name '*.php' -print0 | xargs -0 -n1 php -l

newsletter-popup:
name: Newsletter popup regression
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: package-lock.json

- name: Install test dependencies
run: npm ci

- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium

- name: Check deployment scripts and asset syntax
run: |
bash -n scripts/deploy-file.sh
node --check deploy/production/3w-newsletter-popup/assets/newsletter-popup.js
bash tests/scripts/deploy-file.test.sh

- name: Run newsletter popup regression
run: npx playwright test tests/e2e/newsletter-popup.spec.js --reporter=line

npm-audit:
name: npm audit
runs-on: ubuntu-latest
Expand Down
193 changes: 193 additions & 0 deletions deploy/production/3w-newsletter-popup/assets/newsletter-popup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/* Production override for 3w-newsletter-popup/assets/newsletter-popup.js.
* Base: deployed ?ver=1.0.1 asset (sha256 f3873bf2…). Adds Escape/backdrop
* dismissal and modal focus management; existing timing and signup behavior stay intact.
*/
(function () {
'use strict';

var cfg = window.threewNewsletterPopup || {};
var key = 'threewNewsletterPopupSuppressedAt';
var dayMs = 24 * 60 * 60 * 1000;
var suppressMs = (parseInt(cfg.suppressionDays || 30, 10) || 30) * dayMs;
var showTimer = null;
var retryTimer = null;
var lastFocused = null;

function suppressed() {
var last = parseInt(localStorage.getItem(key) || '0', 10);
return last && Date.now() - last < suppressMs;
}

function suppress() {
localStorage.setItem(key, String(Date.now()));
}

function modal() {
return document.getElementById('threew-newsletter-popup');
}

function isOpen() {
var modalElement = modal();
return !!(modalElement && !modalElement.hidden);
}

function show() {
var modalElement = modal();
if (!modalElement) return;
if (hasBlockingConsentUi()) {
scheduleRetry(1200);
return;
}
lastFocused = document.activeElement;
modalElement.hidden = false;
document.documentElement.classList.add('threew-newsletter-open');
suppress();
focusFirstControl();
}

function hide(restoreFocus) {
var modalElement = modal();
if (modalElement) modalElement.hidden = true;
document.documentElement.classList.remove('threew-newsletter-open');
if (restoreFocus && lastFocused && typeof lastFocused.focus === 'function') {
try { lastFocused.focus(); } catch (error) { /* detached node */ }
}
lastFocused = null;
}

function dismiss() {
suppress();
hide(true);
}

function focusable() {
var modalElement = modal();
if (!modalElement) return [];
var selector = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
return Array.prototype.slice.call(modalElement.querySelectorAll(selector)).filter(function (element) {
return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
});
}

function focusFirstControl() {
var controls = focusable();
if (controls.length) controls[0].focus();
}

function trapTab(event) {
var modalElement = modal();
var controls = focusable();
if (!modalElement || !controls.length) return;
var first = controls[0];
var last = controls[controls.length - 1];
var active = document.activeElement;
if (event.shiftKey && (active === first || !modalElement.contains(active))) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (active === last || !modalElement.contains(active))) {
event.preventDefault();
first.focus();
}
}

function readCookie(name) {
var match = document.cookie.match(
new RegExp('(?:^|; )' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)')
);
return match ? decodeURIComponent(match[1]) : '';
}

function hasConsentCookie() {
var cookieName = cfg.consentCookie || 'threew_consent_v1';
return !!readCookie(cookieName);
}

function hasBlockingConsentUi() {
var banner = document.querySelector('[data-threew-banner]');
var modalElement = document.querySelector('[data-threew-modal]');
var bannerVisible = !!(banner && !banner.hidden);
var modalVisible = !!(modalElement && !modalElement.hidden);
return bannerVisible || modalVisible || !hasConsentCookie();
}

function scheduleRetry(delay) {
if (retryTimer) window.clearTimeout(retryTimer);
retryTimer = window.setTimeout(show, delay);
}

document.addEventListener('DOMContentLoaded', function () {
if (suppressed()) return;

var form = document.getElementById('threew-newsletter-popup-form');
var close = document.getElementById('threew-newsletter-popup-close');
var status = document.getElementById('threew-newsletter-popup-status');
var modalElement = modal();

showTimer = window.setTimeout(show, (parseInt(cfg.delaySeconds || 7, 10) || 0) * 1000);

if (close) close.addEventListener('click', dismiss);

if (modalElement) {
// Clicking the scrim (anywhere outside the dialog) dismisses the popup.
modalElement.addEventListener('click', function (event) {
if (event.target === modalElement) dismiss();
});
}

document.addEventListener('keydown', function (event) {
if (!isOpen()) return;
var keyName = event.key;
if (keyName === 'Escape' || keyName === 'Esc') {
event.preventDefault();
dismiss();
return;
}
if (keyName === 'Tab') trapTab(event);
});

document.addEventListener('threew:consent-updated', function () {
if (suppressed()) return;
if (showTimer) window.clearTimeout(showTimer);
scheduleRetry(600);
});
if (!form) return;

form.addEventListener('submit', function (event) {
event.preventDefault();

var email = form.querySelector('[name="email"]').value.trim();
var consent = form.querySelector('[name="consent"]').checked;
if (!email || !consent) {
status.textContent = 'Enter your email and accept newsletter signup.';
return;
}

var body = new URLSearchParams();
body.set('action', 'threew_np_subscribe');
body.set('nonce', cfg.nonce || '');
body.set('email', email);
body.set('consent', consent ? '1' : '0');

form.querySelector('button[type="submit"]').disabled = true;
status.textContent = 'Submitting...';

fetch(cfg.ajaxUrl, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
})
.then(function (response) { return response.json(); })
.then(function (data) {
if (!data.success) throw new Error(data.data && data.data.message ? data.data.message : 'Signup failed.');
suppress();
form.innerHTML = '<p class="threew-np-success"></p>';
form.querySelector('.threew-np-success').textContent = 'Success. Use code ' + data.data.couponCode + ' for ' + data.data.discountPercent + '% off.';
})
.catch(function (error) {
status.textContent = error.message;
form.querySelector('button[type="submit"]').disabled = false;
});
});
});
})();
8 changes: 6 additions & 2 deletions docs/deployment-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ Runs on pull requests targeting `main`:
- JavaScript lint with `npm run lint:js`
- Theme asset build with `npm run build`
- PHP syntax validation for theme PHP files
- Newsletter-popup asset syntax, deployment-script safety tests and desktop/mobile Playwright regression
- Production dependency audit with `npm audit --omit=dev --audit-level=moderate`
- GitHub dependency review, failing on moderate or higher severity
- Gitleaks secret scan
- CodeQL JavaScript/TypeScript analysis

Expand Down Expand Up @@ -47,10 +47,13 @@ The workflow performs quality gates before calling the script:
- `npm run lint:js`
- `npm run build`
- PHP syntax validation
- newsletter-popup deployment-script safety and desktop/mobile Playwright regression
- required deployment secret presence check

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`.

## Required GitHub configuration

Create a protected GitHub environment named `production`.
Expand Down Expand Up @@ -78,6 +81,7 @@ Do not add `.env`, passwords, deploy keys, or host credentials to git.
## Notes

- `scripts/deploy-theme.sh` loads `.env` for local runs only. GitHub Actions supplies the same variable names from GitHub Secrets.
- The npm audit gate omits development dependencies because the WordPress build toolchain currently reports dev-only advisories that require breaking tool upgrades; production dependency exposure is still gated, while CodeQL, Gitleaks, dependency review, linting, and builds cover PR quality/security.
- The npm audit gate omits development dependencies because the WordPress build toolchain currently reports dev-only advisories that require breaking tool upgrades; production dependency exposure is still gated, while CodeQL, Gitleaks, linting, builds and the popup regression cover PR quality/security.
- The production deploy job is attached to the `production` environment so GitHub can enforce approvals before secrets are exposed to the runner.
- `scripts/deploy-file.sh` accepts only a reviewed remote path, safe path characters and an allowlist of expected remote hashes. Use it for bounded production assets only; do not replace plugin directories with the theme mirror command.
- If SFTP key auth is required later, update `scripts/deploy-theme.sh` to support a production-specific key secret materialized into a temporary runner file, then pass `THREEW_SSH_KEY_PATH` to the script.
Loading
Loading