Compare commits

..

11 Commits

Author SHA1 Message Date
peptiderepo
6adf255dea fix(ci): P2-A injection, P2-B lint-js fallback, P2-C phpcs config detection
P2-A: move ${{ inputs.line_limit_paths }} from raw shell into env: block (PATHS)
      to eliminate template-injection surface on caller-controlled input.
P2-B: replace 'npm run lint --if-present' (always exits 0 on missing script,
      killing the node --check fallback) with explicit node -e detection;
      fallback now actually runs when no lint npm script is defined.
P2-C: both phpcbf and phpcs steps now detect which of phpcs.xml /
      phpcs.xml.dist / .phpcs.xml.dist is present and pass it to --standard;
      guard and command now agree for all three config filename variants.

Validators: actionlint 1.7.4 clean, yamllint 1.38.0 clean (both files).
Version: 1.6.1

Agent-Session: claude/reusable-ci-20260616-p2-fixes
2026-06-16 12:01:53 +00:00
peptiderepo
47acc85a0e fix(ci): graceful PHPCS skip when phpcs.xml not present
Add guard to phpcbf and phpcs strict steps: if neither phpcs.xml,
phpcs.xml.dist, nor .phpcs.xml.dist exists in the repo root, both steps
exit 0 with an info message. This prevents startup_failure on the self-test
(peptide-e2e is a Node repo with no PHPCS config) and provides clean
feedback for repos not yet configured for PHPCS.

Real PHP repos all have a phpcs.xml (prautoblogger, theme, pr-core, etc.)
so the guard is transparent for them.

Agent-Session: claude/reusable-ci-20260616
2026-06-16 11:46:12 +00:00
peptiderepo
d0f8c1d65c fix(ci): permissions model — no top-level perms in ci.yml; callers need write
GitHub Actions permission model for reusable workflows: callers must declare
at least the permission level that any job in the called workflow requests.
Since the phpcs job declares 'permissions: contents: write' (for phpcbf
commit-back), all callers must include 'permissions: contents: write' at
their workflow level.

Changes:
- ci.yml: remove top-level permissions block (was: contents: read); phpcs job
  already has job-level contents: write. Docblock now explains the caller
  requirement clearly.
- ci-self-test.yml: add 'permissions: contents: write' at workflow level.
- README.md: adoption snippet now includes the required permissions block.
- Remove diagnostic bisect/minimal test workflows (used to identify the root
  cause of startup_failure; finding: job-level write in reusable workflow
  requires caller to declare write at workflow level).

Confirmed: ci-bisect-caller with permissions: contents: write = SUCCESS.
Confirmed: actionlint 1.7.4 + yamllint 1.38.0 both clean.

Agent-Session: claude/reusable-ci-20260616
2026-06-16 11:44:24 +00:00
peptiderepo
fa1df001ea test: bisect v3 — caller has write, called has only job-level write
Agent-Session: claude/reusable-ci-20260616
2026-06-16 11:42:38 +00:00
peptiderepo
d0f4844af8 test: bisect v2 — no top-level perms in called, job-level write only
Agent-Session: claude/reusable-ci-20260616
2026-06-16 11:41:35 +00:00
peptiderepo
3fb920e343 fix(ci): move permissions: contents: write to phpcs job level
Workflow-level permissions: write in a reusable workflow causes startup_failure
when calling workflow has default (read-only) permissions. Move write scope to
the phpcs job level only — the one job that actually needs it (phpcbf commit-back).
All other jobs run with the inherited read scope.

Self-test caller (ci-self-test.yml) confirmed clean with no top-level write
permission declared — job-level override in the reusable workflow is sufficient.

Confirmed via bisect: ci-bisect.yml (workflow-level write) startup_failed;
ci-minimal.yml (no write needed) succeeded; ci-bisect-caller with
permissions: write added succeeded. Job-level approach is the correct pattern.

Agent-Session: claude/reusable-ci-20260616
2026-06-16 11:40:29 +00:00
peptiderepo
49daffafb6 test: bisect — add permissions: contents: write to caller
Agent-Session: claude/reusable-ci-20260616
2026-06-16 11:38:16 +00:00
peptiderepo
be686bb3e0 test: bisect ci.yml startup_failure (matrix+if+permissions)
Agent-Session: claude/reusable-ci-20260616
2026-06-16 11:37:21 +00:00
peptiderepo
84e1bfebfd test: minimal reusable workflow diagnostic
Adds ci-minimal.yml + ci-minimal-test.yml to diagnose the startup_failure
on the self-test reusable workflow call. To be removed after diagnosis.

Agent-Session: claude/reusable-ci-20260616
2026-06-16 11:36:29 +00:00
peptiderepo
e3be036d19 fix(ci-self-test): use relative path for same-repo reusable workflow call
GitHub requires ./.github/workflows/ci.yml (not @branch-ref) for same-repo
reusable workflow calls from a PR branch. The @claude/reusable-ci-20260616
ref caused startup_failure because GitHub can't resolve branch-ref reusable
workflows that aren't on main yet.

Agent-Session: claude/reusable-ci-20260616
2026-06-16 11:32:03 +00:00
peptiderepo
b5e54a4bdd feat: reusable ci.yml — estate CI standard (Phase 0, step 1)
Add .github/workflows/ci.yml as a reusable workflow_call entrypoint for
all 7 estate repos. Consistent job names (lint-php, phpcs, file-size,
phpunit, lint-js) so deploy-app.yml can needs: them uniformly.

