Living document -- kept up to date with tips, processes, and guidance for working on WebMS Intra.
The Git repository root contains documentation and CI/CD config. All deployable
code lives inside web/, which maps directly to the server domain directory:
Git repo root (NOT deployed) Server: portal.millrdsdacambridge.uk/
├── .claude/ ├── _core/
├── .github/workflows/deploy.yml ├── _vendor/
├── CHANGELOG.md ├── _sql/
├── DEV_NOTES.md ├── _auth_keys/ (server-managed)
├── README.md ├── _libraries/ (server-managed)
└── web/ ─── contents deployed ──────► ├── _uploads/ (server-managed)
├── _core/ ├── _apps/ (app controllers, #159)
├── _apps/ (#159) ├── _backups/ (server-managed)
├── _vendor/ ├── _includes/
├── _sql/ ├── _functions/
├── _lang/ ├── public_html/ (front controller + static)
├── _install/ ├── public_html_dev/ (alpha branch deploy)
├── _includes/ ├── public_html_beta/ (beta branch deploy)
├── _functions/ ├── private_html/
├── _libraries/ (gitignored) ├── public_html_landing/
├── public_html/ └── public_html_redir/
│ ├── auth/
│ ├── dashboard/
│ ├── expenses/
│ ├── help/
│ └── settings/
├── private_html/
├── public_html_landing/
└── public_html_redir/
The _ prefix on every server-side dir is a naming convention: any dir
that starts with _ is above Apache's DocumentRoot and cannot be
directly accessed via HTTP. Only public_html/ (and its per-branch
siblings on the server) are web-accessible.
The repo holds one public_html/ source tree. Branch-based deploy mirrors it to the appropriate server-side destination — alpha → public_html_dev/, beta → public_html_beta/, main → public_html/. There is no per-channel front controller in the repo.
Key rule: when referencing paths in PHP code, use PORTAL_ROOT and related
constants (defined in bootstrap.php). When referencing paths in Git/CI, prefix
with web/.
WebMS Intra uses a three-branch SFTP deployment model modelled on the
iHymns pipeline. Only web/ is synced; the active branch decides which
public web root the upload lands in.
| Branch | Channel | Public dir on server | Auto-bump rule |
|---|---|---|---|
alpha |
alpha/dev | public_html_dev/ |
PATCH (always) |
beta |
beta | public_html_beta/ |
Conventional Commits |
main |
production | public_html/ |
none — tag v* manually |
All three branches share one remote base directory on DreamHost. Per
branch, web/public_html/ mirrors to a different sibling; everything else
inside web/ (_core/, _vendor/, _sql/, _lang/, _install/,
_includes/, _functions/) goes to the shared base from every branch —
last push wins for shared code.
SFTP_BASE_PATH/
├── _core/ ← from web/_core/ (all branches)
├── _apps/ ← from web/_apps/ (all branches, #159)
├── _vendor/ ← from web/_vendor/ (all branches)
├── _sql/ ← from web/_sql/ (all branches)
├── _lang/ ← from web/_lang/ (all branches)
├── _install/ ← from web/_install/ (all branches)
├── _auth_keys/ ← server-managed (excluded from sync)
├── _libraries/dompdf/ ← fetched at deploy time by tools/download-dompdf.sh
├── _uploads/ ← server-managed (excluded from sync)
├── _backups/ ← server-managed (excluded from sync)
├── public_html/ ← from web/public_html/ (main branch)
├── public_html_beta/ ← from web/public_html/ (beta branch)
└── public_html_dev/ ← from web/public_html/ (alpha branch)
deploy.yml— push to alpha/beta/main, or manual dispatch. PHP-lint, fetch pinned dompdf, SFTP via lftp (SSH key first, password fallback).version-bump.yml— push to alpha or beta. Alpha always bumps PATCH; beta uses Conventional Commits (BREAKING/!:→ major,feat(→ minor, else patch).changelog.yml— push to alpha or beta only (NOT main — the ruleset on main blocks the bot's direct push). Appends per-branch sections toCHANGELOG.mdfrom commit messages since the lastv*tag. Entries propagate to main via the normal beta → main merge.release.yml— push of anyv*tag. Creates a GitHub Release fromCHANGELOG.md; tags containing-betaor-rcare marked pre-release.auto-merge-alpha.yml— PR opened or synchronised againstalpha. Enables GitHub native auto-merge and dispatchesdeploy.ymlafter merge. The bridge is required here because GitHub's native auto-merge IS attributed toGITHUB_TOKEN, which doesn't trigger downstream workflows. Manual UI merges onbetaandmaindon't need a bridge — thepush:event from a human-attributed merge fires normally.pr-security.yml— runs on every PR against alpha/beta/main. PHP lint (hard gate), gitleaks secrets scan, heuristic anti-pattern scan.repo-config-audit.yml— weekly + on PRs touching.github/workflows/. Detects orphaned required-status-check rules (see gotchas section).
- Branch off
alphafor new work. - Open a PR against
alpha→ auto-merge fires once checks pass. - When
alphais stable, open a PR fromalpha→betafor wider testing. - When
betais stable, open a PR frombeta→mainfor production. - Tag a release on
main:
git tag -a v0.9.0 -m "Release notes summary"
git push origin v0.9.0 # fires release.ymlActions → Deploy via SFTP → Run workflow accepts an override target
(alpha / beta / main) that bypasses the branch-based mapping for a
one-off deploy.
[skip ci]— skip every workflow on this commit[deploy all]— force a full re-sync regardless of change detection
Configure these once when bringing a fresh repo (or a new server) online.
On your local machine:
ssh-keygen -t ed25519 -C "webms-intra-deploy@github" \
-f ~/.ssh/webms_intra_deploy -N ''Produces:
~/.ssh/webms_intra_deploy— private key (goes into GitHub secretSFTP_KEY)~/.ssh/webms_intra_deploy.pub— public key (goes onto the DreamHost server)
DreamHost panel → Users → SFTP Users → [deploy user] → Manage Users,
paste the contents of ~/.ssh/webms_intra_deploy.pub into Authorized Keys.
Verify from your laptop:
ssh -i ~/.ssh/webms_intra_deploy -p 22 <SFTP_USER>@<SFTP_HOST> 'pwd; ls'| Secret | Required | Example value |
|---|---|---|
SFTP_HOST |
yes | iad1-shared-XX-XX.dreamhost.com |
SFTP_USER |
yes | dh_abcd1234 |
SFTP_LIVE_PATH |
yes | /home/dh_abcd1234/portal.millrdsdacambridge.uk/public_html |
SFTP_BETA_PATH |
yes | /home/dh_abcd1234/portal.millrdsdacambridge.uk/public_html_beta |
SFTP_DEV_PATH |
yes | /home/dh_abcd1234/portal.millrdsdacambridge.uk/public_html_dev |
SFTP_PORT |
no | 22 (default if omitted) |
SFTP_KEY |
one of | full contents of ~/.ssh/webms_intra_deploy (private key, preferred) |
SFTP_PASSWORD |
one of | DreamHost SFTP password (fallback when SFTP_KEY is unset) |
gh secret set SFTP_HOST --body 'iad1-shared-XX-XX.dreamhost.com'
gh secret set SFTP_USER --body 'dh_abcd1234'
gh secret set SFTP_LIVE_PATH --body '/home/dh_abcd1234/portal.millrdsdacambridge.uk/public_html'
gh secret set SFTP_BETA_PATH --body '/home/dh_abcd1234/portal.millrdsdacambridge.uk/public_html_beta'
gh secret set SFTP_DEV_PATH --body '/home/dh_abcd1234/portal.millrdsdacambridge.uk/public_html_dev'
gh secret set SFTP_KEY < ~/.ssh/webms_intra_deploy
# optional:
gh secret set SFTP_PORT --body '22'
gh secret set SFTP_PASSWORD # prompts (avoids password in shell history)Shared-base note. The shared core/, vendor/, sql/ etc. upload to
dirname() of whichever per-branch path applies. When all three paths share
one parent (the default — recommended for the WebMS-Intra single-site setup),
all branches' shared code lands in the same place. Point them at different
parents if you want full isolation.
gh variable set SFTP_ENABLED --body 'true'While SFTP_ENABLED != 'true', all deploy runs no-op.
- Settings → General → Pull Requests → Allow auto-merge = ON.
- Settings → Branches → Add rule on
alpha,beta, andmain:- Disable allow-deletions
- Disable allow-force-pushes
- (Recommended) Require status check on
main
gh workflow run deploy.yml --ref main
gh run watch- Generate a new keypair
- Add the new public key on DreamHost (don't remove the old yet)
- Update
SFTP_KEYin GitHub - Trigger a manual deploy to confirm
- Remove the old public key from DreamHost
Expense PDF generation depends on dompdf in _libraries/dompdf/. The library
is not committed to this repo — tools/download-dompdf.sh fetches the
pinned version at deploy time and the lftp mirror uploads it as part of the
shared web/ sync. Update the pinned version by editing DOMPDF_VERSION in
that script.
For local development:
bash tools/download-dompdf.shThe script is idempotent — re-runs skip if the right version is already present.
The portal supports per-site visual branding via tblSites columns. Site
admins can override the brand colour, logo and favicon per-site without
touching code.
tblSites columns relevant to branding (see migration 037_site_favicon.sql):
| Column | Type | Purpose |
|---|---|---|
siteName |
VARCHAR(255) | Display name used in nav, page titles, footer |
logoPath |
VARCHAR(500) | Header logo URL/path (any image format) |
faviconPath |
VARCHAR(500) | Browser-tab favicon URL/path; NULL = default |
primaryColor |
VARCHAR(7) | #RRGGBB hex; default #5e6ad2 (Linear indigo) |
copyrightOrg |
VARCHAR(255) | Footer copyright holder |
-
Site::loadCurrentSite()selects the row into a class-level cache. -
Site::branding('color' | 'logo' | 'favicon' | 'name' | …)returns the relevant column. -
web/_core/templates/header.phpreadsSite::branding('color'), derives--portal-primary-rgb(R,G,B) from the hex, and inline- styles the<html>element:<html data-bs-theme="light" style="--portal-primary: #5e6ad2; --portal-primary-rgb: 94, 106, 210;">
-
web/public_html/assets/css/portal.cssdefines the design tokens inside:root. Because the<html>inline style has higher specificity than:root, the per-site primary wins. The derived variants (--portal-primary-hover,--portal-primary-active,--portal-primary-subtle) are auto-derived from the primary viacolor-mix()and shift along with it on any browser that supports color-mix (Chrome 111+, Safari 16.2+, Firefox 113+). On older browsers, the literal indigo hex fallbacks defined in:rootapply. -
header.phpalso renders<link rel="icon">fromSite::branding('favicon'), with/assets/images/favicon.icoas the fallback.
Umbrella admins manage all sites at /admin/sites/. The "New / Edit
site" modal has form fields for siteName, siteKey, hostPattern,
logoPath, faviconPath, primaryColor (color picker), copyrightOrg,
timezone, and active status.
The save handler at web/public_html/admin/sites/save.php validates the
primary colour as #RGB or #RRGGBB and falls back to the indigo default
on invalid input.
tblSites ships with the global "WebMS Intra" defaults. New sites
inherit #5e6ad2 until the admin sets their own brand colour. Logo and
favicon default to /assets/images/logo.svg and
/assets/images/favicon.ico respectively.
When a site uses CUSTOM branding (any branding field differs from the
ACTIVE product brand — see "Two-layer brand model" below), the footer
renders a small "Powered by <product>" attribution after the
copyright line, where <product> resolves via Site::productName().
Sites running the active product brand defaults don't show it — the
copyright line already names the product.
Detection (Site::usesCustomBranding() in web/_core/Site.php):
siteNamediffers fromSite::productName()(active product name resolved fromproduct.namesetting /PORTAL_PRODUCT_NAME_DEFAULTconstant /Site::DEFAULT_SITE_NAMEcold-start fallback), ORlogoPathdiffers fromSite::DEFAULT_LOGO_PATH('/assets/images/logo.svg'), ORprimaryColordiffers fromSite::DEFAULT_PRIMARY_COLOR('#5e6ad2', compared case-insensitively), ORcopyrightOrgis non-empty (default is NULL), ORfaviconPathis non-empty and differs from the default favicon path
Admins disable attribution globally via the branding.hidePoweredBy
setting in /settings/ (set to the string 'true'). Default is
'false', so attribution is on out-of-the-box for custom-branded
deploys.
Markup lives in web/_core/templates/footer.php; styling is in the
.portal-powered-by, .portal-powered-by-prefix, and
.portal-powered-by-mark rules in portal.css. The mark class is a
hook for future hyperlinking when the product landing page exists.
The same detection ALSO drives a <meta name="generator" content="<product>">
tag in web/_core/templates/header.php. This is the standard SaaS / CMS
attribution mechanism — invisible to humans, picked up by site analysers
like Wappalyzer + "View page source" + browser dev tools.
There are TWO independent branding layers in the portal. They compose top-down at render time.
┌─ Layer 1 — PRODUCT (system) ────────────────────────────────────┐
│ Set ONCE at install via the installer's Step 1.5 picker. │
│ Lives in tblSettings (siteID=NULL) as product.* + portal.industry. │
│ Drives: <meta name="generator">, footer "Powered by …", │
│ X-Powered-By header, PWA manifest name/description, │
│ installer wizard heading + footer. │
└──────────────────────────────────────────────────────────────────┘
▼ overridden by ▼
┌─ Layer 2 — TENANT (per-site, already shipped) ──────────────────┐
│ Set per-site via /admin/sites/<id>/branding. │
│ Lives in tblSites columns + `branding.*` settings. │
│ Drives: page chrome (siteName, logo, colour, favicon), │
│ copyright org, "Powered by …" visibility opt-out. │
└──────────────────────────────────────────────────────────────────┘
Resolution rule everywhere: tenant override > product default > hardcoded constant.
Tenants already had branding.* (Linear-style per-org skin). Adding a
SECOND layer above lets the same codebase ship as different sub-brands
without forking — WebMS Intra for the generic install, ChurchMS for
church installs, future SchoolMS / CharityMS etc. for other verticals.
Tenant branding stays decoupled; an install branded as ChurchMS can
still be deployed for Mill Road SDA Cambridge and the latter wins in
the chrome.
Defined in web/_core/brand-defaults.php as a return [...] array keyed
by portal.industry value (generic, church, school, nonprofit,
community, small-business). Each preset declares name, tagline,
publisher, assetFolder, and a human displayLabel for the installer
dropdown.
The file is bootstrap-free — it cannot reference any class, function,
or constant from elsewhere — so the installer (which runs before the
framework loads) can require it the same way the runtime does.
| Helper | Reads from | Used by |
|---|---|---|
Site::productName() |
App::settings('product.name') → PORTAL_PRODUCT_NAME_DEFAULT → DEFAULT_SITE_NAME |
header meta, footer mark, X-Powered-By, manifest |
Site::productTagline() |
App::settings('product.tagline') → PORTAL_PRODUCT_TAGLINE_DEFAULT |
manifest description, installer subtitle |
Site::productPublisher() |
App::settings('product.publisher') → PORTAL_PRODUCT_PUBLISHER_DEFAULT |
footer copyright |
The PORTAL_PRODUCT_*_DEFAULT constants are seeded in bootstrap.php
from brand-defaults.php's generic preset, so they're already valid
strings before $SETTINGS is loaded.
The installer wizard's Step 1.5 — Organisation Type (encoded as the
string '1.5' in URLs, alongside the existing '2.5' data-choice page)
shows a dropdown of preset display labels. The chosen industry key is
stored in $_SESSION['install_industry']; all subsequent steps display
the matching brand. After full_schema.sql runs, Step 3 INSERTs the
preset's name / tagline / publisher values plus the industry key
into tblSettings.
The brand is fully reversible: admins can edit portal.industry,
product.name, product.tagline, or product.publisher via
/admin/settings. Changing portal.industry does not auto-rewrite
the other rows — the admin may have customised them. Re-seeding is a
manual SQL exercise if a full preset reset is wanted; documented as a
v1.x follow-up.
Per-brand asset folders live at
web/public_html/assets/images/brands/<assetFolder>/{logo,icon-192,icon-512}.svg.
The brand-aware manifest.php controller resolves the active preset's
assetFolder and serves icons from there; it falls back to the existing
/assets/images/{logo,icon-192,icon-512}.svg placeholders if the per-brand
file isn't present yet. v1 ships placeholder copies for generic and
church; designers replace with distinct artwork in a follow-up PR
without touching code.
By design, these surfaces stay as WebMS Intra regardless of preset:
- PHP
@package WebMS Intra/@author MWBM Partners Ltddoc-tags — these document code authorship, not user-facing branding. error_log('[WebMS-Intra] …')server-log prefixes — codebase identity for operators reading logs, not user-facing brand.robots.txt— comment header is brand-neutral so the static file can be served without going through a PHP controller.openapi.jsoninfo.title— developer-facing surface; brand-aware conversion deferred to a v1.x follow-up (see below).
Tracked as separate issues; called out here so they don't get lost between PRs.
-
Distinct sub-brand artwork — the
assets/images/brands/<type>/folders currently contain placeholder copies of the generic SVGs so themanifest.phpresolver finds something. Designers replace the artwork in a follow-up without touching code; the controller discovers new files at next render. ChurchMS gets the first distinct logo pass; school / charity / community / small-business stay placeholders until those presets need to ship. -
openapi.jsonbrand-aware conversion —info.title,info.contact.name, andinfo.contact.urlare still hardcoded toWebMS Intra REST API/MWBM Partners Ltd …regardless of the active brand. Pattern would mirrormanifest.json→manifest.php: move the static spec toweb/_core/api-spec.json, addweb/public_html/openapi.phpthat loads it and rewrites theinfoblock before emitting, route via tblRoutes. Deferred because the OpenAPI surface is developer-facing (Swagger UI viewers, integrators) and the same brand value reads cleanly in both contexts. -
prayerRequests.*→prayer-requests.*setting-key naming standardisation — drift dating to the original prayer-requests app (PR #129). Every other app uses kebab-case slugs as setting prefixes (prayer-requestsis the directory name,tblRouteskey, app slug). The setting key is camelCase. A migration would rename the rows intblSettingsAND update the three handlers that readApp::settings('prayerRequests.*'). Mechanical work; only deferred because it touches a wide-blast-radius app and isn't urgent enough to bundle into this PR.
Each is filed as its own GitHub issue with for consideration label
so the per-item decision happens later. Search the issue tracker for
"deferred from #297" to find them.
The portal and the standalone installer support three theme modes and an
opt-in colour-blind safe palette. Both are user-level preferences stored in
localStorage (per-device, per-browser).
- Light — light surfaces, dark text (the design's default visual)
- Dark — dark surfaces, light text (
[data-bs-theme="dark"]overrides) - Auto — follows the OS
prefers-color-schemeand live-updates if the system flips
Click the half-stroke circle icon in the navbar to cycle through the modes.
The icon updates to indicate the active preference (sun = light, moon =
dark, half-stroke = auto). Persisted as localStorage.portal-theme =
light / dark / auto. Missing key defaults to auto.
The same control exists in the standalone installer at /install/ (top-
right of the page). Its tokens mirror portal.css and are kept in sync
manually.
Opt-in toggle (localStorage.portal-cb = on / unset). When enabled,
the eye icon in the navbar shows as active and the semantic colours
(success, danger, warning, accent) shift to a palette from Wong (Nature
Methods, 2011) that's distinguishable for deutan + protan colour
blindness (~95 % of CB cases):
--portal-success: default#16a34a(green) → CB#009e73(bluish-green); dark CB#5dd1a8--portal-danger: default#dc2626(red) → CB#d55e00(vermillion); dark CB#ff8a4d--portal-warning: default#d97706(amber) → CB#e69f00(orange); dark CB#ffc04d--portal-accent: default#06b6d4(cyan) → CB#56b4e9(sky blue); dark CB#7fc6f0
Primary stays untouched — it's the site's identity colour and is user/site-set (see "Per-site branding flow" above).
Accessibility note: CB-safe tokens reduce the risk of mis-reading status colours, but colour alone should never be the only signal. Components that convey state (badges, alerts, validation messages) should also use icons or text labels. The PR template's security checklist already mentions this for new UI work.
localStorage ──FOUC script──▶ <html data-bs-theme="..." data-portal-cb="...">
│
▼
portal.css token overrides
│
▼
all components inherit
The FOUC script runs synchronously in <head> before first paint, so
the chosen theme + CB mode are applied with no flash. portal.js (and
the installer's inline JS) then wire up the toggle buttons and listen
for prefers-color-scheme changes when in auto mode.
web/public_html/assets/css/portal.css— token blocks for light, dark, CB-safe (and dark + CB-safe combined)web/_core/templates/header.php— inline FOUC script reading localStorage and applying the attrsweb/_core/templates/nav.php— theme + CB toggle buttonsweb/public_html/assets/js/portal.js—initThemeToggle()(cycles light → dark → auto),initCbToggle()(on/off)web/_install/index.php— installer mirrors all of the above inline (it's standalone, can't load portal.css/portal.js)
Last verification: 2026-06-19 (originally 2026-06-03) · Status: unfired (still 0 runs since creation)
2026-06-19 re-audit: gh run list --workflow auto-merge-alpha.yml --limit 20 returns []. gh pr list --base alpha --state all --limit 20 returns []. No PR has ever targeted the alpha branch; the workflow has never executed. Decision below stands — retained, not deleted. Re-audit every ~6 months; if alpha branch usage stays at zero for the next audit window, revisit deletion.
The workflow at .github/workflows/auto-merge-alpha.yml calls
gh pr merge --auto --squash on any PR whose base is alpha, then waits
for the merge and dispatches deploy.yml so the alpha environment updates
(the dispatch is required because GitHub's anti-recursion rule suppresses
push events attributed to GITHUB_TOKEN).
It has zero runs to date because no PR has been opened against alpha
in the entire history of the repo — every PR (#280 - #286 reviewed) has
targeted main directly. The workflow is structurally correct (trigger,
permissions, command syntax all match GitHub's auto-merge contract), it
simply has not had the opportunity to execute.
git checkout alpha
git checkout -b test-auto-merge-$(date +%s)
echo "" >> README.md # trivial no-op
git commit -am "test: auto-merge smoke"
git push -u origin HEAD
gh pr create --base alpha --title "test: auto-merge smoke" --body "verifying #147"
# Wait ≤30s for "Auto-Merge Alpha PRs" run to appear in Actions tab.
# PR should show the green "auto-merge" badge.
# Once required checks pass, GitHub auto-merges and dispatches deploy.yml.
gh pr view --json autoMergeRequest,mergeStateStatusWorkflow retained, not deleted. Cost of keeping it = zero (it only runs when triggered). Cost of deleting = re-authoring the trigger/permissions/dispatch logic if the team starts using alpha. Delete only if alpha branch usage stays at zero for the next 6 months.
Two GitHub mechanisms can guard a branch in parallel: classic branch protection rules (Settings → Branches) and the newer rulesets (Settings → Rules → Rulesets). This repo currently uses both, which is allowed but creates traps. Read this before adding or modifying any required check.
When you add a required status check to a ruleset or branch protection,
the context name you enter must match the exact string GitHub records
on the check_run — which for GitHub Actions is the job's name: field
(or the job ID if no name: is set). It is not the prefixed
Workflow Name / Job Name form you see in the PR UI's checks list.
Example. Given this workflow:
name: PR Security Checks # workflow name
jobs:
security: # job ID
name: Static security checks # job name — THIS is what to enterThe PR UI shows PR Security Checks / Static security checks (pull_request).
But the required-check context to enter is just:
Static security checks
If you enter the prefixed form, the rule waits forever for a check that never arrives — the same orphan condition that bit PR #104.
A required check that no workflow emits silently soft-locks every future PR. Common causes:
- A workflow gets renamed and the rule isn't updated
- A required check is added in anticipation of a workflow that never ships
- A
name:field is changed without thinking about the rule
.github/workflows/repo-config-audit.yml runs weekly and on PRs that
touch any workflow. It calls tools/audit-required-checks.py, which
cross-references every required check name against every workflow job
name in the repo. Orphans fail the audit and post a comment on the PR.
Run the audit locally:
python3 tools/audit-required-checks.pyExits 0 on clean (or degraded mode), 1 on orphans, 2 on unexpected error.
The default GITHUB_TOKEN in workflow runs cannot read rulesets or
branch protection — the GitHub Actions permissions model has no
administration: read key. Without that, the CI audit runs in
degraded mode (it can still emit a useful summary based on
workflow-file inspection, but can't catch orphans).
To unlock the full CI audit, create a fine-grained personal access
token scoped to this repo with Administration: Read permission,
then store it as a repo secret named RULESET_AUDIT_TOKEN:
- GitHub → your account → Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token
- Repository access: select only
WebMS-Intra(least privilege) - Repository permissions: Administration: Read (rest stay None)
- Generate and copy the token
- In the repo: Settings → Secrets and variables → Actions →
New repository secret → name
RULESET_AUDIT_TOKEN, value = the PAT
The workflow auto-detects the secret and uses it when present; absent
secret = degraded mode, no failure. Local gh runs are unaffected
since you're already authenticated as an admin.
If a check is required by either source, the PR is blocked until it passes. Removing a rule from branch protection does not remove a duplicate copy in a ruleset. When debugging "why is this PR blocked?", inspect both:
# Branch protection on a branch
gh api repos/MWBMPartners/WebMS-Intra/branches/main/protection
# All active rulesets
gh api repos/MWBMPartners/WebMS-Intra/rulesets
gh api repos/MWBMPartners/WebMS-Intra/rulesets/<id>PUT /repos/.../rulesets/<id> with the full ruleset body (after stripping
server-only fields like id, created_at, updated_at, _links).
Easier-but-slower: use the GitHub UI at Settings → Rules → Rulesets →
[ruleset] → Edit.
Set on main, beta, and alpha to disallow deletions and force-pushes
without requiring PR reviews you can't satisfy:
- Disallow allow_deletions, allow_force_pushes
- Do not enforce_admins (so you can bypass when needed)
- No required_pull_request_reviews (would block solo dev)
- Required linear history on
mainonly (forces squash/rebase) - Required status checks:
Static security checksonmain
The dev site (public_html_dev/) is not protected by .htaccess basic
auth. Instead, it uses the portal's own authentication and authorisation
system via Gatekeeper::enforce('dev').
- User visits the dev site
- If not logged in, they are redirected to the login page (MS365 SSO or local)
- After login, the Gatekeeper checks:
- Root Admins (
isRootAdmin=1in tblUsers) -- always allowed - Admins (
isAdmin=1in tblUsers) -- always allowed - Role-based -- if the user's roles match
portal.devAccessRolessetting
- Root Admins (
- If denied, they see a 403 error page and the attempt is logged
To grant a non-admin user access to the dev site:
- Go to Settings in the portal admin UI
- Find or create the setting
portal.devAccessRoles - Set the value to a comma-separated list of role keys, e.g.:
Developer,Tester - Ensure the user has the matching role assigned in
tblUserRoles
This approach is better than .htaccess because:
- Uses the same SSO login (no separate passwords to manage)
- Role-based (grant/revoke via DB, not file editing)
- Audit trail (denied access is logged via Logger)
- Consistent UX with the rest of the portal
The portal automatically detects which environment it is running in,
based on the PORTAL_ENV environment variable or the server's document
root directory name:
| Directory | PORTAL_ENV | Behaviour |
|---|---|---|
public_html/ |
prod |
Errors hidden, no debug panel |
public_html_dev/ |
dev |
Errors displayed, debug panel available |
You can override detection by setting the PORTAL_ENV environment variable
in your shell or hosting panel.
cd web
export PORTAL_ENV=dev
php -S localhost:8080 -t public_htmlUse Semantic Versioning:
v{MAJOR}.{MINOR}.{PATCH}
- MAJOR -- breaking changes (e.g. DB schema changes requiring migration)
- MINOR -- new features, new app modules
- PATCH -- bug fixes, minor tweaks
Examples: v0.1.0, v0.2.0, v1.0.0
- Ensure all changes are committed and pushed to
main - Verify the dev site works correctly
- Run pending SQL migrations on production (if any)
- Tag the release:
git tag -a v0.3.0 -m "Directory restructure"
git push origin v0.3.0- Monitor the GitHub Actions deploy
- Verify the production health check:
https://portal.millrdsdacambridge.uk/health
These are enforced across the codebase. Follow them in all new code.
declare(strict_types=1)at the top of every PHP file- Full IF notation:
if ($x === true)notif ($x) - Platform-neutral paths: use
DIRECTORY_SEPARATORinstead of/ - Emoji-annotated comments for major code sections
htmlspecialchars($val, ENT_QUOTES, 'UTF-8')for all output escaping- No
<table>tags for data display -- useportal-data-listcomponent - MySQLi prepared statements only -- never interpolate user input into SQL
- Use
Portal\Core\App::methods overglobalkeyword in new code
Migrations live in web/_sql/ as numbered .sql files. They are executed via
the web-based Migrator (admin-only) and tracked in tblMigrations.
- Create
web/_sql/NNN_description.sql(next sequential number) - Write idempotent SQL (use
IF NOT EXISTS,IF EXISTSwhere appropriate) - Push to
main-- it deploys to dev - Run the migration on dev via the admin migration runner
- Test thoroughly
- Before tagging a production release, run the migration on production
| File | Purpose |
|---|---|
000_create_migrations_table.sql |
Migration tracking table |
001_create_tblErrors.sql |
Error logging |
002_create_expense_support_tables.sql |
Expense approvals + payments |
003_add_missing_settings.sql |
Required settings entries |
004_seed_routes.sql |
Initial route definitions |
006_local_auth_enhancement.sql |
Password resets, password policy settings, auth routes |
007_admin_routes.sql |
Admin section routes |
008_calendar_events_schema.sql |
Calendar / Events / Preaching Plan tables and seeds |
009_attendance_schema.sql |
Attendance service types, sessions, counts tables and seeds |
010_expenses_phase6.sql |
Expense multi-approver settings, file stage column, approver role column, view route |
011_auth_phase7.sql |
Linked accounts table, WebAuthn credentials table, Google/WebAuthn settings, account routes |
012_i18n_phase8.sql |
Adds locale column to tblUsers, i18n settings (defaultLocale, enabled) |
013_help_translations_route.sql |
Adds route for translations help page |
014_admin_integrations_route.sql |
Adds route for admin integration diagnostics page |
015_multisite.sql |
Multi-site support: tblSites, tblUserSites, siteID columns, multisite settings/routes |
016_google_mail.sql |
Google Workspace email settings: mail.provider, service account key, delegate user |
017_leadership.sql |
Leadership app: roles, assignments tables, seed roles, routes, settings |
018_multisite_fixes.sql |
Multi-site bug fixes: missing siteID on recurrence rules, open redirect prevention |
019_slug_uniqueness_multisite.sql |
Composite unique index on event slugs (slug + siteID) |
020_composite_indexes.sql |
Composite indexes for multi-site query performance |
021_display_format_settings.sql |
Configurable date/time display format settings |
022_expense_withdrawal.sql |
Expense claim withdrawal feature, concurrent approval lock |
023_series_bulk_edit_route.sql |
Event series bulk edit route |
024_csv_export_routes.sql |
CSV export routes for expenses, attendance, leadership, admin |
025_install_upgrade_route.sql |
Upgrade handler route for admin upgrade page |
026_notification_preferences.sql |
notifyPrefs JSON column, digest settings |
027_user_import_route.sql |
User CSV import route |
028_event_rsvp.sql |
tblEventRSVPs, capacity column on tblEvents, RSVP route |
029_announcements.sql |
tblAnnouncements, announcement routes and app settings |
030_document_library.sql |
tblDocCategories, tblDocuments, document routes and settings |
031_audit_trail.sql |
tblAuditTrail for before/after change tracking |
032_totp_2fa.sql |
TOTP columns on tblUsers, tblTotpBackupCodes, 2FA routes |
033_reports.sql |
Reports/analytics dashboard routes |
034_workflow_engine.sql |
tblWorkflows, Steps, Instances, Actions tables |
035_api_expansion.sql |
REST API routes for events, attendance, users, announcements |
036_tasks_reminders.sql |
tblTasks with recurrence, task routes and app settings |
full_schema.sql |
Consolidated schema for fresh installs (covers 000–036) |
All paths below are relative to web/ (the deployable root):
| Path | Purpose |
|---|---|
_core/ |
Framework classes (Portal\Core namespace) |
_core/templates/ |
Shared page templates (header, footer, nav, errors) |
_vendor/simplejwt/ |
Vendored RS256 JWT verifier (no Composer) |
_sql/ |
Numbered SQL migration files |
_lang/ |
I18n translation files (en.php, cy.php, …) |
_install/ |
Installation wizard and upgrade handler |
public_html/ |
The single web-root source; branch-based deploy maps this to public_html/ (main), public_html_dev/ (alpha) or public_html_beta/ (beta) on the server |
public_html/{app}/ |
App controllers (e.g. expenses/, auth/, dashboard/) |
_auth_keys/ |
Credentials and encryption keys (gitignored, created by installer) |
_uploads/ |
User file uploads (gitignored) |
_backups/ |
Server backups (gitignored) |
_libraries/ |
Self-hosted libs e.g. dompdf (gitignored) |
_includes/ |
Shared includes (future) |
_functions/ |
Shared functions (future) |
- Create directory:
web/public_html/{appname}/index.php - Add route to
tblRoutes(or create a migration) - In the app file, set page metadata and include templates:
<?php
declare(strict_types=1);
use Portal\Core\Auth;
$pageTitle = 'My App';
$pageSection = 'myapp';
$breadcrumbs = ['Dashboard' => '/', 'My App' => ''];
require PORTAL_CORE . DIRECTORY_SEPARATOR . 'templates' . DIRECTORY_SEPARATOR . 'header.php';
?>
<!-- App content here -->
<?php
require PORTAL_CORE . DIRECTORY_SEPARATOR . 'templates' . DIRECTORY_SEPARATOR . 'footer.php';
?>- If the app needs a settings-based enable flag, add
myapp.enabled = truetotblSettings - The nav will pick it up automatically if configured in the template
The portal supports multiple languages via the I18n framework (core/I18n.php).
All user-facing text is stored in language files under web/_lang/, one file
per locale. English (en.php) is the baseline — every other language file only
needs to include the keys it translates; missing keys fall back to English automatically.
User visits page
→ I18n checks: user DB preference → session → browser Accept-Language → default
→ Loads web/_lang/{locale}.php (e.g. lang/fr.php)
→ t('auth.sign_in') returns "Se connecter" instead of "Sign In"
→ Missing keys fall back to English automatically
Each language file is a PHP file that returns a flat associative array.
Keys use dot-notation for logical grouping (e.g. nav.dashboard, auth.sign_in).
<?php
// File: web/_lang/fr.php
declare(strict_types=1);
return [
'nav.dashboard' => 'Tableau de bord',
'nav.sign_in' => 'Se connecter',
'auth.sign_in' => 'Se connecter',
'auth.password' => 'Mot de passe',
// ... only include keys you want to translate
// anything missing falls back to English
];Keys follow the pattern {section}.{description} using lowercase and underscores:
| Prefix | Section | Example |
|---|---|---|
nav. |
Navigation bar | nav.dashboard, nav.sign_out |
auth. |
Login, password, account | auth.sign_in, auth.forgot_password |
dashboard. |
Dashboard page | dashboard.welcome |
expenses. |
Expense claims | expenses.submit_title |
calendar. |
Calendar / Events | calendar.all_categories |
attendance. |
Attendance tracker | attendance.record_title |
admin. |
Admin panel | admin.user_management |
settings. |
Settings page | settings.add_setting |
help. |
Help centre | help.title |
error. |
Error pages (403/404/500) | error.page_not_found |
common. |
Shared UI elements | common.save, common.cancel |
email. |
Email templates | email.greeting |
format. |
Date/number/currency formats | format.date.short |
-
Copy the English baseline as a starting point:
cp web/_lang/en.php web/_lang/fr.php
-
Edit the file header — update the language name and flag emoji:
/** * French (fr) Translation File 🇫🇷 */
-
Translate each string value (the part after
=>). Do NOT change the keys (the part before=>):// ✅ Correct — only change the value 'nav.dashboard' => 'Tableau de bord', // ❌ Wrong — never change the key 'nav.tableau_de_bord' => 'Tableau de bord',
-
Remove keys you haven't translated yet — they'll fall back to English automatically. This is better than leaving English text in a French file.
-
Check the locale is registered in
core/I18n.phpin the$localesarray. All 13 currently supported locales are already registered:en, cy, fr, de, es, pt, ar, he, fa, ur, zh, ja, ko -
Test it — visit any page and add
?lang=frto the URL, or use the language switcher dropdown in the navigation bar.
When you see a string you want to translate:
-
Find the key — search
web/_lang/en.phpfor the English text:grep -n "Sign In" web/_lang/en.phpResult:
'auth.sign_in' => 'Sign In', -
Add the key to your language file with the translated value:
'auth.sign_in' => 'Se connecter',
-
Save and test — the change is live immediately (no build step needed).
Some strings include dynamic values using :param syntax:
// English
'auth.too_many_attempts' => 'Too many attempts. Try again in :minutes minute(s).',
// French
'auth.too_many_attempts' => 'Trop de tentatives. Réessayez dans :minutes minute(s).',The :minutes placeholder is replaced at runtime. Keep the :param names exactly
as they are in the English file — only translate the surrounding text.
Strings that change based on a count use | as a separator:
// Two forms: singular | plural
'expenses.claim_count' => 'One claim|:count claims',
// Three forms: zero | one | many
'items.count' => 'No items|One item|:count items',French example:
'expenses.claim_count' => 'Une réclamation|:count réclamations',
'items.count' => 'Aucun élément|Un élément|:count éléments',RTL locales (Arabic, Hebrew, Farsi, Urdu) are handled automatically:
- The
<html>tag getsdir="rtl" - Bootstrap loads its RTL CSS variant
- Portal CSS applies margin/text-alignment overrides
No special action is needed when translating — just provide the translated text and the framework handles the layout direction.
In any PHP file loaded after bootstrap:
// Simple translation
echo t('nav.dashboard'); // "Dashboard" or translated equivalent
// With parameters
echo t('auth.too_many_attempts', ['minutes' => 5]);
// With pluralisation
echo t('items.count', ['count' => 3]);
// Always escape for HTML output
echo htmlspecialchars(t('auth.sign_in'), ENT_QUOTES, 'UTF-8');Users change their language via the globe dropdown in the navigation bar. When a user switches language:
- A
?lang=frquery parameter is sent - The preference is stored in their session
- If logged in, it's also saved to
tblUsers.localein the database - On next login, their preference is loaded from the database automatically
Two settings control i18n behaviour (in the portal Settings page):
| Setting Key | Purpose | Default |
|---|---|---|
i18n.defaultLocale |
The default language for users who haven't chosen one | en |
i18n.enabled |
Whether the i18n system is active | true |
There is no built-in approval UI — translations are managed as code:
- Translator creates or edits
web/_lang/{locale}.php - Developer reviews the changes via Git pull request or code review
- Merge to
main— translations deploy to dev automatically - Test on dev — verify strings appear correctly in context
- Tag a release — translations deploy to production
This keeps translations version-controlled, reviewable, and auditable.
Lightweight dependency injection container that works alongside the existing static
App registry. Supports singleton and factory bindings with lazy resolution:
$container = new Container();
$container->singleton('mailer', fn() => new Mailer($config));
$mailer = $container->get('mailer'); // same instance each timeUse Container for new service wiring; existing App::db(), App::settings() etc.
remain unchanged for backward compatibility.
Dedicated API route dispatcher, extracted from the main Router class. Handles
all api/{app}/{action} patterns with JSON content-type enforcement, CORS headers,
and standardised error envelopes via ApiResponse. The main Router::dispatch()
delegates to ApiRouter for any path starting with api/.
Generic CSV export helper used across five apps: expenses, attendance, leadership,
admin users, and activity logs. Accepts a column definition array and a MySQLi result
set, streams output with proper headers (Content-Type: text/csv,
Content-Disposition: attachment), and escapes fields to prevent formula injection.
Input validation framework using pipe-separated rule syntax:
$v = new Validator($_POST, [
'email' => 'required|email|max:255',
'amount' => 'required|numeric|min:0.01',
'date' => 'required|date',
]);
if ($v->fails()) {
$errors = $v->errors(); // ['email' => ['The email field is required.']]
}Built-in rules: required, email, numeric, integer, min, max,
date, in, regex, string, boolean. Custom rules can be added via closures.
App::beginTransaction(), App::commit(), and App::rollback() wrap MySQLi
transaction methods for cleaner multi-statement operations:
App::beginTransaction();
try {
// multiple inserts/updates
App::commit();
} catch (\Throwable $e) {
App::rollback();
throw $e;
}All CSRF validation failures and OAuth errors now follow a consistent
flash + redirect pattern instead of mixed approaches (some pages used
die(), others rendered inline errors, others returned JSON):
- CSRF failures — set a flash error message in
$_SESSION['flash']and redirect back to the originating form. The header template renders flash messages automatically. - OAuth errors — capture error details, flash a user-friendly message,
and redirect to the login page. Technical details are logged via
Logger. - No remaining
die()or bareexit()calls — all early-termination paths use flash+redirect orApiResponse::error()(for API endpoints).
This was tracked in Issue #82.
Auth::validatePassword() and Auth::passwordPolicy() are the canonical
helpers — every password-set flow goes through them (reset, account
change-password, admin user create / update, and the standalone installer
which carries a self-contained copy).
Settings (all auth.password.*):
| Key | Default | Notes |
|---|---|---|
minLength |
12 |
Bumped from 8 in migration 041 (OWASP ASVS L1) |
maxLength |
128 |
Defence against pathological inputs; bcrypt truncates at 72 anyway |
requireUppercase |
true |
Independent of lowercase since #132 |
requireLowercase |
true |
New flag — previously implicit |
requireNumber |
true |
|
requireSpecial |
true |
Any non-alphanumeric |
Auth::passwordPolicy() returns the active policy as a structured array
(rules list + min/max + required flags) so password forms can render
hints consistently. Forms also wire up the JS strength meter via the
data-portal-password-input + data-portal-password-meter attributes —
portal.js attaches the meter on every matching input; the installer
ships an inline copy because it loads before bootstrap.php.
5-step score mirrors the server policy:
- +1 length ≥ minLength
- +1 contains lowercase
- +1 contains uppercase
- +1 contains digit
- +1 contains symbol
Bands: 0-1 Very weak (red), 2 Weak (red), 3 Fair (warning), 4 Strong (info), 5 Very strong (success).
Portal\Core\Captcha accepts three providers — Cloudflare Turnstile,
Google reCAPTCHA (v2 checkbox or v3 invisible-score), and hCaptcha — and
picks the active one based on an admin-configurable priority list.
Settings:
auth.captcha.priority— comma-separated provider keys (defaultturnstile,recaptcha,hcaptcha); the first one with both site + secret keys configured wins.auth.turnstile.{siteKey,secretKey}auth.recaptcha.{siteKey,secretKey,version}(version =v2orv3)auth.recaptcha.v3.{action,threshold}(default actionsubmit, threshold0.5)auth.hcaptcha.{siteKey,secretKey}
Public API (unchanged contract from previous Captcha class):
Captcha::scriptTag() // <script> tag(s) for the active provider
Captcha::widget() // widget markup (or invisible hidden input for v3)
Captcha::verify($_POST) // server-side verification
Captcha::isConfigured() // true if at least one provider is wired up
Captcha::activeProvider() // 'turnstile' | 'recaptcha' | 'hcaptcha' | ''
Captcha::listProviders() // for the admin UI
Captcha::normalisePriority() // for the admin save handlerAdmin UI lives at /admin/captcha — SortableJS-powered drag-and-drop
priority list + per-provider key inputs + v2/v3 toggle + action / score
threshold inputs for v3.
reCAPTCHA v3 verification enforces both action match (anti-replay)
and score threshold; rejections are logged via Logger::activity() as
CaptchaRejected so probing surfaces in the activity log.
Debug::isEnabled() and App::isDebug() both refuse to enable debug
mode when PORTAL_ENV === 'prod', regardless of admin status or query
params. Defence-in-depth:
Debug::isEnabled()— returns false in prod;Debug::renderPanel()is already gated on it. Attempts in prod are logged once per request asDebugBlockedactivity (IP + path).App::isDebug()— same prod refusal. The global exception handler inbootstrap.phpalready routes detailed traces throughApp::isDebug(), so stack traces / file paths can never leak in prod even on unhandled exceptions.bootstrap.php— forcesdisplay_errors,display_startup_errors, andhtml_errorsto'0'in prod.error_reporting(E_ALL)stays on soLogger::phpError()continues to capture everything.
portal.css binds --portal-link (and its hover / RGB variants) to
Bootstrap's --bs-link-color, --bs-link-color-rgb,
--bs-link-hover-color, and --bs-link-hover-color-rgb in both the
light :root and [data-bs-theme="dark"] blocks. Without these
bindings, every plain <a> / .btn-link / .alert-link / .link-*
falls back to the browser-default blue, which clashes hard in dark mode.
install/index.php mirrors the same binding in its self-contained
inline <style> block because the installer doesn't load portal.css.
Per-site branding still flows through: --portal-link resolves to
--portal-primary, which Site::branding() overrides on
<html style="--portal-primary: …">, so anchor colour follows the
site's primary colour automatically.
web/public_html/calendar/index.php is a thin view router. It:
- Validates
?view=against the whitelist (day | week | weekdays | weekend | month | year | list). - Resolves a visible date range from
?date=YYYY-MM-DD(orYYYY-MM,YYYY, falling back to today on parse failure). - Fetches every event overlapping that range in one query (no per-cell N+1).
- Delegates rendering to a per-view partial under
views/.
View partials (under web/public_html/calendar/views/):
_shared_header.php— date navigation (◀ Today ▶ + date picker), view switcher, filter row._day_columns.php— one hour-timeline renderer reused by day / week / weekdays / weekend, parametrised by column count. Events position absolutely by start time and clip to each column's[00:00, 24:00]window. All-day events strip above the timeline.day.php,week.php,weekdays.php,weekend.php— thin wrappers around_day_columns.phpwith their own day list.month.php— 7-column 5/6-row calendar grid; up to 3 event pills per cell + "+ N more" link to day view.year.php— 12-month wall planner. 24-column grid (12 months × day-number + content sub-columns), 31 day rows, blank cells where months are shorter. Multi-day event bands repeat on every covered day so they read as continuous strips. Auto-built legend with category swatches at the top.list.php— the original chronological card grid.
Settings: calendar.defaultView (default month).
Per-user: localStorage['portal-calendar-view'] remembers the
last-used view across visits; URL ?view= always wins.
Category styling (#138): tblEventCategories.color (hex,
regex-validated server-side) drives event background tints AND a
left-border accent. tblEventCategories.displayStyle toggles between
'background' (tinted band — default) and 'text' (coloured text
on default background — used for Bank Holidays / Notable Days that
should flag a day rather than fill it).
Per-month strap-lines (#138): tblCalendarMonthThemes stores one
text line per (siteID, year, month). Rendered as an italicised
strap-line under each month name on the year planner. Managed via
/calendar/manage/month-themes (year picker + 12 inputs; empty
values delete the row).
Security: all colour values are hex-validated by regex
(/^#[0-9a-fA-F]{3,8}$/) before persistence and
htmlspecialchars-escaped on output, blocking CSS injection via
crafted category colours.
Self-contained app at /prayer-requests/. Single table
(tblPrayerRequests) with status lifecycle
(pending → active → answered → archived) and visibility flag
(leadership | congregation).
Anonymous public submission lives at /prayer-requests/anonymous —
no login required — and is gated by:
- CSRF — session token issued by the GET form, verified on POST.
- Captcha — whichever provider is active per the
auth.captcha.prioritysetting (see above). - RateLimiter — same per-IP limiter used by the login form.
- Hard-coded to
visibility = leadershipandstatus = pending(anonymous submissions never broadcast directly to the congregation). - Always redirects to the same generic success page so abusers can't fingerprint success vs failure.
Logged-in submitters get a "display as Anonymous" toggle — members see "Anonymous", but the moderation queue still shows the real submitter for pastoral follow-up.
The CSRF token has expired or was already used (tokens rotate after use). Reload the form page to get a fresh token.
Check GitHub Actions for deploy failures. Common causes:
- PHP lint error (syntax issue blocks deploy)
- FTP credentials expired (check DH_HOST/DH_USER/DH_PASS secrets)
Your user account lacks dev access. Either:
- Set
isAdmin=1on your user record intblUsers, or - Add your role to
portal.devAccessRolesin Settings
Append ?debug=true to the URL. Only visible to admin users in non-prod
environments. Debug mode is unconditionally refused when PORTAL_ENV=prod
(any attempt is logged as a DebugBlocked activity entry). See issue #54.
Almost certainly because it isn't in the web/ tree in the repo. The
deploy workflow mirrors with --delete on the shared dirs, so any file
present on the server but absent from web/ will be removed on the next
push.
Affected (mirrored) trees on the server:
<base>/core/
<base>/vendor/
<base>/sql/
<base>/_includes/
<base>/_functions/
<base>/_libraries/
What survives:
<base>/_auth_keys/— server-only (credentials, encryption key)<base>/_uploads/— user uploads<base>/_backups/— server-managed snapshots
If you need a quick patch on the server during an incident, commit + push
instead of SCP'ing the file — manual edits to mirrored dirs vanish on the
next deploy. If a library or vendored asset must live on the server but
not in the repo, add it to WEB_ROOT_EXCLUDES in .github/workflows/deploy.yml.
Use the workflow_dispatch dry-run input on deploy.yml to preview what
a deploy would change (and crucially, what it would delete) before pushing.
See issue #107 for the full rationale and mitigation list.
Before each release, run every migration through a real MySQL 8.0.36
container to catch what the static check_sql_columns.py and check_migration_idempotency.py audits miss:
- Statement order — a later migration assuming a table a previous one forgot to create.
- FK constraints that pass static parsing but blow up at runtime on real data shapes.
- Genuine non-idempotency that the static audit can't model (e.g. a trigger that errors second-time-round).
Requires docker + docker compose on your local machine.
tools/e2e-migrations/run.sh # all three phases (~30s)
tools/e2e-migrations/run.sh --skip-stale # phases 1+2 only
tools/e2e-migrations/run.sh --keep # leave container up for pokingThe script drives three phases:
- Fresh install — apply every
web/_sql/NNN_*.sqlin order to an empty DB. Any SQL error fails the run. - Idempotency — re-run the same loop. Schema row counts (information_schema.tables/columns/statistics) must be unchanged.
- Stale-DB upgrade — wipe, apply first half, then apply the rest. Catch-up must reach the same final state as fresh install.
See tools/e2e-migrations/README.md for details.
tools/audit-checks/check_migration_idempotency.py is the fast
first-pass. Flags CREATE TABLE without IF NOT EXISTS, ADD COLUMN
without IF NOT EXISTS, and INSERT without ON DUPLICATE KEY UPDATE
or INSERT IGNORE. Quote-aware splitter — ; inside string literals
and comments doesn't fragment the parse.
Some old migrations (014-018, 037, 043) flag because they used pre-multi-site
patterns without the IF NOT EXISTS clause. These have already run in
production and the Migrator wrapper skips already-applied files, so
they're safe. New migrations must pass cleanly — drop a non-idempotent
DDL in a fresh migration and the audit will catch it.
Every <script> and <link> tag pointing at a third-party CDN MUST carry
an integrity="sha384-…" attribute and crossorigin="anonymous". Without
SRI, a compromise of the CDN serves arbitrary JS/CSS to every visitor
simultaneously — and SRI is the only client-side mitigation.
The tools/audit-checks/check_cdn_sri.py script scans every PHP / HTML
file under web/ and flags any CDN tag without an integrity= attribute.
It runs in CI and locally — drop a tag without SRI and the check fails.
-
Pin the version. SRI requires exact byte matching, so
@latestand unpinned major versions (bootstrap@5) will break the check on every release. Use an exact patch version (bootstrap@5.3.3). -
Generate the hash:
curl -sL https://cdn.jsdelivr.net/npm/<package>@<version>/<file> \ | openssl dgst -sha384 -binary \ | openssl base64 -A
Prefix the output with
sha384-. -
Add to
web/_core/Asset.php:- Add a
*_VERSIONconstant (one source of truth for bumps). - Add a
CDN_*URL constant building from the version. - Add a
*_INTEGRITYhash constant from the curl/openssl pipeline. - Add a helper method (
Asset::sortableJs()style) that callsself::css()orself::js()with the constants. Helpers attach SRI automatically and route anonerrorto the local fallback.
- Add a
-
Use the helper, never raw
<script>:<?php echo \Portal\Core\Asset::sortableJs(); ?>
Inline
<script src="https://cdn…">tags will be caught by the audit AND don't get the local-fallback handler. -
Run the audit:
python3 tools/audit-checks/check_cdn_sri.py
Should report
No CDN tags missing integrity= attribute.✅
The Sortable + Swagger UI helpers ship with empty integrity constants
(TODO markers in Asset.php). The tags still render, but without
integrity verification. To fill them, run the curl/openssl command
above and update the four *_INTEGRITY constants in Asset.php.
Last updated: May 2026