diff --git a/.gitea/workflows/qa.yml b/.gitea/workflows/qa.yml new file mode 100644 index 0000000..1def107 --- /dev/null +++ b/.gitea/workflows/qa.yml @@ -0,0 +1,24 @@ +name: QA +on: + push: + workflow_dispatch: + schedule: + - cron: '0 9 * * *' # 09:00 UTC nightly live QA + +jobs: + repo-qa: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Repo checks (structure, frontmatter, link targets) + run: ./qa/site-qa.sh + + live-qa: + runs-on: ubuntu-latest + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Live checks (STLP site routes, links, canonical) + run: ./qa/site-qa.sh -l https://startinglineproductions.com diff --git a/README.md b/README.md new file mode 100644 index 0000000..8367995 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ + +## QA / CI / deployment process + +- `qa/site-qa.sh` — repo checks (structure, frontmatter, internal link targets). + Run before every push; also enforced by the local pre-push hook. +- `.gitea/workflows/qa.yml` — Gitea Actions: repo QA on every push, nightly + live QA (full route crawl + link/asset check + HTTPS-canonical check). +- Deployment: the site container runs `app/git-sync-job.sh` (config-as-code + copy here) every 15 minutes via the Grav scheduler + (`app/scheduler/git-sync.job.yaml`): pulls upstream, clears the Grav cache, + verifies `/` and `/shop` answer 200 — **rolls back to the previous commit + automatically if verification fails** — and pushes local content edits + (authored VpEngOps). +- Site management: edit here (gitea web UI works fine) and the site follows + within 15 minutes. There is no /admin UI: Grav 2 ships without one and this + deployment is git-first by design. diff --git a/app/git-sync-job.sh b/app/git-sync-job.sh new file mode 100644 index 0000000..fad41dd --- /dev/null +++ b/app/git-sync-job.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# git-sync-job.sh v2 - deploy upstream, verify, roll back on failure. +# Pull: if upstream moved -> deploy + clearcache + verify key routes; rollback on failure. +# Push: if local pages edits exist -> commit + push (VpEngOps identity). +cd /app/data/user || exit 1 +GIT=/usr/bin/git +BASE="https://${CLOUDRON_APP_DOMAIN}" +PORT="${CLOUDRON_APP_PORT:-80}" +verify() { # returns 0 if key routes answer 200 + sleep 2 + for r in / /shop; do + code=$(curl -s -o /dev/null -w '%{http_code}' -m 10 "http://127.0.0.1:${PORT}${r}" 2>/dev/null) + [ "$code" != "200" ] && { echo "$(date -u +%FT%TZ) ALARM: route $r -> $code after deploy"; return 1; } + done + return 0 +} +$GIT fetch origin main 2>/dev/null || exit 0 +LOCAL=$($GIT rev-parse HEAD 2>/dev/null || echo none) +REMOTE=$($GIT rev-parse origin/main 2>/dev/null || echo none) +if [ "$LOCAL" != "$REMOTE" ]; then + if [ "$LOCAL" = "none" ]; then + $GIT checkout -f -B main origin/main || exit 0 + else + PREV=$LOCAL + $GIT reset --hard origin/main || exit 0 + fi + $GIT clean -fdq pages/ 2>/dev/null + cd /app/code && php bin/grav clearcache >/dev/null 2>&1; cd /app/data/user + if ! verify; then + if [ -n "${PREV:-}" ]; then + $GIT reset --hard "$PREV" && $GIT clean -fdq pages/ 2>/dev/null + cd /app/code && php bin/grav clearcache >/dev/null 2>&1 + echo "$(date -u +%FT%TZ) rolled back to $PREV" + fi + exit 1 + fi + echo "$(date -u +%FT%TZ) deployed $REMOTE" + exit 0 +fi +if ! $GIT diff --quiet || ! $GIT diff --cached --quiet; then + $GIT add pages/ + $GIT -c user.name=VpEngOps -c user.email=tsgstaff-coo-vpengops@turnsys.com commit -qm '(Grav site) content changes' || exit 0 +fi +$GIT push origin main 2>/dev/null && echo "$(date -u +%FT%TZ) pushed" || true diff --git a/app/scheduler/git-sync.job.yaml b/app/scheduler/git-sync.job.yaml new file mode 100644 index 0000000..18b9a32 --- /dev/null +++ b/app/scheduler/git-sync.job.yaml @@ -0,0 +1,4 @@ +enabled: true +run_at: '*/15 * * * *' +command: /bin/bash /app/data/git-sync-job.sh +output: logs/git-sync-job.log diff --git a/app/system.yaml b/app/system.yaml new file mode 100644 index 0000000..f8081cf --- /dev/null +++ b/app/system.yaml @@ -0,0 +1,14 @@ +site: + title: 'Starting Line Productions' + description: 'Fabrication, printing, and maker services in Austin, TX.' +author: + name: 'VpEngOps' + email: 'tsgstaff-coo-vpengops@turnsys.com' +reverse_proxy_setup: true +http_x_forwarded: + protocol: true + host: true +debugger: + enabled: false +errors: + display: false diff --git a/qa/pre-push b/qa/pre-push new file mode 100755 index 0000000..c908d60 --- /dev/null +++ b/qa/pre-push @@ -0,0 +1,3 @@ +#!/bin/bash +# runs repo QA before any push +"$(dirname "$0")/site-qa.sh" diff --git a/qa/site-qa.sh b/qa/site-qa.sh new file mode 100755 index 0000000..53e8a8b --- /dev/null +++ b/qa/site-qa.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# site-qa.sh - QA for the Grav Website repo and (with -l BASE) the live site. +# ./site-qa.sh repo checks: structure, frontmatter, link targets +# ./site-qa.sh -l BASE live checks: every known route returns 200, all +# internal links/assets resolve, pages have content +# Exit non-zero on any failure. Used by CI and the post-deploy sync job. +set -uo pipefail +cd "$(dirname "$0")/.." +FAIL=0 +fail() { echo "FAIL: $*"; FAIL=$((FAIL+1)); } +route_of() { # pages-relative dir -> URL route (lowercase, strip NN. prefixes) + echo "$1" | tr 'A-Z' 'a-z' | sed -E 's|/([0-9]+\.)|/|g; s|^([0-9]+\.)||' +} + +repo_checks() { + declare -A ROUTES + while IFS= read -r f; do + grep -q '^title:' "$f" || fail "no title frontmatter: $f" + d=$(dirname "$f"); r=$(route_of "${d#pages/}") + ROUTES["${r:-/}"]=1 + done < <(find pages -name '*.md') + while IFS= read -r f; do + d=$(dirname "$f") + for link in $(grep -oE '\]\([^)#]+' "$f" | sed -E 's/^\]\(//'); do + case "$link" in http*|mailto:*) continue ;; esac + t=$(realpath -m --relative-to=. "$(dirname "$f")/$link" 2>/dev/null) || { fail "unresolvable link $link in $f"; continue; } + t=${t#pages}; t=$(route_of "$t") + [ -n "${ROUTES[${t:-/}]:-}" ] || fail "broken link '$link' in $f (no route '${t:-/}')" + done + done < <(find pages -name '*.md') + echo "repo: ${#ROUTES[@]} routes checked" +} + +live_checks() { + local BASE=${1%/} + local ROUTES=("/") + while IFS= read -r f; do + d=$(dirname "$f"); r=$(route_of "${d#pages/}") + ROUTES+=("/${r#/}") + done < <(find pages -name '*.md' | grep -v 'pages/01.home/home.md') + local SITEMAP=/tmp/qa-sitemap.txt CHECKED=/tmp/qa-checked.txt + : > $SITEMAP; : > $CHECKED + for r in $(printf '%s\n' "${ROUTES[@]}" | sort -u); do + code=$(curl -s -o /dev/null -w '%{http_code}' -m 20 --retry 1 "$BASE$r") + if [ "$code" = "200" ]; then echo "$BASE$r" >> $SITEMAP; else fail "route $r -> $code"; fi + sleep 0.2 + done + for url in $(cat $SITEMAP); do + body=$(curl -s -m 20 "$url"); sleep 0.2 + [ ${#body} -lt 500 ] && fail "page too small ($url, ${#body} bytes)" + upath=${url#$BASE}; [ -z "$upath" ] && upath=/ + for link in $(echo "$body" | grep -oE 'href="[^"]*"' | sed 's/href="//; s/"$//' | sort -u); do + case "$link" in + http*|mailto:*|\#*) [[ "$link" == "$BASE"* ]] || continue ;; + /*) link="$BASE$link" ;; + *) link="$BASE$(realpath -m "$upath/../$link" | sed 's|//\+|/|g')" ;; + esac + grep -qxF "$link" $CHECKED && continue + echo "$link" >> $CHECKED + case "$link" in + *.css|*.js|*.png|*.jpg|*.jpeg|*.svg|*.ico|*.woff*|*.map|*.webp|*.txt|*.xml) ;; + *) continue ;; + esac + code=$(curl -s -o /dev/null -w '%{http_code}' -m 20 "$link"); sleep 0.1 + [ "$code" != "200" ] && fail "asset $link -> $code" + done + for link in $(echo "$body" | grep -oE 'href="[^"]*"' | sed 's/href="//; s/"$//' | sort -u); do + case "$link" in http*|mailto:*|\#*) continue ;; esac + case "$link" in + /*) L="$BASE$link" ;; + *) L="$BASE$(realpath -m "$upath/../$link" | sed 's|//\+|/|g')" ;; + esac + grep -qxF "$L" $CHECKED && continue + echo "$L" >> $CHECKED + case "$L" in + *.css|*.js|*.png|*.jpg|*.jpeg|*.svg|*.ico|*.woff*|*.map|*.webp|*.txt|*.xml) continue ;; + esac + code=$(curl -s -o /dev/null -w '%{http_code}' -m 20 "$L"); sleep 0.1 + [ "$code" != "200" ] && fail "link $L -> $code" + done + done + # canonical must be https (reverse-proxy scheme detection) + can=$(curl -s -m 15 "$BASE/" | grep -oE '