Inputs: php_versions (JSON matrix), tests (stubs|wp-framework|brain-monkey|none),
enforce_300, line_limit_paths, has_js, phpcs_autofix.

phpcbf auto-commit: on pull_request, phpcbf runs and commits any auto-fixable
PHPCS violations back to the branch via GITHUB_TOKEN before strict phpcs.
On push events the commit step is skipped to prevent loops.

wp-framework PHPUnit path handles all three known WP CI gotchas:
(1) install svn (ubuntu-latest lacks it);
(2) skip-db-create=true (MySQL service pre-creates wp_tests via MYSQL_DATABASE);
(3) WP_CORE_DIR trailing slash (ABSPATH concatenation guard).

Also adds:
- .github/workflows/ci-self-test.yml: self-test caller (tests: none) so the
  reusable workflow is exercised on this PR.
- README.md: adoption snippet + full input reference for caller repos.
- CHANGELOG.md: v1.6.0 entry.

Agent-Session: claude/reusable-ci-20260616
2026-06-16 11:30:33 +00:00
12 changed files with 9 additions and 706 deletions

View File

@@ -5,7 +5,7 @@
# #
# jobs: # jobs:
# ci: # ci:
# uses: peptide-repo/peptide-e2e/.github/workflows/ci.yml@main # uses: peptiderepo/peptide-e2e/.github/workflows/ci.yml@main
# with: # with:
# tests: stubs # or wp-framework | brain-monkey | none # tests: stubs # or wp-framework | brain-monkey | none
# has_js: false # has_js: false

View File

@@ -35,7 +35,7 @@ permissions:
jobs: jobs:
deploy-staging: deploy-staging:
name: Deploy to Staging name: Deploy to Staging
runs-on: [self-hosted, peptide-vps] runs-on: ubuntu-latest
environment: staging environment: staging
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -72,7 +72,7 @@ jobs:
name: Smoke tests (staging) name: Smoke tests (staging)
needs: deploy-staging needs: deploy-staging
if: ${{ inputs.run_smoke }} if: ${{ inputs.run_smoke }}
uses: peptide-repo/peptide-e2e/.github/workflows/smoke.yml@main uses: peptiderepo/peptide-e2e/.github/workflows/smoke.yml@main
secrets: secrets:
STAGING_WP_ADMIN_USER: ${{ secrets.STAGING_WP_ADMIN_USER }} STAGING_WP_ADMIN_USER: ${{ secrets.STAGING_WP_ADMIN_USER }}
STAGING_WP_ADMIN_PASSWORD: ${{ secrets.STAGING_WP_ADMIN_PASSWORD }} STAGING_WP_ADMIN_PASSWORD: ${{ secrets.STAGING_WP_ADMIN_PASSWORD }}
@@ -83,7 +83,7 @@ jobs:
# skipped smoke (run_smoke: false) still allows prod via the if-condition. # skipped smoke (run_smoke: false) still allows prod via the if-condition.
needs: [deploy-staging, smoke] needs: [deploy-staging, smoke]
if: ${{ !cancelled() && needs.deploy-staging.result == 'success' && needs.smoke.result != 'failure' }} if: ${{ !cancelled() && needs.deploy-staging.result == 'success' && needs.smoke.result != 'failure' }}
runs-on: [self-hosted, peptide-vps] runs-on: ubuntu-latest
environment: production environment: production
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View File

@@ -22,7 +22,7 @@ jobs:
- uses: actions/github-script@v7 - uses: actions/github-script@v7
with: with:
script: | script: |
const owner = 'peptide-repo'; const owner = 'peptiderepo';
const repos = [ const repos = [
'prautoblogger','peptide-repo-core','peptide-search-ai', 'prautoblogger','peptide-repo-core','peptide-search-ai',
'peptide-news-plugin','peptide-reconstitution-calculator','peptide-starter-theme' 'peptide-news-plugin','peptide-reconstitution-calculator','peptide-starter-theme'

View File

@@ -32,14 +32,14 @@ on:
jobs: jobs:
smoke: smoke:
name: Playwright smoke suite name: Playwright smoke suite
runs-on: [self-hosted, peptide-vps] runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- name: Checkout peptide-e2e (explicit — workflow_call defaults to caller repo) - name: Checkout peptide-e2e (explicit — workflow_call defaults to caller repo)
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
repository: peptide-repo/peptide-e2e repository: peptiderepo/peptide-e2e
ref: main ref: main
- name: Set up Node.js - name: Set up Node.js
@@ -53,8 +53,7 @@ jobs:
run: npm ci run: npm ci
- name: Install Playwright browsers - name: Install Playwright browsers
# OS deps pre-installed on the self-hosted runner host; --with-deps needs sudo which the sandbox blocks run: npx playwright install chromium --with-deps
run: npx playwright install chromium
- name: Run smoke tests - name: Run smoke tests
env: env:

View File

@@ -1,59 +1,5 @@
# Changelog # Changelog
## [1.8.0] - 2026-06-23
### Added
- **`infra/vps/` PRAB generation orchestrator**: systemd timer + service + bash
script that SSHes from the Coolify KVM8 VPS to Hostinger and drives
`wp prautoblogger generate --sync` in a held-open SSH session. Sidesteps
Hostinger's ~10-min background-PHP-process kill that was blocking full
generation runs. Components:
- `orchestrate-generation.sh`: main script with DRY_RUN mode, structured
logging, reaper pre-sweep, failure email alert, and shellcheck-clean quoting.
- `prab-generation.service` / `prab-generation.timer`: systemd oneshot service
(2h timeout, no auto-restart) + daily 06:00 UTC timer with 5-min jitter.
- `install-orchestrator.sh`: one-time setup script; installs units but does NOT
enable the timer (requires explicit CEO greenlight post-QA).
- Security: `StrictHostKeyChecking=accept-new` with pre-seeded known_hosts for
the Hostinger IP; no hardcoded secrets; SSH key referenced by env-var path.
## [1.7.2] - 2026-06-17
### Fixed
- **CI**: Drop `--with-deps` from `npx playwright install chromium` in `smoke.yml`.
The `peptide-vps` self-hosted runner has OS-level Playwright deps pre-installed by
root; `--with-deps` invokes `apt-get` which requires `sudo`, blocked by the runner's
`NoNewPrivileges=true` systemd hardening. Binary-only install works without
elevated privileges.## [1.7.1] - 2026-06-17
### Changed
- **CI/CD**: Switch `deploy-staging`, `deploy-production`, and `smoke` jobs from
`ubuntu-latest` (GitHub-hosted) to `[self-hosted, peptide-vps]` (org runner on
KVM8 VPS). This routes all Hostinger SSH/rsync traffic through our stable VPS IP,
fixing the Azure-region throttle that caused intermittent deploy failures.
Callers' CI/validate jobs are unaffected (remain on `ubuntu-latest`).
## [1.7.0] - 2026-06-17
### Added
- **Peptide News admin smoke (07)** (`07-peptide-news-admin.spec.ts`).
Covers the Peptide News Plugin admin surfaces: dashboard (HTTP 200, no fatal,
h1 "Peptide News Analytics" heading, `.pn-summary-cards`, `.pn-date-filter`),
settings (form present), articles, and LLM costs sub-pages. Skip-if-absent guard
on all tests using the standard 05/06 merge-early binding pattern. Slugs sourced
from `admin/class-pn-admin-menu.php`.
- **PR Vision admin smoke (08)** (`08-pr-vision-admin.spec.ts`).
Covers the PR Vision plugin admin surfaces: dashboard (HTTP 200, no fatal,
h1 "PR Vision" visible), Settings page (API-key-manager card `#prv-key-card`
present, status badge `#prv-key-source-badge` visible), write-only security
invariant (`#prv_api_key` value="" + full HTML must not contain "sk-or-"),
Costs sub-page (`pr-vision-costs`), and Call Log sub-page (`pr-vision-calls`).
Skip-if-absent at two levels: plugin absent (loud annotation + skip), and
`#prv-key-card` absent (Settings v0.2.0 not yet deployed on environment).
Selectors sourced from `class-prv-admin-page.php`, `class-prv-key-manager-renderer.php`.
All notable changes to the peptide-e2e smoke suite will be documented here. All notable changes to the peptide-e2e smoke suite will be documented here.
## [1.6.0] - 2026-06-16 ## [1.6.0] - 2026-06-16

View File

@@ -1,79 +0,0 @@
#!/usr/bin/env bash
# install-orchestrator.sh — One-time setup for the PRAB generation orchestrator on the VPS.
#
# Installs:
# 1. orchestrate-generation.sh -> /opt/prab-orchestrator/ (from this repo)
# 2. prab-generation.service -> /etc/systemd/system/
# 3. prab-generation.timer -> /etc/systemd/system/
#
# Does NOT enable or start the timer — that is an explicit post-QA cutover step.
# Does NOT write secrets — the SSH key at /root/.ssh/hostinger_prod_ed25519 must
# already be present (placed manually by the CTO from .env.credentials).
#
# Run as root on the KVM8 VPS (76.13.220.15):
# bash infra/vps/install-orchestrator.sh
#
# After install, validate with DRY_RUN=1 before enabling the timer:
# DRY_RUN=1 /opt/prab-orchestrator/orchestrate-generation.sh
# systemctl enable --now prab-generation.timer # only after CEO greenlight
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_DIR="/opt/prab-orchestrator"
SYSTEMD_DIR="/etc/systemd/system"
ORCHESTRATE_SH="${SCRIPT_DIR}/orchestrate-generation.sh"
SERVICE_FILE="${SCRIPT_DIR}/prab-generation.service"
TIMER_FILE="${SCRIPT_DIR}/prab-generation.timer"
# Must be root.
if [[ "$(id -u)" -ne 0 ]]; then
echo "ERROR: install-orchestrator.sh must be run as root." >&2
exit 1
fi
# Validate source files exist.
for f in "${ORCHESTRATE_SH}" "${SERVICE_FILE}" "${TIMER_FILE}"; do
if [[ ! -f "$f" ]]; then
echo "ERROR: source file not found: $f" >&2
exit 1
fi
done
echo "Installing PRAB orchestrator to ${INSTALL_DIR}..."
mkdir -p "${INSTALL_DIR}"
install -m 0750 "${ORCHESTRATE_SH}" "${INSTALL_DIR}/orchestrate-generation.sh"
echo " OK: ${INSTALL_DIR}/orchestrate-generation.sh"
# Pre-seed known_hosts with Hostinger's host key so ssh(1) can verify
# the server identity without StrictHostKeyChecking=no.
# ssh-keyscan output is pinned here; re-run if Hostinger rotates its key.
KNOWN_HOSTS_FILE="${INSTALL_DIR}/known_hosts"
if [[ ! -f "${KNOWN_HOSTS_FILE}" ]]; then
echo "Pre-seeding known_hosts from Hostinger (${SSH_HOST:-145.223.107.228}:${SSH_PORT:-65002})..."
ssh-keyscan -p "${SSH_PORT:-65002}" "${SSH_HOST:-145.223.107.228}" \
> "${KNOWN_HOSTS_FILE}" 2>/dev/null \
&& chmod 0600 "${KNOWN_HOSTS_FILE}" \
&& echo " OK: ${KNOWN_HOSTS_FILE}" \
|| echo " WARN: ssh-keyscan failed — known_hosts not seeded; first SSH will add key via accept-new"
else
echo " OK: ${KNOWN_HOSTS_FILE} (already exists, not overwritten)"
fi
echo "Installing systemd units..."
install -m 0644 "${SERVICE_FILE}" "${SYSTEMD_DIR}/prab-generation.service"
install -m 0644 "${TIMER_FILE}" "${SYSTEMD_DIR}/prab-generation.timer"
echo " OK: ${SYSTEMD_DIR}/prab-generation.service"
echo " OK: ${SYSTEMD_DIR}/prab-generation.timer"
systemctl daemon-reload
echo " OK: systemctl daemon-reload"
echo ""
echo "Install complete. Timer is installed but NOT enabled."
echo "Next steps (post-QA + CEO greenlight only):"
echo " 1. Ensure SSH key exists: ls -la /root/.ssh/hostinger_prod_ed25519"
echo " 2. Validate with dry run: DRY_RUN=1 /opt/prab-orchestrator/orchestrate-generation.sh"
echo " 3. Run first real invocation: /opt/prab-orchestrator/orchestrate-generation.sh"
echo " 4. Enable timer: systemctl enable --now prab-generation.timer"
echo " 5. Confirm next run: systemctl list-timers prab-generation.timer"

View File

@@ -1,158 +0,0 @@
#!/usr/bin/env bash
# orchestrate-generation.sh — VPS orchestrator for PRAutoBlogger article generation
#
# Runs as a systemd oneshot service (prab-generation.service) on the Coolify KVM8 VPS.
# SSHes to Hostinger and drives wp prautoblogger generate --sync in a held-open SSH
# session. SSH-attached processes survive Hostinger's ~10-min background-PHP-process kill.
#
# Usage:
# DRY_RUN=1 ./orchestrate-generation.sh # echo commands, no generation
# ./orchestrate-generation.sh # real run
#
# Alerting: on failure, sends email to cto@peptiderepo.com via system mail.
# Logs: structured log lines to stdout (captured by systemd journal).
# Lock: the plugin's Generation_Lock (DB mutex) prevents double-drive.
#
# Environment variables:
# DRY_RUN Set to "1" to echo SSH/WP-CLI commands without executing. Default: 0.
# LOGFILE Path for persistent log file. Default: /var/log/prab-generation.log.
# ALERT_EMAIL Failure alert recipient. Default: cto@peptiderepo.com.
# SSH_KEY Path to Hostinger SSH private key. Default: /root/.ssh/hostinger_prod_ed25519.
# SSH_HOST Hostinger SSH host. Default: 145.223.107.228.
# SSH_PORT Hostinger SSH port. Default: 65002.
# SSH_USER Hostinger SSH user. Default: u117248512.
# WP_PATH WordPress docroot on Hostinger. Default: /home/u117248512/domains/peptiderepo.com/public_html.
set -euo pipefail
# ---------------------------------------------------------------------------
# Configuration (override via environment; no hardcoded secrets)
# ---------------------------------------------------------------------------
DRY_RUN="${DRY_RUN:-0}"
LOGFILE="${LOGFILE:-/var/log/prab-generation.log}"
ALERT_EMAIL="${ALERT_EMAIL:-cto@peptiderepo.com}"
SSH_KEY="${SSH_KEY:-/root/.ssh/hostinger_prod_ed25519}"
SSH_HOST="${SSH_HOST:-145.223.107.228}"
SSH_PORT="${SSH_PORT:-65002}"
SSH_USER="${SSH_USER:-u117248512}"
WP_PATH="${WP_PATH:-/home/u117248512/domains/peptiderepo.com/public_html}"
# Known-hosts file pre-seeded by install-orchestrator.sh with Hostinger's host key.
# Pins the server identity; StrictHostKeyChecking=accept-new adds on first connect.
KNOWN_HOSTS="${KNOWN_HOSTS:-/opt/prab-orchestrator/known_hosts}"
# ---------------------------------------------------------------------------
# Logging helpers
# ---------------------------------------------------------------------------
PID="$$"
TS() { date -u '+%Y-%m-%dT%H:%M:%SZ'; }
log() {
local msg="prab-generation[${PID}]: $*"
echo "$msg"
echo "$(TS) $msg" >> "${LOGFILE}" 2>/dev/null || true
}
die() {
log "FATAL: $*"
exit 1
}
# ---------------------------------------------------------------------------
# Validate required key before doing anything
# ---------------------------------------------------------------------------
if [[ "${DRY_RUN}" != "1" ]] && [[ ! -f "${SSH_KEY}" ]]; then
die "SSH key not found at ${SSH_KEY}. Run install-orchestrator.sh first."
fi
# ---------------------------------------------------------------------------
# DRY_RUN mode: echo commands and exit
# ---------------------------------------------------------------------------
if [[ "${DRY_RUN}" == "1" ]]; then
log "DRY_RUN=1 — echoing commands only, no generation"
log "WOULD RUN: ssh -i ${SSH_KEY} -p ${SSH_PORT} -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15 ${SSH_USER}@${SSH_HOST} \\"
log " 'wp prautoblogger generate --sync --path=${WP_PATH} 2>&1'"
log "DRY_RUN complete"
exit 0
fi
# ---------------------------------------------------------------------------
# Run start
# ---------------------------------------------------------------------------
START_TS="$(TS)"
log "START ts=${START_TS}"
# ---------------------------------------------------------------------------
# Step 1: Reaper sweep — clear any stuck runs before starting.
# Belt-and-suspenders: the plugin's daily reaper cron handles this too, but
# running it explicitly here ensures a clean state before each generation.
# ---------------------------------------------------------------------------
log "REAPER_SWEEP: invoking Run_Reaper::reap()"
REAP_CMD="wp eval 'echo json_encode(PRAutoBlogger_Run_Reaper::reap());' --path=${WP_PATH} 2>&1"
REAP_OUT=""
# shellcheck disable=SC2029
REAP_OUT="$(ssh -i "${SSH_KEY}" \
-p "${SSH_PORT}" \
-o StrictHostKeyChecking=accept-new \
-o UserKnownHostsFile="${KNOWN_HOSTS}" \
-o ConnectTimeout=15 \
-o ServerAliveInterval=60 \
-o ServerAliveCountMax=30 \
"${SSH_USER}@${SSH_HOST}" \
"${REAP_CMD}" \
2>&1)" || true
log "REAPER_RESULT: ${REAP_OUT}"
# ---------------------------------------------------------------------------
# Step 2: Run wp prautoblogger generate --sync
# ---------------------------------------------------------------------------
log "SSH_CONNECT: ${SSH_USER}@${SSH_HOST}:${SSH_PORT} key=${SSH_KEY}"
WP_CMD="wp prautoblogger generate --sync --path=${WP_PATH} 2>&1"
RESULT_OUTPUT=""
EXIT_CODE=0
# shellcheck disable=SC2029
RESULT_OUTPUT="$(ssh -i "${SSH_KEY}" \
-p "${SSH_PORT}" \
-o StrictHostKeyChecking=accept-new \
-o UserKnownHostsFile="${KNOWN_HOSTS}" \
-o ConnectTimeout=15 \
-o ServerAliveInterval=60 \
-o ServerAliveCountMax=30 \
"${SSH_USER}@${SSH_HOST}" \
"${WP_CMD}" \
2>&1)" || EXIT_CODE=$?
# Extract the last JSON summary line (the one from WP_CLI::log(json_encode(...)))
LAST_JSON="$(echo "${RESULT_OUTPUT}" | grep -E '^\{' | tail -1 || true)"
LAST_LINE="$(echo "${RESULT_OUTPUT}" | tail -1)"
log "SSH_RESULT exit=${EXIT_CODE} json=${LAST_JSON:-none}"
# ---------------------------------------------------------------------------
# Step 3: Evaluate result
# ---------------------------------------------------------------------------
END_TS="$(TS)"
if [[ ${EXIT_CODE} -eq 0 ]]; then
log "END result=success ts=${END_TS}"
else
log "END result=failure exit=${EXIT_CODE} last_line=${LAST_LINE} ts=${END_TS}"
# Send failure alert via email (system mail; no external deps required).
# cto@peptiderepo.com forwards to peptiderepo@gmail.com via Cloudflare Email Routing.
ALERT_BODY="PRAutoBlogger generation FAILED on $(hostname) at ${END_TS}.
Exit code: ${EXIT_CODE}
Last output: ${LAST_LINE}
Full log: ${LOGFILE}
SSH: ${SSH_USER}@${SSH_HOST}:${SSH_PORT}
WP path: ${WP_PATH}"
echo "${ALERT_BODY}" \
| mail -s "PRAB generation FAILED @ ${END_TS}" "${ALERT_EMAIL}" 2>/dev/null \
|| log "ALERT_FAILED: could not send email to ${ALERT_EMAIL} (mail not configured?)"
exit "${EXIT_CODE}"
fi

View File

@@ -1,22 +0,0 @@
[Unit]
Description=PRAutoBlogger article generation (VPS orchestrator)
Documentation=https://github.com/peptide-repo/peptide-e2e/tree/main/infra/vps/
After=network.target
# Do not start if the timer fires while another instance is still running.
# Type=oneshot + TimeoutStartSec enforce this at the systemd level.
[Service]
Type=oneshot
# root owns /root/.ssh/hostinger_prod_ed25519 and /opt/prab-orchestrator/known_hosts;
# a dedicated service user would need its own keypair + sudo-less SSH access.
# Acceptable on this dedicated lab VPS (not shared); revisit if VPS is multi-tenant.
User=root
ExecStart=/opt/prab-orchestrator/orchestrate-generation.sh
StandardOutput=journal
StandardError=journal
SyslogIdentifier=prab-generation
# Allow up to 2 hours for a full generation run (orchestrate + draft LLM calls).
# The longest observed single LLM call was 1925s (~32 min); normal runs 2-5 min.
TimeoutStartSec=7200
# Do not restart on failure — the timer will retry the next day.
Restart=no

View File

@@ -1,19 +0,0 @@
[Unit]
Description=Daily PRAutoBlogger generation at 06:00 UTC
Documentation=https://github.com/peptide-repo/peptide-e2e/tree/main/infra/vps/
Requires=prab-generation.service
[Timer]
# Fire daily at 06:00 UTC — before Hostinger's daily wp-cron window.
OnCalendar=*-*-* 06:00:00 UTC
# Persistent=true: if the VPS was down at 06:00 UTC, fire immediately on next
# boot so at most one day's generation is missed (not deferred indefinitely).
Persistent=true
# Up to 5 min random jitter to avoid thundering-herd if multiple VPS instances
# are ever added. Harmless with a single instance.
RandomizedDelaySec=300
# Accuracy within 1 minute (default is 1 minute; explicit for clarity).
AccuracySec=60
[Install]
WantedBy=timers.target

View File

@@ -1,6 +1,6 @@
{ {
"name": "peptide-e2e", "name": "peptide-e2e",
"version": "1.8.0", "version": "1.5.0",
"description": "Playwright smoke tests for peptiderepo.com (staging + production)", "description": "Playwright smoke tests for peptiderepo.com (staging + production)",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@@ -1,144 +0,0 @@
/**
* 07-peptide-news-admin.spec.ts
*
* What: Smoke tests for the Peptide News Plugin admin surfaces.
* Verifies the dashboard, settings, articles, and cost sub-pages
* load without PHP fatals and checks expected UI elements.
* Guards against activation failures, missing menu registration, or
* PHP fatals introduced by a bad deploy.
*
* Who triggers it: Playwright chromium project (post-auth setup).
* Dependencies: wp-admin authenticated session (from global.setup.ts).
*
* SKIP-IF-ABSENT: When the Peptide News Plugin is not installed/activated on
* this environment, each test detects the absent menu page and skips with a
* loud annotation — consistent with the 05/06 merge-early binding pattern.
*
* Admin menu slugs (sourced from admin/class-pn-admin-menu.php):
* Dashboard -> admin.php?page=peptide-news-dashboard
* Settings -> admin.php?page=peptide-news-settings
* Articles -> admin.php?page=peptide-news-articles
* LLM Costs -> admin.php?page=peptide-news-costs
*
* Selectors sourced from admin/partials/dashboard.php:
* h1 "Peptide News Analytics" - page heading (text match)
* .pn-summary-cards - summary card row
* .pn-date-filter - date range filter form
*
* @see peptiderepo/peptide-news-plugin admin/class-pn-admin-menu.php
* @see peptiderepo/peptide-news-plugin admin/partials/dashboard.php
*/
import { test, expect } from '@playwright/test';
const ADMIN = '/wp-admin';
/**
* Detect the "page not registered" condition WordPress returns when a plugin
* menu slug is unknown. Returns true when the plugin is absent.
*
* WordPress either:
* (a) Shows "You do not have sufficient permissions to access this page."
* (b) Shows a notice that the page cannot be found
* (c) Redirects back to /wp-admin/ stripping the ?page= param
*/
function isPluginAbsent(body: string, url: string): boolean {
return (
body.includes('Invalid plugin page') ||
body.includes('do not have sufficient permissions') ||
body.includes('The page you are looking for') ||
( url.includes('/wp-admin/') && ! url.includes('page=peptide-news') )
);
}
/** Push a loud skip annotation and call test.skip(). */
function skipMissing(): void {
test.info().annotations.push({
type: 'skip',
description:
'[peptide-news smoke] Peptide News Plugin is not installed or activated ' +
'on this environment. Skipping per the merge-early binding.',
});
test.skip();
}
test.describe('WP Admin - Peptide News Plugin dashboard', () => {
test('dashboard page loads (HTTP 200, no PHP fatal)', async ({ page }) => {
const res = await page.goto(`${ADMIN}/admin.php?page=peptide-news-dashboard`);
expect(res?.status()).toBe(200);
await expect(page.locator('#wpbody')).toBeVisible();
const body = (await page.textContent('body')) ?? '';
expect(body).not.toContain('Fatal error');
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
});
test('dashboard renders analytics heading, summary cards, and date filter', async ({ page }) => {
await page.goto(`${ADMIN}/admin.php?page=peptide-news-dashboard`);
await expect(page.locator('#wpbody')).toBeVisible();
const body = (await page.textContent('body')) ?? '';
expect(body).not.toContain('Fatal error');
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
// Heading from admin/partials/dashboard.php: <h1>Peptide News Analytics</h1>
await expect(
page.locator('h1').filter({ hasText: 'Peptide News Analytics' })
).toBeVisible({ timeout: 10_000 });
// Summary card row: <div class="pn-summary-cards">
await expect(page.locator('.pn-summary-cards')).toBeVisible({ timeout: 10_000 });
// Date range filter form: <form class="pn-date-filter">
await expect(page.locator('.pn-date-filter')).toBeVisible({ timeout: 10_000 });
});
});
test.describe('WP Admin - Peptide News Plugin settings page', () => {
test('settings page loads (HTTP 200, no fatal, settings form present)', async ({ page }) => {
const res = await page.goto(`${ADMIN}/admin.php?page=peptide-news-settings`);
expect(res?.status()).toBe(200);
await expect(page.locator('#wpbody')).toBeVisible();
const body = (await page.textContent('body')) ?? '';
expect(body).not.toContain('Fatal error');
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
// Every settings page rendered by class-pn-admin-settings-page.php contains a form
await expect(page.locator('form').first()).toBeVisible({ timeout: 10_000 });
});
});
test.describe('WP Admin - Peptide News Plugin articles page', () => {
test('articles page loads without fatal error', async ({ page }) => {
const res = await page.goto(`${ADMIN}/admin.php?page=peptide-news-articles`);
expect(res?.status()).toBe(200);
await expect(page.locator('#wpbody')).toBeVisible();
const body = (await page.textContent('body')) ?? '';
expect(body).not.toContain('Fatal error');
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
expect(body).not.toContain('Invalid plugin page');
});
});
test.describe('WP Admin - Peptide News Plugin LLM costs page', () => {
test('costs page loads without fatal error', async ({ page }) => {
const res = await page.goto(`${ADMIN}/admin.php?page=peptide-news-costs`);
expect(res?.status()).toBe(200);
await expect(page.locator('#wpbody')).toBeVisible();
const body = (await page.textContent('body')) ?? '';
expect(body).not.toContain('Fatal error');
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
expect(body).not.toContain('Invalid plugin page');
});
});

View File

@@ -1,220 +0,0 @@
/**
* 08-pr-vision-admin.spec.ts
*
* What: Smoke tests for the PR Vision plugin admin surfaces.
* Covers the dashboard, Settings (API-key-manager card + write-only
* invariant), Costs, and Call Log sub-pages. Guards against PHP fatals,
* missing menu registration, and key leakage regressions.
*
* Who triggers it: Playwright chromium project (post-auth setup).
* Dependencies: wp-admin authenticated session (from global.setup.ts).
*
* SKIP-IF-ABSENT: When PR Vision is not installed/activated on this
* environment, each test detects the absent page and skips with a loud
* annotation - consistent with the 05/06 merge-early binding pattern.
*
* Admin menu slugs (sourced from includes/core/class-prv-admin-page.php
* and includes/core/class-prv-settings-page.php,
* includes/core/class-prv-costs-page.php,
* includes/core/class-prv-call-log-page.php):
* Dashboard -> admin.php?page=pr-vision (MENU_SLUG = 'pr-vision')
* Settings -> admin.php?page=pr-vision-settings (MENU_SLUG = 'pr-vision-settings')
* Costs -> admin.php?page=pr-vision-costs (MENU_SLUG = 'pr-vision-costs')
* Call Log -> admin.php?page=pr-vision-calls (MENU_SLUG = 'pr-vision-calls')
*
* Selectors sourced from includes/core/class-prv-admin-page.php:
* h1 "PR Vision - AI Visibility" - dashboard page heading
*
* Selectors sourced from includes/core/class-prv-key-manager-renderer.php:
* #prv-key-card - the API-key-manager card container
* #prv-key-source-badge - key source status indicator span
* #prv_api_key - password input (must render empty - write-only invariant)
*
* Write-only invariant (security):
* The stored OpenRouter key must NEVER appear in page source.
* #prv_api_key renders with value="" on every page load regardless of
* whether a key is stored. This spec asserts the empty value and asserts
* that no structurally valid key token (regex /sk-or-v1-[A-Za-z0-9]{20,}/)
* appears in the full body HTML. A regex is used instead of a naive
* toContain('sk-or-') to avoid a false-positive on the password input's
* placeholder="sk-or-…" attribute (the ellipsis is a format hint, not a
* token body, and won't match the regex).
*
* @see peptiderepo/pr-vision includes/core/class-prv-admin-page.php
* @see peptiderepo/pr-vision includes/core/class-prv-settings-page.php
* @see peptiderepo/pr-vision includes/core/class-prv-key-manager-renderer.php
*/
import { test, expect } from '@playwright/test';
const ADMIN = '/wp-admin';
/**
* Detect the "page not registered" condition WordPress returns when a plugin
* menu slug is unknown. Returns true when the plugin is absent.
*/
function isPluginAbsent(body: string, url: string): boolean {
return (
body.includes('Invalid plugin page') ||
body.includes('do not have sufficient permissions') ||
body.includes('The page you are looking for') ||
( url.includes('/wp-admin/') && ! url.includes('page=pr-vision') )
);
}
/** Push a loud skip annotation and call test.skip(). */
function skipMissing(): void {
test.info().annotations.push({
type: 'skip',
description:
'[pr-vision smoke] PR Vision plugin is not installed or activated ' +
'on this environment. Skipping per the merge-early binding.',
});
test.skip();
}
test.describe('WP Admin - PR Vision dashboard', () => {
test('dashboard page loads (HTTP 200, no PHP fatal)', async ({ page }) => {
const res = await page.goto(`${ADMIN}/admin.php?page=pr-vision`);
expect(res?.status()).toBe(200);
await expect(page.locator('#wpbody')).toBeVisible();
const body = (await page.textContent('body')) ?? '';
expect(body).not.toContain('Fatal error');
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
// Heading from class-prv-admin-page.php render_page():
// echo '<h1>' . esc_html__( 'PR Vision - AI Visibility', 'pr-vision' ) . '</h1>';
await expect(
page.locator('h1').filter({ hasText: 'PR Vision' })
).toBeVisible({ timeout: 10_000 });
});
});
test.describe('WP Admin - PR Vision Settings page (API-key-manager)', () => {
test('settings page loads (HTTP 200, no PHP fatal)', async ({ page }) => {
const res = await page.goto(`${ADMIN}/admin.php?page=pr-vision-settings`);
expect(res?.status()).toBe(200);
await expect(page.locator('#wpbody')).toBeVisible();
const body = (await page.textContent('body')) ?? '';
expect(body).not.toContain('Fatal error');
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
});
test('API-key-manager card is present and status indicator visible', async ({ page }) => {
await page.goto(`${ADMIN}/admin.php?page=pr-vision-settings`);
await expect(page.locator('#wpbody')).toBeVisible();
const body = (await page.textContent('body')) ?? '';
expect(body).not.toContain('Fatal error');
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
// ── SKIP-IF-ABSENT guard: Settings page may not have key-card if Settings
// subpage (v0.2.0) is not yet deployed on this environment.
const keyCard = page.locator('#prv-key-card');
const cardPresent = await keyCard.count() > 0;
if ( ! cardPresent ) {
test.info().annotations.push({
type: 'skip',
description:
'[pr-vision smoke] #prv-key-card absent: PR Vision Settings page ' +
'(v0.2.0 admin) is not deployed on this environment. Skipping key-manager assertions.',
});
test.skip();
return;
}
// API-key-manager card container from class-prv-key-manager-renderer.php:
// <div class="prv-card" id="prv-key-card">
await expect(keyCard).toBeVisible({ timeout: 10_000 });
// Key source status indicator: <span ... id="prv-key-source-badge">
// Shows one of: "Set via wp-config", "Set via admin", "Not set"
await expect(page.locator('#prv-key-source-badge')).toBeVisible({ timeout: 10_000 });
});
test('password input is present and renders EMPTY (write-only invariant)', async ({ page }) => {
await page.goto(`${ADMIN}/admin.php?page=pr-vision-settings`);
await expect(page.locator('#wpbody')).toBeVisible();
const body = (await page.textContent('body')) ?? '';
expect(body).not.toContain('Fatal error');
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
const keyCard = page.locator('#prv-key-card');
const cardPresent = await keyCard.count() > 0;
if ( ! cardPresent ) {
test.info().annotations.push({
type: 'skip',
description:
'[pr-vision smoke] #prv-key-card absent: skipping write-only invariant check.',
});
test.skip();
return;
}
// Password input from class-prv-key-manager-renderer.php render_key_form():
// <input type="password" id="prv_api_key" name="prv_api_key" ... value="" ...>
// The renderer explicitly passes value="" -- the stored key is NEVER output.
const keyInput = page.locator('#prv_api_key');
// When the key source is SOURCE_CONSTANT the input is disabled but still rendered.
// In all cases it must be present in the DOM.
await expect(keyInput).toBeAttached({ timeout: 10_000 });
// Write-only invariant: value attribute must be empty string, not the stored key.
const inputValue = await keyInput.getAttribute('value');
expect(inputValue ?? '').toBe('');
// Security check: a *real* OpenRouter key (format "sk-or-v1-<20+ alnum chars>")
// must not appear anywhere in the page HTML -- guards against accidental
// server-side leakage.
//
// NOTE: We use a regex rather than the naive toContain('sk-or-') to avoid
// a false-positive: the password input carries placeholder="sk-or-…"
// (an ellipsis format-hint, not a token body) which contains the literal
// string "sk-or-" but is NOT a leaked key. A regex that requires the
// "v1-" segment and at least 20 alphanumeric characters only matches a
// structurally valid key, so the placeholder never trips it.
const html = await page.content();
expect(html).not.toMatch(/sk-or-v1-[A-Za-z0-9]{20,}/);
});
});
test.describe('WP Admin - PR Vision Costs page', () => {
test('costs page loads without fatal error', async ({ page }) => {
const res = await page.goto(`${ADMIN}/admin.php?page=pr-vision-costs`);
expect(res?.status()).toBe(200);
await expect(page.locator('#wpbody')).toBeVisible();
const body = (await page.textContent('body')) ?? '';
expect(body).not.toContain('Fatal error');
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
expect(body).not.toContain('Invalid plugin page');
});
});
test.describe('WP Admin - PR Vision Call Log page', () => {
test('call log page loads without fatal error', async ({ page }) => {
const res = await page.goto(`${ADMIN}/admin.php?page=pr-vision-calls`);
expect(res?.status()).toBe(200);
await expect(page.locator('#wpbody')).toBeVisible();
const body = (await page.textContent('body')) ?? '';
expect(body).not.toContain('Fatal error');
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
expect(body).not.toContain('Invalid plugin page');
});
});