From 56f4f6fe0f6c57c8982d06e8fd71b93e17e9755d Mon Sep 17 00:00:00 2001 From: Sluijsens Date: Wed, 29 Jul 2026 19:39:44 +0200 Subject: [PATCH 01/10] Adds Monitoring Setup docs and deploy-scp troubleshooting/debug fixes Monitoring Setup: operations/plans/monitoring-setup-plan.md and operations/monitoring/monitoring-instructions.md, covering Sentry alert rules on the security_event tag, UptimeRobot's 6 liveness monitors, and the two new Umami website entries for the admin SPA. deployment-instructions.md gains a missing Observability__Environment host var (without it, both environments would tag Sentry events as "Production"), the nginx client_max_body_size fix for the 413 seen on publish-test/production artifact uploads, and two troubleshooting notes on Gitea Actions re-run behaviour: re-running deploy-test/production alone loses the run's uploaded artifact, and re-running all jobs on an existing (rather than a brand new) run can replay stale secrets. deploy-scp.yaml: step names no longer show literal unresolved ${{ inputs.* }} text (Gitea doesn't interpolate that context in step names), and a temporary debug step logs PI_MAIN_USERNAME/PASSWORD length plus a username equality check to diagnose a persistent Permission denied during the SSH steps, without ever logging the secret values themselves. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015FffvxxJp5wG34Ru48GBig --- .gitea/workflows/deploy-scp.yaml | 23 ++- .../gitea-deployment-workflow/aidlc-state.md | 21 ++- .../deployment/deployment-instructions.md | 84 +++++++++++ .../monitoring/monitoring-instructions.md | 134 ++++++++++++++++++ .../operations/plans/monitoring-setup-plan.md | 127 +++++++++++++++++ 5 files changed, 382 insertions(+), 7 deletions(-) create mode 100644 aidlc-docs/features/gitea-deployment-workflow/operations/monitoring/monitoring-instructions.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/operations/plans/monitoring-setup-plan.md diff --git a/.gitea/workflows/deploy-scp.yaml b/.gitea/workflows/deploy-scp.yaml index 67bce96..a445419 100644 --- a/.gitea/workflows/deploy-scp.yaml +++ b/.gitea/workflows/deploy-scp.yaml @@ -42,6 +42,19 @@ jobs: deploy: runs-on: ubuntu-latest steps: + # TEMPORARY — diagnosing a persistent "Permission denied, please try again." (sshpass exit 5) + # on the SSH steps below. Never prints the actual secret values, only lengths and a boolean + # match against the expected username, so it's safe to leave in a log — but remove this step + # once the PI_MAIN_* secrets are confirmed correct; it has no purpose beyond that diagnosis. + - name: Debug - verify PI_MAIN_USERNAME/PASSWORD secrets (remove after diagnosis) + run: | + echo "PI_MAIN_USERNAME length: ${#PI_MAIN_USERNAME}" + echo "PI_MAIN_USERNAME equals 'gitea-workflow': $([ "$PI_MAIN_USERNAME" = "gitea-workflow" ] && echo yes || echo no)" + echo "PI_MAIN_PASSWORD length: ${#PI_MAIN_PASSWORD}" + env: + PI_MAIN_USERNAME: ${{ secrets.PI_MAIN_USERNAME }} + PI_MAIN_PASSWORD: ${{ secrets.PI_MAIN_PASSWORD }} + - name: Compute release timestamp id: release run: echo "timestamp=$(date -u +%Y%m%d%H%M%S)" >> "$GITHUB_OUTPUT" @@ -56,9 +69,13 @@ jobs: # The backup script itself lives on the host (created once during Operations host setup) and # is only invoked here — no database connection string or credential is ever known to this # workflow, keeping D-16 ("runtime secrets live in host environment variables") intact. - - name: Back up database (${{ inputs.environment }}) + # Gitea Actions does not resolve `${{ inputs.* }}` inside a step's `name:` (unlike inside + # `run:`, where it works fine — see the echo below) — kept static rather than showing the + # literal, unresolved `${{ inputs.environment }}` text in the log. + - name: Back up database if: ${{ inputs.run_db_backup }} run: | + echo "Environment: ${{ inputs.environment }}" sudo apt-get update && sudo apt-get install -y sshpass sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \ -p ${{ secrets.PI_MAIN_PORT }} \ @@ -74,8 +91,10 @@ jobs: # runner with "failed to attach to container: unable to upgrade to tcp, received 409", a known # limitation of Podman's Docker-compatible API for the attach/log-streaming that container # actions rely on (D-05). A plain scp command needs no nested container. - - name: Upload release to ${{ inputs.environment }} (${{ inputs.transport }}) + # Same Gitea Actions limitation as the step above — static name, value logged via echo instead. + - name: Upload release run: | + echo "Environment: ${{ inputs.environment }}, transport: ${{ inputs.transport }}" sudo apt-get update && sudo apt-get install -y sshpass RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}" sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \ diff --git a/aidlc-docs/features/gitea-deployment-workflow/aidlc-state.md b/aidlc-docs/features/gitea-deployment-workflow/aidlc-state.md index f45a7b2..20ef335 100644 --- a/aidlc-docs/features/gitea-deployment-workflow/aidlc-state.md +++ b/aidlc-docs/features/gitea-deployment-workflow/aidlc-state.md @@ -179,8 +179,15 @@ Execution rounds (Q4 = B): **R1** = U1 + U2 · **R2** = U3 + U4 · **R3** = U5 + - [x] Build and Test — **EXECUTE** — Complete 2026-07-28. 609 unit tests (372 backend + 237 frontend), 0 vulnerable packages, 0 build errors. Plus live-host integration verification: static content/SPA-fallback/header-scoping, real master↔slave communication proving the persistent key ring survives a process restart, and W3C trace-id correlation in real log output. Artifacts in `construction/build-and-test/` ### OPERATIONS -- [ ] Deployment Setup — **EXECUTE** -- [ ] Monitoring Setup — **EXECUTE** +- [x] Deployment Setup — **EXECUTE** — Complete 2026-07-28, extended 2026-07-29 (MariaDB migration, + reverse proxy topology correction, artifact-upload 413 fix in `deployment-instructions.md` § 1.7.3, + missing `Observability__Environment` host var added § 1.5). Artifacts: `operations/deployment/` +- [x] Monitoring Setup — **EXECUTE** — Complete 2026-07-29. Plan answered (Sentry project reused, + UptimeRobot account reused, Umami website entries created by user, 20-events/5-min default alert + threshold). User has applied the real values in Gitea variables, UptimeRobot and Sentry directly + (DSN, Umami website IDs, alert rules, 6 monitors) — not verified by execution from this session + (no access to those external accounts), taken on the user's report. Artifacts: + `operations/plans/monitoring-setup-plan.md`, `operations/monitoring/monitoring-instructions.md` - [ ] Production Readiness Validation — **EXECUTE** (includes the `dotnet-appsettings` compliance gate) ## Execution Plan Summary @@ -190,9 +197,13 @@ Execution rounds (Q4 = B): **R1** = U1 + U2 · **R2** = U3 + U4 · **R3** = U5 + ## Current Status - **Lifecycle Phase**: OPERATIONS -- **Current Stage**: Build and Test **complete** 2026-07-28 (all 7 units, full-suite + live-host integration verification). CONSTRUCTION phase is now closed -- **Next Stage**: Deployment Setup (Operations Configuration = Yes, decided at Requirements Analysis) -- **Status**: Rounds 1–4 all done, nothing pushed. Entering Operations phase +- **Current Stage**: Deployment Setup complete (2026-07-28, extended 2026-07-29). Monitoring Setup + complete 2026-07-29 — user applied real values in Gitea, UptimeRobot and Sentry +- **Next Stage**: Production Readiness Validation (includes the `dotnet-appsettings` compliance gate) +- **Status**: CI pipeline built and merging via an open PR; `workflow_dispatch` manual testing of + `deploy-test`/`deploy-production` (and therefore a real end-to-end run touching the monitors/alerts + just configured) blocked until that PR merges to `master` (Gitea Actions only shows manual-dispatch + workflows that exist on the default branch) ## Round 2 Design Record (2026-07-28) - Functional Design U3 + U4 complete and committed (`357d395`) diff --git a/aidlc-docs/features/gitea-deployment-workflow/operations/deployment/deployment-instructions.md b/aidlc-docs/features/gitea-deployment-workflow/operations/deployment/deployment-instructions.md index d4438fc..809927e 100644 --- a/aidlc-docs/features/gitea-deployment-workflow/operations/deployment/deployment-instructions.md +++ b/aidlc-docs/features/gitea-deployment-workflow/operations/deployment/deployment-instructions.md @@ -182,6 +182,14 @@ MasterModule__MasterUrl=https:// # continues (see Observability section in README.md). Observability__SentryDsn= +# REQUIRED if the DSN above is set, and must differ between this file and the other environment's. +# D-19 uses one Sentry project for both test and production, distinguished only by this tag. +# ASPNETCORE_ENVIRONMENT is deliberately "Production" for both environments (see note below), so +# without this explicit override, ObservabilityOptions.Environment falls back to +# ASPNETCORE_ENVIRONMENT and every event — test and production alike — would be tagged "Production", +# silently defeating D-19's whole point of telling them apart in Sentry. +Observability__Environment= + # Optional — both only needed if this instance uses Umami analytics (VITE_UMAMI_SCRIPT_URL set for # the frontend build). Leave both lines out entirely if you don't use Umami; there is no other # origin either one needs by default. @@ -362,6 +370,82 @@ server { } ``` +#### 1.7.3 Artifact Upload Body Size (413 on `publish-test`/`publish-production`) + +The Gitea instance itself (`gitea.slpsoftware.nl`) sits behind the same proxy Pi as every other +domain in § 1.7 — Actions' own web UI/API traffic is proxied through it exactly like +`test.slpsoftware.nl` and `slpsoftware.nl` are. Gitea Actions' artifact upload +(`actions/upload-artifact`) sends the build output in chunks; if a chunk exceeds nginx's +`client_max_body_size` (default 1m), nginx itself rejects it with `413 Request Entity Too Large` +before the request ever reaches Gitea — surfacing in the `publish-test`/`publish-production` job log +as repeated `A 413 status code has been received, will attempt to retry the upload` followed by +`Retry limit has been reached` on individual files once the action's retries are exhausted. + +**pi-main is not involved** — § 1.7 already established it runs no nginx and holds no certificates +for any of these domains; this is purely a proxy Pi setting. + +**Fix — set once, globally, not per server block.** `client_max_body_size` is inherited +(`http` → `server` → `location`); setting it in the top-level `http {}` block covers every current +and future domain's server blocks — both the `listen 80` and `listen 443` blocks certbot manages — +without needing to repeat it each time § 1.7.1's procedure is run for a new domain. + +🌐 **proxy Pi / root:** +```nginx +# /etc/nginx/nginx.conf, inside the http { } block: +http { + client_max_body_size 512m; + ... +} +``` +```bash +sudo nginx -t && sudo systemctl reload nginx +``` + +#### 1.7.4 Never Re-Run `deploy-test`/`deploy-production` Alone — Gitea Platform Limitation + +`deploy-test` and `deploy-production` in `continuous_integration.yaml` are not ordinary jobs — each +one *calls* the reusable `deploy-scp.yaml` workflow (`uses: ./.gitea/workflows/deploy-scp.yaml`). +Re-running **only** one of these two jobs after a failure (Gitea's per-job "re-run" action) is a +known Gitea Actions limitation, not a bug in either workflow file here: Gitea does not cleanly resume +a `workflow_call` job inside its original run — it re-executes the call in a way that loses access to +that run's already-uploaded artifact. The `Download build artifact` step then fails immediately with: +``` +List Artifacts - Error is not retryable +Status Code: 404 +Error: List Artifacts failed: Artifact service responded with 404 +``` +even though `publish-test`/`publish-production` genuinely succeeded and uploaded the artifact +moments earlier in the same run (retention had not expired — this is not the same failure mode as +§ 1.7.3, and not a retention issue at all). + +**Fix: re-run the entire workflow, not just this job** — but see § 1.7.5 immediately below before +doing that: Gitea's "re-run all jobs" on an *existing* run is not the same as triggering a genuinely +new run, and has its own, different failure mode. + +#### 1.7.5 "Re-Run All Jobs" Can Silently Reuse Stale Secrets/Variables — Trigger a New Run Instead + +Symptom: a secret (e.g. `PI_MAIN_PASSWORD`) was wrong, causing `sshpass`/`ssh` to fail with +`Permission denied, please try again.` (`sshpass` exit code 5 — the password itself was rejected, +not a connectivity or config problem). The secret is corrected in Gitea's UI and confirmed working +via a manual SSH test with the same value. **"Re-run all jobs" is used on the existing, already-failed +run — and it fails again, identically**, as if the fix never happened. + +This matches a documented behaviour of Actions-style re-run implementations, seen concretely in +GitHub's own tooling ([cli/cli#13522](https://github.com/cli/cli/issues/13522)): re-running an +*existing* run can replay against secrets/variables as they were **when that run was first created**, +not their current values — particularly for a job that calls a reusable workflow with +`secrets: inherit` (exactly what `deploy-test`/`deploy-production` do here). The documented contract +("secrets are fetched at the time of the re-run") does not hold in practice for this case. A genuinely +**new** run (fresh push, or `workflow_dispatch`) always fetches current values correctly — only +re-running an existing run risks the stale snapshot. + +**Net effect of § 1.7.4 + § 1.7.5 together**: neither of Gitea's two "re-run" options is fully safe +for `deploy-test`/`deploy-production` — "re-run this job alone" loses the artifact, "re-run all jobs" +can keep stale secrets. **The one reliable option is to trigger a brand new run** (an actual push, or +`workflow_dispatch`'s "Run workflow" button) rather than using either re-run action on a failed run, +whenever a secret or variable was just changed to fix that failure. Re-running an existing run is +only safe when nothing about its secrets/variables changed since it was created. + ### 1.8 Database Backup Credentials (§ 4 depends on this) 👤 **pi-main / `gitea-workflow`:** diff --git a/aidlc-docs/features/gitea-deployment-workflow/operations/monitoring/monitoring-instructions.md b/aidlc-docs/features/gitea-deployment-workflow/operations/monitoring/monitoring-instructions.md new file mode 100644 index 0000000..e8c91e4 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/operations/monitoring/monitoring-instructions.md @@ -0,0 +1,134 @@ +# Monitoring Instructions + +All three services below were built into the application during Construction (U4) — this document +only wires them to the real accounts and writes down the configuration that necessarily lives +outside this repository (Sentry alert rules, UptimeRobot monitors, Umami website entries). + +**Who does what, where** — reuses the labels from `deployment-instructions.md` § "Who runs what, +where", plus the external services themselves: +- 👤 **pi-main / `gitea-workflow`** — the environment's `shared/env` file (§ 1.5 of + `deployment-instructions.md`) +- ☁️ **Gitea web UI** — repository Variables (Settings → Actions → Variables) +- ☁️ **Sentry web UI** — project settings and alert rules +- ☁️ **UptimeRobot web UI** — monitors and alert contacts +- ☁️ **Umami web UI** — website entries + +## 1. Sentry + +One project for the whole CMS (D-19); `Observability__Environment` (host-side, § 1.5) and +`VITE_APP_ENV` (build-time) are what tell test and production events apart in that one project — +there is no separate DSN per environment. + +### 1.1 Gitea variable + +☁️ **Gitea web UI** — Settings → Actions → Variables: + +| Variable | Value | +|---|---| +| `VITE_SENTRY_DSN` | the project's DSN (same value used by both `publish-test` and `publish-production`) | + +### 1.2 Host configuration + +Already documented in `deployment-instructions.md` § 1.5 — `Observability__SentryDsn` and +`Observability__Environment` in each environment's `shared/env` file. `Observability__Environment` +**must** differ between the two files (`test` / `production`); see that section's note on why +`ASPNETCORE_ENVIRONMENT` can't be reused for this. + +### 1.3 Alert rules (FR-19) + +The six `security_event` tag values already emitted by `SecurityEvents.cs` — alert rules must filter +on the **tag**, not the message text (`nfr-design-patterns.md` Pattern 6): + +| `security_event` tag value | Rule type | Threshold | +|---|---|---| +| `failed_login` | Count-based | > 20 events in 5 minutes | +| `authorization_denied` | Count-based | > 20 events in 5 minutes | +| `master_api_key_rejected` | Count-based | > 20 events in 5 minutes | +| `admin_bypass_rejected` | Count-based | > 20 events in 5 minutes | +| `rate_limit_triggered` | Count-based | > 20 events in 5 minutes | +| `migration_failure` | Always-fire | any occurrence (already `Critical` level — the process is exiting) | + +20-in-5-minutes is a starting default (no production traffic history exists yet to tune against) — +revisit once real traffic volume is known. + +☁️ **Sentry web UI** — Alerts → Create Alert Rule, once per row above: +1. Condition: `tags.security_event equals ` +2. For the five count-based rules: "when greater than 20 events, in 5 minutes" (or the platform's + equivalent rate-window phrasing) +3. For `migration_failure`: no count condition — fire on every matching event +4. Action: notify via whichever channel/integration alerts should reach (same destination used + elsewhere for this project is fine — nothing here requires a dedicated channel) + +**Note (DEV-01, accepted deviation)**: Sentry's plan in use retains events for roughly 30 days, +short of the 90-day SECURITY-14 minimum. Already accepted as a cost decision at Requirements +Analysis — nothing to configure here, just to be aware of if investigating an old event. + +## 2. UptimeRobot + +Six monitors — `/health`, `/`, `/admin` × test/production (D-23). What each does and does not prove +(FR-17): + +| URL | Proves | Does NOT prove | +|---|---|---| +| `https://test.slpsoftware.nl/health` | The test process is alive (liveness only, D-21) | Database connectivity, migrations applied — no DB check by design (Q17=A) | +| `https://test.slpsoftware.nl/` | Proxy Pi → pi-main routing and static hosting work for test | The API or admin SPA work at all | +| `https://test.slpsoftware.nl/admin` | The admin SPA is served and its assets aren't blocked by CSP | Login/auth actually succeeds — this only checks the shell loads | +| `https://slpsoftware.nl/health` | Same as above, production | Same as above, production | +| `https://slpsoftware.nl/` | Same as above, production | Same as above, production | +| `https://slpsoftware.nl/admin` | Same as above, production | Same as above, production | + +☁️ **UptimeRobot web UI** — Add New Monitor, once per row above: +- Monitor Type: **HTTP(s)** (not Keyword/Port/Ping) — checks only the response status code (2xx/3xx), + the same liveness signal `deploy-scp.yaml`'s own `curl -f` health check already relies on +- Interval: 5 minutes is enough for all six — `/health` recovers within a process restart + (`Restart=on-failure`, `RestartSec=5` in the systemd units), no need for a tighter interval +- Alert Contact: the existing contact already configured on this account + +**Free-plan note**: 6 monitors at 5-minute intervals fits comfortably within the free plan's 50-monitor +cap and 5-minute minimum interval — no paid tier needed for this feature's monitoring. One gap the +free plan leaves open: no SSL-certificate-expiry monitoring. Certbot's systemd timer renews +automatically (`deployment-instructions.md` § 1.7.1), so the risk is low, but a silently failed +renewal would only be caught reactively — once an HTTP(s) check itself starts failing — rather than +warned about in advance. Accepted as a known gap, not built around, consistent with this project's +other documented deviations (e.g. DEV-01's log-retention gap). + +## 3. Umami + +Reuses the existing self-hosted instance at `analytics.slpsoftware.nl` (D-24/ASM-05). This section +covers only the **admin SPA's** two website entries — the public website's Umami wiring (also +required by D-25) belongs to whichever website workspace serves `wwwroot/web/` (FR-09/FR-16), not to +this deploy pipeline. + +☁️ **Umami web UI** — Websites → Add website, twice (once per environment), domain set to the +admin SPA's real URL (`test.slpsoftware.nl/admin`, `slpsoftware.nl/admin`) — note the resulting +website ID for each. + +☁️ **Gitea web UI** — Settings → Actions → Variables: + +| Variable | Value | +|---|---| +| `VITE_UMAMI_SCRIPT_URL` | `https://analytics.slpsoftware.nl/script.js` (shared — same instance, both environments) | +| `VITE_UMAMI_WEBSITE_ID_TEST` | the test website's ID from the step above | +| `VITE_UMAMI_WEBSITE_ID_PRODUCTION` | the production website's ID from the step above | + +**These must stay in sync with two other Gitea variables that already exist from U5** +(`SECURITY_ALLOWED_SCRIPT_ORIGINS_TEST` / `_PRODUCTION`) and with the matching +`SecurityHeaders__AllowedScriptOrigins__0` / `_AllowedConnectOrigins__0` lines in each environment's +host `shared/env` file (`deployment-instructions.md` § 1.5) — all four must name the same +`https://analytics.slpsoftware.nl` origin, or either the CI gate (REF-U5-01) fails the build, or the +script loads in CI but is silently blocked by CSP on the real host (the gate cannot see the host +file, only the Gitea variable side of this). + +## 4. Gitea Variables — Full Summary + +For reference, every Actions variable this document and `deployment-instructions.md` § 1.9 together +require: + +| Variable | Scope | Set by | +|---|---|---| +| `VITE_SENTRY_DSN` | shared | § 1.1 above | +| `VITE_UMAMI_SCRIPT_URL` | shared | § 3 above | +| `VITE_UMAMI_WEBSITE_ID_TEST` | test | § 3 above | +| `VITE_UMAMI_WEBSITE_ID_PRODUCTION` | production | § 3 above | +| `SECURITY_ALLOWED_SCRIPT_ORIGINS_TEST` / `_PRODUCTION` | per environment | already set at Deployment Setup — must match § 3's Umami origin | +| `DEPLOY_PATH_*`, `SERVICE_NAME_*`, `HEALTH_CHECK_URL_*` | per environment | `deployment-instructions.md` § 1.9 (unrelated to monitoring, listed there) | diff --git a/aidlc-docs/features/gitea-deployment-workflow/operations/plans/monitoring-setup-plan.md b/aidlc-docs/features/gitea-deployment-workflow/operations/plans/monitoring-setup-plan.md new file mode 100644 index 0000000..6ba9a38 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/operations/plans/monitoring-setup-plan.md @@ -0,0 +1,127 @@ +# Monitoring Setup Plan + +## Context already established (not re-asked) + +The observability *stack* and *what it must cover* were already decided in Requirements Analysis and +built during Construction (U4) — this stage is about configuring the real external services and +writing the operational documentation, not re-deciding the approach: + +- **Stack**: Sentry (backend `Sentry.AspNetCore` + frontend `@sentry/react`), self-hosted Umami + analytics, UptimeRobot uptime monitoring (D-18, D-24, requirements.md § "Monitoring and observability") +- **Sentry projects**: **one** project for the whole CMS, `environment` tag distinguishes + test/production (D-19) — so one DSN, reused by both environments and both frontend/backend +- **Sentry is optional by design**: an absent DSN disables it cleanly, console logging stays active + (BR-U4-08) — nothing breaks if a value is left blank temporarily +- **Alertable events already exist in code**: six `security_event` tag values are emitted today — + `failed_login`, `authorization_denied`, `master_api_key_rejected`, `admin_bypass_rejected`, + `rate_limit_triggered`, `migration_failure` (`SecurityEvents.cs`). Alert rules must filter on the + `security_event` tag, not message text (`nfr-design-patterns.md` Pattern 6) — this is explicitly + named as **Operations — Monitoring Setup** work in `logical-components.md` (FR-19) +- **UptimeRobot scope**: `/health`, `/` (public website) and `/admin`, per environment — 6 monitors + total (D-23) +- **Umami scope**: reuse the existing self-hosted instance at `analytics.slpsoftware.nl`, add new + website entries for this CMS rather than standing up a new instance (D-24/ASM-05). Both the admin + SPA (via `VITE_UMAMI_WEBSITE_ID_`, built by this pipeline) and the public website (via the + separate website-workspace, FR-09/FR-16, out of this pipeline's scope) are measured (D-25) — this + stage only configures the **admin SPA's** two website entries; the public website's Umami wiring + belongs to whichever website workspace serves it +- **Health endpoint**: liveness only, no DB check, already built and already on the + `AvailabilityMiddleware` bypass list (D-21) — nothing left to build, only to point UptimeRobot at +- **Log retention deviation**: accepted knowingly (DEV-01) — Sentry's ~30-day retention against the + 90-day SECURITY-14 minimum. No action here beyond documenting it. + +## Question 1: Include Monitoring Setup? + +Given U4 built all the alertable-event plumbing specifically so that Operations could wire up real +alert rules and monitors on top of it, declining this stage would leave that code inert — events +would be emitted into a Sentry project that doesn't exist yet, with no alert rule reading the tag, +and no UptimeRobot monitor watching `/health`. Recommended: A. + +A) Yes — configure the real services and document it (Recommended) +B) No — monitoring is handled elsewhere or not needed +C) Not sure — suggest an approach and I'll decide + +X) Other (please describe after [Answer]: tag below) + +[Answer]:A + +## Question 2: Monitoring Method + +Confirms the stack already decided in Requirements Analysis — asked per the mandatory format, not +because it's genuinely open. + +A) Sentry + self-hosted Umami + UptimeRobot, exactly as scoped above (Recommended) +B) Something else entirely — describe below + +X) Other (please describe after [Answer]: tag below) + +[Answer]:A + +--- + +The remaining questions are the genuinely open items — real account/service facts that only you +know, which nothing in Construction could decide for you. + +## Question 3: Sentry Project + +Does a Sentry project for this CMS already exist (e.g. created ad hoc while testing U4 locally), or +does this stage need to walk through creating one from scratch? + +A) Already exists — I'll provide the DSN after [Answer]: below +B) Doesn't exist yet — walk me through creating one (org, project platform picks for .NET + React, + where to find the DSN afterward) +C) Not yet, and I'd rather set it up myself later — document it as a TODO with exactly which Gitea + secrets/variables need the DSN once it exists + +X) Other (please describe after [Answer]: tag below) + +[Answer]:A — DSN to be added directly to the Gitea secret/variable once shared (kept out of this doc) + +## Question 4: Sentry Alert Rule Thresholds + +FR-19 requires alert rules for repeated auth failures and authorization violations, filtering on the +`security_event` tag (values: `failed_login`, `authorization_denied`, `master_api_key_rejected`, +`admin_bypass_rejected`, `rate_limit_triggered`; `migration_failure` is `Critical`-level and worth +its own always-fire rule regardless of count). What threshold should the repeated-failure rules use? + +A) A single reasonable default for all of them (e.g. 20 occurrences in 5 minutes) — document it as a + starting point, tune later from real traffic (Recommended) +B) I'll specify exact thresholds per event type — describe below +C) No count threshold — alert on every occurrence of each of the six (noisier, but nothing is ever + missed) + +X) Other (please describe after [Answer]: tag below) + +[Answer]:A + +## Question 5: UptimeRobot Account and Alert Contacts + +6 monitors are needed (`/health`, `/`, `/admin` × test/production). Do you have an UptimeRobot +account already (e.g. from the reference `SlpSoftware` project), and where should alerts go? + +A) Existing account, reuse it — alert contact(s): describe after [Answer]: below (email, and/or + any other channel already configured there) +B) Need a new account/monitor group set up from scratch — walk me through it +C) Not sure yet — document the 6 monitors' exact URLs and recommended check interval as a checklist, + I'll create them myself + +X) Other (please describe after [Answer]: tag below) + +[Answer]:A — existing account, alert contact to be confirmed + +## Question 6: Umami Website Entries (Admin SPA) + +Two new website entries are needed on the existing `analytics.slpsoftware.nl` instance — one per +environment — to obtain the `VITE_UMAMI_WEBSITE_ID_TEST`/`_PRODUCTION` values the CI workflow already +expects as Gitea variables (`continuous_integration.yaml` reads `vars.VITE_UMAMI_WEBSITE_ID_TEST`/ +`_PRODUCTION`). + +A) I have access to the Umami instance — I'll create the two entries and provide the website IDs + after [Answer]: below +B) Walk me through creating them (Umami's own UI/API steps) +C) Not yet — document it as a TODO with the exact variable names the pipeline expects, I'll fill + them in before the first real deploy + +X) Other (please describe after [Answer]: tag below) + +[Answer]:A — website IDs to be added directly to Gitea variables once created (kept out of this doc) From 92547efd4705783284746b044a326d1cc312d6f8 Mon Sep 17 00:00:00 2001 From: Sluijsens Date: Wed, 29 Jul 2026 19:41:17 +0200 Subject: [PATCH 02/10] Allows deploy-test to run on pull_request events too So PR pushes deploy straight to the test environment, without a manual workflow_dispatch each time. Only one test environment exists, so this means concurrent PRs would overwrite each other's deploy - acceptable with a single active PR, worth revisiting once that's no longer true. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015FffvxxJp5wG34Ru48GBig --- .gitea/workflows/continuous_integration.yaml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/continuous_integration.yaml b/.gitea/workflows/continuous_integration.yaml index 18ee877..21f0e2e 100644 --- a/.gitea/workflows/continuous_integration.yaml +++ b/.gitea/workflows/continuous_integration.yaml @@ -318,10 +318,15 @@ jobs: path: ${{ env.ARTIFACT_NAME_PRODUCTION }} retention-days: 1 - # Automatic test deploy on push to master, or on any workflow_dispatch run (FR-03, D-01, D-09). + # Automatic test deploy on push to master, any workflow_dispatch run, or a pull_request event + # (FR-03, D-01, D-09 — pull_request added as a deliberate extension beyond the original scope, so + # PR pushes can be verified against the real test environment without a manual workflow_dispatch + # each time). There is only one shared test environment: with more than one PR open at once, each + # push to any of them will redeploy and overwrite it — fine for a single active PR, revisit + # (e.g. gate behind a label, or restrict to one branch) before that becomes a real scenario. deploy-test: needs: [publish-test, config] - if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/master') + if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/master') uses: ./.gitea/workflows/deploy-scp.yaml secrets: inherit with: From 3776c0f27bd4915d54a71167dcda8c8ca9da28ea Mon Sep 17 00:00:00 2001 From: Sluijsens Date: Wed, 29 Jul 2026 20:39:36 +0200 Subject: [PATCH 03/10] Adds a trimmed-length check to the PI_MAIN_PASSWORD debug step Username is now confirmed correct (14 chars, exact match). This checks whether the password secret carries leading/trailing whitespace or a stray newline picked up when it was pasted into Gitea - the length differs from the trimmed length if so, without ever logging the password's actual characters. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015FffvxxJp5wG34Ru48GBig --- .gitea/workflows/deploy-scp.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitea/workflows/deploy-scp.yaml b/.gitea/workflows/deploy-scp.yaml index a445419..8b168db 100644 --- a/.gitea/workflows/deploy-scp.yaml +++ b/.gitea/workflows/deploy-scp.yaml @@ -51,6 +51,8 @@ jobs: echo "PI_MAIN_USERNAME length: ${#PI_MAIN_USERNAME}" echo "PI_MAIN_USERNAME equals 'gitea-workflow': $([ "$PI_MAIN_USERNAME" = "gitea-workflow" ] && echo yes || echo no)" echo "PI_MAIN_PASSWORD length: ${#PI_MAIN_PASSWORD}" + PW_TRIMMED="$(printf '%s' "$PI_MAIN_PASSWORD" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + echo "PI_MAIN_PASSWORD trimmed length: ${#PW_TRIMMED}" env: PI_MAIN_USERNAME: ${{ secrets.PI_MAIN_USERNAME }} PI_MAIN_PASSWORD: ${{ secrets.PI_MAIN_PASSWORD }} From 51488d6d94b74e6618d03ec45a8b610878df262c Mon Sep 17 00:00:00 2001 From: Sluijsens Date: Wed, 29 Jul 2026 20:42:57 +0200 Subject: [PATCH 04/10] Routes PI_MAIN_* secrets through env vars instead of inline interpolation Every ssh/scp step interpolated ${{ secrets.PI_MAIN_PASSWORD }} and ${{ secrets.PI_MAIN_USERNAME }} directly into the run: shell text. Gitea/GitHub Actions substitutes that as literal text before bash ever sees it, so any shell-metacharacter in the password ($, `, ", \) gets re-interpreted by bash instead of passed through - silently changing what sshpass actually receives. This plausibly explains a persistent "Permission denied" even after the username and password length were both confirmed correct via the debug step (which used env: and so never hit this). All five steps now receive SSH_USER/SSH_PASS/SSH_PORT/SSH_HOST via env:, which bash treats as opaque values with no re-parsing. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015FffvxxJp5wG34Ru48GBig --- .gitea/workflows/deploy-scp.yaml | 61 ++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/.gitea/workflows/deploy-scp.yaml b/.gitea/workflows/deploy-scp.yaml index 8b168db..13035c1 100644 --- a/.gitea/workflows/deploy-scp.yaml +++ b/.gitea/workflows/deploy-scp.yaml @@ -76,13 +76,18 @@ jobs: # literal, unresolved `${{ inputs.environment }}` text in the log. - name: Back up database if: ${{ inputs.run_db_backup }} + env: + SSH_USER: ${{ secrets.PI_MAIN_USERNAME }} + SSH_PASS: ${{ secrets.PI_MAIN_PASSWORD }} + SSH_PORT: ${{ secrets.PI_MAIN_PORT }} + SSH_HOST: ${{ secrets.PI_MAIN_ADDRESS }} run: | echo "Environment: ${{ inputs.environment }}" sudo apt-get update && sudo apt-get install -y sshpass - sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \ - -p ${{ secrets.PI_MAIN_PORT }} \ + sshpass -p "$SSH_PASS" ssh \ + -p "$SSH_PORT" \ -o StrictHostKeyChecking=no \ - ${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \ + "$SSH_USER@$SSH_HOST" \ "bash ~/scripts/backup-slpsoftware-db.sh ${{ inputs.environment }}" # Uploads the published output to a fresh, timestamped release directory rather than @@ -95,20 +100,25 @@ jobs: # actions rely on (D-05). A plain scp command needs no nested container. # Same Gitea Actions limitation as the step above — static name, value logged via echo instead. - name: Upload release + env: + SSH_USER: ${{ secrets.PI_MAIN_USERNAME }} + SSH_PASS: ${{ secrets.PI_MAIN_PASSWORD }} + SSH_PORT: ${{ secrets.PI_MAIN_PORT }} + SSH_HOST: ${{ secrets.PI_MAIN_ADDRESS }} run: | echo "Environment: ${{ inputs.environment }}, transport: ${{ inputs.transport }}" sudo apt-get update && sudo apt-get install -y sshpass RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}" - sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \ - -p ${{ secrets.PI_MAIN_PORT }} \ + sshpass -p "$SSH_PASS" ssh \ + -p "$SSH_PORT" \ -o StrictHostKeyChecking=no \ - ${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \ + "$SSH_USER@$SSH_HOST" \ "mkdir -p $RELEASE_DIR" - sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" scp \ - -P ${{ secrets.PI_MAIN_PORT }} \ + sshpass -p "$SSH_PASS" scp \ + -P "$SSH_PORT" \ -o StrictHostKeyChecking=no \ -r ${{ inputs.artifact_name }}/* \ - ${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }}:"$RELEASE_DIR"/ + "$SSH_USER@$SSH_HOST:$RELEASE_DIR/" # The customer's public website (wwwroot/web) must survive every CMS deploy (FR-08, ASM-01). # It lives outside the swapped release directory in a persistent shared/ folder, and is @@ -118,12 +128,17 @@ jobs: # containing only the placeholder page) is removed before the symlink is created, so it never # shadows the persistent content. - name: Link persistent website content + env: + SSH_USER: ${{ secrets.PI_MAIN_USERNAME }} + SSH_PASS: ${{ secrets.PI_MAIN_PASSWORD }} + SSH_PORT: ${{ secrets.PI_MAIN_PORT }} + SSH_HOST: ${{ secrets.PI_MAIN_ADDRESS }} run: | RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}" - sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \ - -p ${{ secrets.PI_MAIN_PORT }} \ + sshpass -p "$SSH_PASS" ssh \ + -p "$SSH_PORT" \ -o StrictHostKeyChecking=no \ - ${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \ + "$SSH_USER@$SSH_HOST" \ "mkdir -p ${{ inputs.deploy_path }}/shared/wwwroot-web && \ rm -rf $RELEASE_DIR/wwwroot/web && \ ln -s ../../../shared/wwwroot-web $RELEASE_DIR/wwwroot/web" @@ -133,12 +148,17 @@ jobs: # directory. The process is then restarted so it picks up the new assemblies — a running .NET # process holds on to the ones it already loaded. - name: Switch current release and restart service + env: + SSH_USER: ${{ secrets.PI_MAIN_USERNAME }} + SSH_PASS: ${{ secrets.PI_MAIN_PASSWORD }} + SSH_PORT: ${{ secrets.PI_MAIN_PORT }} + SSH_HOST: ${{ secrets.PI_MAIN_ADDRESS }} run: | RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}" - sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \ - -p ${{ secrets.PI_MAIN_PORT }} \ + sshpass -p "$SSH_PASS" ssh \ + -p "$SSH_PORT" \ -o StrictHostKeyChecking=no \ - ${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \ + "$SSH_USER@$SSH_HOST" \ "ln -sfn $RELEASE_DIR ${{ inputs.deploy_path }}/current && \ systemctl --user restart ${{ inputs.service_name }}" @@ -163,9 +183,14 @@ jobs: # a re-point-and-restart away without rebuilding. Only runs after a passing health check — # pruning after a failed check could leave the only other release as the sole survivor. - name: Prune old releases + env: + SSH_USER: ${{ secrets.PI_MAIN_USERNAME }} + SSH_PASS: ${{ secrets.PI_MAIN_PASSWORD }} + SSH_PORT: ${{ secrets.PI_MAIN_PORT }} + SSH_HOST: ${{ secrets.PI_MAIN_ADDRESS }} run: | - sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \ - -p ${{ secrets.PI_MAIN_PORT }} \ + sshpass -p "$SSH_PASS" ssh \ + -p "$SSH_PORT" \ -o StrictHostKeyChecking=no \ - ${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \ + "$SSH_USER@$SSH_HOST" \ "cd ${{ inputs.deploy_path }}/releases && ls -1t | tail -n +3 | xargs -r rm -rf" From d51285d9ddc38f9a5f8efa7442ca365b1e2e3b75 Mon Sep 17 00:00:00 2001 From: Sluijsens Date: Thu, 30 Jul 2026 00:28:29 +0200 Subject: [PATCH 05/10] Removes the secret-diagnosis debug step, fixes wwwroot symlink parent dir The PI_MAIN_USERNAME/PASSWORD debug step served its purpose (found the shell-metacharacter password bug fixed in 51488d6) and is removed now that deploy-test gets past authentication. "Link persistent website content" then failed with `ln: failed to create symbolic link '.../wwwroot/web': No such file or directory` - dotnet publish only emits a wwwroot/ folder when the source project's own wwwroot has content, so a release built before any frontend/website content exists can land with no wwwroot/ directory at all, not merely an empty wwwroot/web/. `ln -s` can't create the missing parent directory itself. Added `mkdir -p $RELEASE_DIR/wwwroot` before the existing rm/ln pair to guarantee the parent exists either way. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015FffvxxJp5wG34Ru48GBig --- .gitea/workflows/deploy-scp.yaml | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/.gitea/workflows/deploy-scp.yaml b/.gitea/workflows/deploy-scp.yaml index 13035c1..667c721 100644 --- a/.gitea/workflows/deploy-scp.yaml +++ b/.gitea/workflows/deploy-scp.yaml @@ -42,21 +42,6 @@ jobs: deploy: runs-on: ubuntu-latest steps: - # TEMPORARY — diagnosing a persistent "Permission denied, please try again." (sshpass exit 5) - # on the SSH steps below. Never prints the actual secret values, only lengths and a boolean - # match against the expected username, so it's safe to leave in a log — but remove this step - # once the PI_MAIN_* secrets are confirmed correct; it has no purpose beyond that diagnosis. - - name: Debug - verify PI_MAIN_USERNAME/PASSWORD secrets (remove after diagnosis) - run: | - echo "PI_MAIN_USERNAME length: ${#PI_MAIN_USERNAME}" - echo "PI_MAIN_USERNAME equals 'gitea-workflow': $([ "$PI_MAIN_USERNAME" = "gitea-workflow" ] && echo yes || echo no)" - echo "PI_MAIN_PASSWORD length: ${#PI_MAIN_PASSWORD}" - PW_TRIMMED="$(printf '%s' "$PI_MAIN_PASSWORD" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" - echo "PI_MAIN_PASSWORD trimmed length: ${#PW_TRIMMED}" - env: - PI_MAIN_USERNAME: ${{ secrets.PI_MAIN_USERNAME }} - PI_MAIN_PASSWORD: ${{ secrets.PI_MAIN_PASSWORD }} - - name: Compute release timestamp id: release run: echo "timestamp=$(date -u +%Y%m%d%H%M%S)" >> "$GITHUB_OUTPUT" @@ -126,7 +111,10 @@ jobs: # first-ever deploy, before any website workspace has published anything there (U1 already # tolerates a missing wwwroot/web at startup). The publish output's own wwwroot/web (empty, or # containing only the placeholder page) is removed before the symlink is created, so it never - # shadows the persistent content. + # shadows the persistent content. `mkdir -p $RELEASE_DIR/wwwroot` guards against `ln -s` failing + # with "No such file or directory": dotnet publish only emits a wwwroot/ folder at all when the + # source project has one with actual content, so a release built before any frontend content + # exists can land with no wwwroot/ directory whatsoever, not merely an empty wwwroot/web/. - name: Link persistent website content env: SSH_USER: ${{ secrets.PI_MAIN_USERNAME }} @@ -140,6 +128,7 @@ jobs: -o StrictHostKeyChecking=no \ "$SSH_USER@$SSH_HOST" \ "mkdir -p ${{ inputs.deploy_path }}/shared/wwwroot-web && \ + mkdir -p $RELEASE_DIR/wwwroot && \ rm -rf $RELEASE_DIR/wwwroot/web && \ ln -s ../../../shared/wwwroot-web $RELEASE_DIR/wwwroot/web" From 9b44a5e85e847e52383111de2cfd67ce6ae4040f Mon Sep 17 00:00:00 2001 From: Sluijsens Date: Thu, 30 Jul 2026 10:09:53 +0200 Subject: [PATCH 06/10] Adds a shared/modules symlink mechanism for optional plugin modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ModuleOrchestrator already discovers SlpModularCms.Modules.*.dll from disk at startup via reflection, so this needed no code change — only a persistent location (mirroring shared/wwwroot-web) that survives release swaps, and a deploy-scp.yaml step to symlink its contents into each new release before restart. --- .gitea/workflows/deploy-scp.yaml | 28 +++++++++++++++++++ .../deployment/deployment-instructions.md | 28 +++++++++++++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/deploy-scp.yaml b/.gitea/workflows/deploy-scp.yaml index 667c721..d46c5e0 100644 --- a/.gitea/workflows/deploy-scp.yaml +++ b/.gitea/workflows/deploy-scp.yaml @@ -132,6 +132,34 @@ jobs: rm -rf $RELEASE_DIR/wwwroot/web && \ ln -s ../../../shared/wwwroot-web $RELEASE_DIR/wwwroot/web" + # Optional plugin modules (future modular support): a compiled SlpModularCms.Modules.*.dll + # dropped into shared/modules survives every release, the same persistence pattern as + # shared/wwwroot-web above — except there's no cross-account permission dance here, since + # shared/modules lives entirely under gitea-workflow's own tree (unlike wwwroot-web, which + # reaches into webadmin's). ModuleOrchestrator.DiscoverModules() already scans its own base + # directory on disk for matching assemblies at startup (SlpModularCms.Core/Hosting/ + # ModuleOrchestrator.cs) and loads whatever it finds via reflection — no code change needed, + # only somewhere for the DLL to still be after the next deploy replaces releases/{timestamp}. + # `ls ... 2>/dev/null` piped to a `while read` (rather than a bare glob loop) keeps this + # POSIX-sh safe and correct when shared/modules is empty, which is the common case today. + - name: Link persistent modules + env: + SSH_USER: ${{ secrets.PI_MAIN_USERNAME }} + SSH_PASS: ${{ secrets.PI_MAIN_PASSWORD }} + SSH_PORT: ${{ secrets.PI_MAIN_PORT }} + SSH_HOST: ${{ secrets.PI_MAIN_ADDRESS }} + run: | + RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}" + sshpass -p "$SSH_PASS" ssh \ + -p "$SSH_PORT" \ + -o StrictHostKeyChecking=no \ + "$SSH_USER@$SSH_HOST" \ + "mkdir -p ${{ inputs.deploy_path }}/shared/modules && \ + ls ${{ inputs.deploy_path }}/shared/modules/*.dll 2>/dev/null | while read -r f; do \ + name=\$(basename \"\$f\"); \ + ln -sf \"../../shared/modules/\$name\" \"$RELEASE_DIR/\$name\"; \ + done" + # Atomic release switch (FR-06, D-27): `ln -sfn` replaces the `current` symlink target in a # single filesystem operation, so there is no moment where `current` points at a half-written # directory. The process is then restarted so it picks up the new assemblies — a running .NET diff --git a/aidlc-docs/features/gitea-deployment-workflow/operations/deployment/deployment-instructions.md b/aidlc-docs/features/gitea-deployment-workflow/operations/deployment/deployment-instructions.md index 809927e..16ac21e 100644 --- a/aidlc-docs/features/gitea-deployment-workflow/operations/deployment/deployment-instructions.md +++ b/aidlc-docs/features/gitea-deployment-workflow/operations/deployment/deployment-instructions.md @@ -148,6 +148,28 @@ sudo chgrp -R webshared /mnt/storage1/www/html/slpsoftware/ and make sure webadmin's FTP server creates new uploads group-readable (`g+rx`, not just owner-readable) — a one-time permission setup, not something either pipeline touches per deploy. +**`shared/modules` is the equivalent mechanism for optional plugin modules** (future modular +support) — but simpler, since it lives entirely under `gitea-workflow`'s own tree, with no +cross-account permission dance: + +👤 **pi-main / `gitea-workflow`:** +```bash +mkdir -p ~/apps/slpsoftware//shared/modules +``` + +`deploy-scp.yaml`'s "Link persistent modules" step already runs this `mkdir -p` on every deploy, so +this line is only useful if you want the directory to exist before the very first deploy — it isn't +required. To add a module: build a `SlpModularCms.Modules..dll` against the same version of +`SlpModularCms.Core` the running app was built against (mismatched contracts are caught per-module +by `ModuleOrchestrator`'s existing try/catch — logged and skipped, not a crash), `scp`/`sftp` it as +`gitea-workflow` into `~/apps/slpsoftware//shared/modules/`, then either wait for the next +deploy or restart the service by hand: +```bash +systemctl --user restart slpsoftware-.service +``` +Either way, `ModuleOrchestrator.DiscoverModules()` picks it up from the release directory the next +time the process starts — no CI run required to add a module this way. + ### 1.5 Runtime Configuration File One file per environment, **outside** the release directory so it survives every switch: @@ -498,9 +520,9 @@ Already built (U5/U6) — this is the read-only walkthrough for whoever operates 1. Push to `master`, or a manual `workflow_dispatch` → the six gates run 2. `publish-test` runs (and `publish-production`, only if `workflow_dispatch` with the flag) 3. `deploy-test` (always, if gates pass) calls `deploy-scp.yaml`, which uploads into a new - `releases/{timestamp}/`, links `shared/wwwroot-web` in, switches `current`, restarts - `slpsoftware-test.service`, verifies `https://test.slpsoftware.nl/health`, then prunes old - releases (only test — `run_db_backup: false`) + `releases/{timestamp}/`, links `shared/wwwroot-web` and any `shared/modules/*.dll` in, switches + `current`, restarts `slpsoftware-test.service`, verifies `https://test.slpsoftware.nl/health`, + then prunes old releases (only test — `run_db_backup: false`) 4. `deploy-production` (only with the flag) does the same, plus a database backup first (`run_db_backup: true`) — see § 4 From 7c1d2aa520acac62e12b3fb9d3ff95e12929166c Mon Sep 17 00:00:00 2001 From: Sluijsens Date: Thu, 30 Jul 2026 11:10:17 +0200 Subject: [PATCH 07/10] Fixes /admin 404 by building the admin SPA before dotnet publish, not via an MSBuild target The BuildAndCopyAdminFrontend MSBuild target (BeforeTargets=Publish) never actually worked: files it created after project evaluation were silently absent from the publish output, and forcing them in via an explicit Content item collided with the SDK's own static-web-asset resolution. Verified locally with a clean obj/bin and a fresh node_modules - every deploy so far genuinely shipped without wwwroot/admin. Moves the frontend build + copy into its own CI step ahead of dotnet publish, so the SDK's ordinary wwwroot handling picks it up with no custom MSBuild involved. Also fixes unreadable badges on the website placeholder page (no explicit text color, relying on inherited body color pairing unreliably with the badge background). --- .gitea/workflows/continuous_integration.yaml | 34 ++++++++++++++++--- .../Extensions/WebsitePlaceholder.html | 3 +- .../SlpModularCms.Api.csproj | 31 ++++++++--------- 3 files changed, 47 insertions(+), 21 deletions(-) diff --git a/.gitea/workflows/continuous_integration.yaml b/.gitea/workflows/continuous_integration.yaml index 21f0e2e..bf0eb15 100644 --- a/.gitea/workflows/continuous_integration.yaml +++ b/.gitea/workflows/continuous_integration.yaml @@ -253,13 +253,29 @@ jobs: exit 1 fi - - name: Publish (test) - working-directory: src/SlpModularCms.Api + # Builds the admin SPA and lands it in wwwroot/admin *before* dotnet publish runs, as its own + # step rather than an MSBuild target hooked to Build/Publish. That was tried and reliably + # failed two different ways (see SlpModularCms.Api.csproj's comment on the topic): the SDK's + # wwwroot static-web-asset item set is fixed at project evaluation time, before any target + # runs, so files a target creates afterward never make it into the publish output; and forcing + # them in as an explicit Content item collided with the SDK's own static-web-asset resolution. + # Physically existing on disk before dotnet publish/build ever runs is the only thing that + # worked, verified locally against a clean obj/bin and a fresh node_modules. + - name: Build admin frontend + working-directory: frontend env: VITE_APP_ENV: test VITE_UMAMI_SCRIPT_URL: ${{ vars.VITE_UMAMI_SCRIPT_URL }} VITE_UMAMI_WEBSITE_ID: ${{ vars.VITE_UMAMI_WEBSITE_ID_TEST }} VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} + run: | + pnpm install --frozen-lockfile + pnpm build + mkdir -p ../src/SlpModularCms.Api/wwwroot/admin + cp -r dist/. ../src/SlpModularCms.Api/wwwroot/admin/ + + - name: Publish (test) + working-directory: src/SlpModularCms.Api run: > dotnet publish -c Release -r ${{ env.PUBLISH_RID }} --self-contained false -o ${{ github.workspace }}/${{ env.ARTIFACT_NAME_TEST }} @@ -300,13 +316,23 @@ jobs: exit 1 fi - - name: Publish (production) - working-directory: src/SlpModularCms.Api + # See publish-test's "Build admin frontend" step for why this runs as its own step ahead of + # dotnet publish rather than an MSBuild target. + - name: Build admin frontend + working-directory: frontend env: VITE_APP_ENV: production VITE_UMAMI_SCRIPT_URL: ${{ vars.VITE_UMAMI_SCRIPT_URL }} VITE_UMAMI_WEBSITE_ID: ${{ vars.VITE_UMAMI_WEBSITE_ID_PRODUCTION }} VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} + run: | + pnpm install --frozen-lockfile + pnpm build + mkdir -p ../src/SlpModularCms.Api/wwwroot/admin + cp -r dist/. ../src/SlpModularCms.Api/wwwroot/admin/ + + - name: Publish (production) + working-directory: src/SlpModularCms.Api run: > dotnet publish -c Release -r ${{ env.PUBLISH_RID }} --self-contained false -o ${{ github.workspace }}/${{ env.ARTIFACT_NAME_PRODUCTION }} diff --git a/src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html b/src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html index 2b8abf8..ff261f7 100644 --- a/src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html +++ b/src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html @@ -20,7 +20,7 @@ } @media (prefers-color-scheme: dark) { body { background: #141414; color: #ededed; } - code { background: #262626; } + code { background: #262626; color: #141414; } a { color: #ff6b6b; } } main { max-width: 34rem; } @@ -28,6 +28,7 @@ p { margin: 0 0 1rem; } code { background: #ececec; + color: #1a1a1a; padding: 0.15em 0.4em; border-radius: 4px; font-family: ui-monospace, "Cascadia Code", Consolas, monospace; diff --git a/src/SlpModularCms.Api/SlpModularCms.Api.csproj b/src/SlpModularCms.Api/SlpModularCms.Api.csproj index 85c0df0..03b7bc8 100644 --- a/src/SlpModularCms.Api/SlpModularCms.Api.csproj +++ b/src/SlpModularCms.Api/SlpModularCms.Api.csproj @@ -35,22 +35,21 @@ - - $(MSBuildProjectDirectory)/../../frontend - - - - - - - - - - - From 22bc5c5cdea040ef0079b633373536f09ec7a70f Mon Sep 17 00:00:00 2001 From: Sluijsens Date: Thu, 30 Jul 2026 12:32:58 +0200 Subject: [PATCH 08/10] Doubles frontend test findBy* timeouts to reduce CI flakiness 5000ms was occasionally too tight on the self-hosted Actions runner for lazy-loaded routes gated behind an async setup-status check (observed on SettingsPage.test.tsx, passed reliably locally). Bumps to 10000ms across all nine test files using that pattern, and raises vite.config.ts's global testTimeout from 15000 to 20000 to keep headroom above it. --- .../cms/AddCmsInstanceDialog.test.tsx | 2 +- .../components/cms/SetStatusDialog.test.tsx | 2 +- .../users/InviteUserDialog.test.tsx | 2 +- frontend/src/pages/AccessDeniedPage.test.tsx | 6 ++--- frontend/src/pages/CmsPage.test.tsx | 12 ++++----- frontend/src/pages/NotFoundPage.test.tsx | 6 ++--- frontend/src/pages/ProfilePage.test.tsx | 4 +-- frontend/src/pages/SettingsPage.test.tsx | 26 +++++++++---------- frontend/src/pages/UsersPage.test.tsx | 2 +- frontend/vite.config.ts | 2 +- 10 files changed, 32 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/cms/AddCmsInstanceDialog.test.tsx b/frontend/src/components/cms/AddCmsInstanceDialog.test.tsx index faa321b..7541120 100644 --- a/frontend/src/components/cms/AddCmsInstanceDialog.test.tsx +++ b/frontend/src/components/cms/AddCmsInstanceDialog.test.tsx @@ -16,7 +16,7 @@ beforeEach(() => { async function openDialog() { mockAuthenticated(); renderApp('/cms'); - await screen.findByTestId('cms-add-button', {}, { timeout: 5000 }); + await screen.findByTestId('cms-add-button', {}, { timeout: 10000 }); await userEvent.click(screen.getByTestId('cms-add-button')); expect(screen.getByTestId('add-cms-name')).toBeInTheDocument(); } diff --git a/frontend/src/components/cms/SetStatusDialog.test.tsx b/frontend/src/components/cms/SetStatusDialog.test.tsx index f819b1b..0d93064 100644 --- a/frontend/src/components/cms/SetStatusDialog.test.tsx +++ b/frontend/src/components/cms/SetStatusDialog.test.tsx @@ -13,7 +13,7 @@ beforeEach(() => { async function openSetStatusDialog() { mockAuthenticated(); renderApp('/cms'); - const triggers = await screen.findAllByTestId('cms-instance-actions-trigger', {}, { timeout: 5000 }); + const triggers = await screen.findAllByTestId('cms-instance-actions-trigger', {}, { timeout: 10000 }); await userEvent.click(triggers[0]); await userEvent.click(await screen.findByTestId('cms-instance-set-status')); expect(screen.getByTestId('set-status-select')).toBeInTheDocument(); diff --git a/frontend/src/components/users/InviteUserDialog.test.tsx b/frontend/src/components/users/InviteUserDialog.test.tsx index adc8b07..46f79a2 100644 --- a/frontend/src/components/users/InviteUserDialog.test.tsx +++ b/frontend/src/components/users/InviteUserDialog.test.tsx @@ -14,7 +14,7 @@ beforeEach(() => { async function openDialog() { mockAuthenticated(); renderApp('/users'); - await screen.findByTestId('users-invite-button', {}, { timeout: 5000 }); + await screen.findByTestId('users-invite-button', {}, { timeout: 10000 }); await userEvent.click(screen.getByTestId('users-invite-button')); expect(screen.getByTestId('invite-dialog-email')).toBeInTheDocument(); } diff --git a/frontend/src/pages/AccessDeniedPage.test.tsx b/frontend/src/pages/AccessDeniedPage.test.tsx index d5237de..772d293 100644 --- a/frontend/src/pages/AccessDeniedPage.test.tsx +++ b/frontend/src/pages/AccessDeniedPage.test.tsx @@ -12,21 +12,21 @@ describe('AccessDeniedPage', () => { it('renders the 403 title and message', async () => { renderApp('/403'); - expect(await screen.findByTestId('access-denied-title', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByTestId('access-denied-title', {}, { timeout: 10000 })).toBeInTheDocument(); expect(screen.getByTestId('access-denied-message')).toBeInTheDocument(); }); it('renders the back to dashboard button', async () => { renderApp('/403'); - expect(await screen.findByTestId('access-denied-back-button', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByTestId('access-denied-back-button', {}, { timeout: 10000 })).toBeInTheDocument(); }); it('navigates to login when back button is clicked as guest', async () => { mockGuest(); renderApp('/403'); - const button = await screen.findByTestId('access-denied-back-button', {}, { timeout: 5000 }); + const button = await screen.findByTestId('access-denied-back-button', {}, { timeout: 10000 }); await userEvent.click(button); expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument(); diff --git a/frontend/src/pages/CmsPage.test.tsx b/frontend/src/pages/CmsPage.test.tsx index fd98470..a36bca9 100644 --- a/frontend/src/pages/CmsPage.test.tsx +++ b/frontend/src/pages/CmsPage.test.tsx @@ -20,7 +20,7 @@ describe('CmsPage', () => { mockAuthenticated(); renderApp('/cms'); - expect(await screen.findByTestId('cms-title', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByTestId('cms-title', {}, { timeout: 10000 })).toBeInTheDocument(); expect(screen.getByTestId('cms-add-button')).toBeInTheDocument(); }); @@ -28,7 +28,7 @@ describe('CmsPage', () => { mockAuthenticated(); renderApp('/cms'); - const rows = await screen.findAllByTestId('cms-instance-row', {}, { timeout: 5000 }); + const rows = await screen.findAllByTestId('cms-instance-row', {}, { timeout: 10000 }); expect(rows.length).toBe(2); }); @@ -37,7 +37,7 @@ describe('CmsPage', () => { server.use(http.get(CMS_URL, () => HttpResponse.json([]))); renderApp('/cms'); - expect(await screen.findByTestId('cms-empty-state', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByTestId('cms-empty-state', {}, { timeout: 10000 })).toBeInTheDocument(); expect(screen.getByTestId('cms-empty-add-button')).toBeInTheDocument(); }); @@ -45,7 +45,7 @@ describe('CmsPage', () => { mockAuthenticated(); renderApp('/cms'); - await screen.findByTestId('cms-add-button', {}, { timeout: 5000 }); + await screen.findByTestId('cms-add-button', {}, { timeout: 10000 }); await userEvent.click(screen.getByTestId('cms-add-button')); expect(await screen.findByTestId('add-cms-name')).toBeInTheDocument(); @@ -55,7 +55,7 @@ describe('CmsPage', () => { mockAuthenticated(); renderApp('/cms'); - await screen.findByTestId('cms-add-button', {}, { timeout: 5000 }); + await screen.findByTestId('cms-add-button', {}, { timeout: 10000 }); await userEvent.click(screen.getByTestId('cms-add-button')); await userEvent.type(screen.getByTestId('add-cms-name'), 'New CMS'); @@ -63,7 +63,7 @@ describe('CmsPage', () => { await userEvent.type(screen.getByTestId('add-cms-apikey-input'), 'secret-key'); await userEvent.click(screen.getByTestId('add-cms-submit')); - const rows = await screen.findAllByTestId('cms-instance-row', {}, { timeout: 5000 }); + const rows = await screen.findAllByTestId('cms-instance-row', {}, { timeout: 10000 }); expect(rows.length).toBe(3); }); diff --git a/frontend/src/pages/NotFoundPage.test.tsx b/frontend/src/pages/NotFoundPage.test.tsx index fae2a14..7278e57 100644 --- a/frontend/src/pages/NotFoundPage.test.tsx +++ b/frontend/src/pages/NotFoundPage.test.tsx @@ -12,21 +12,21 @@ describe('NotFoundPage', () => { it('renders for an unknown route', async () => { renderApp('/this-route-does-not-exist'); - expect(await screen.findByTestId('not-found-title', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByTestId('not-found-title', {}, { timeout: 10000 })).toBeInTheDocument(); expect(screen.getByTestId('not-found-message')).toBeInTheDocument(); }); it('renders the back to dashboard button', async () => { renderApp('/some/nonexistent/path'); - expect(await screen.findByTestId('not-found-back-button', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByTestId('not-found-back-button', {}, { timeout: 10000 })).toBeInTheDocument(); }); it('navigates to login when back button is clicked as guest', async () => { mockGuest(); renderApp('/this-does-not-exist'); - const button = await screen.findByTestId('not-found-back-button', {}, { timeout: 5000 }); + const button = await screen.findByTestId('not-found-back-button', {}, { timeout: 10000 }); await userEvent.click(button); expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument(); diff --git a/frontend/src/pages/ProfilePage.test.tsx b/frontend/src/pages/ProfilePage.test.tsx index 6fa98f3..ca7484b 100644 --- a/frontend/src/pages/ProfilePage.test.tsx +++ b/frontend/src/pages/ProfilePage.test.tsx @@ -16,7 +16,7 @@ describe('ProfilePage', () => { mockAuthenticated(); renderApp('/profile'); - expect(await screen.findByTestId('profile-title', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByTestId('profile-title', {}, { timeout: 10000 })).toBeInTheDocument(); expect(screen.getByTestId('profile-name-input')).toBeInTheDocument(); expect(screen.getByTestId('profile-email-input')).toBeInTheDocument(); expect(screen.getByTestId('profile-role-badge')).toBeInTheDocument(); @@ -61,7 +61,7 @@ describe('ProfilePage', () => { await userEvent.type(nameInput, 'New Name'); await userEvent.click(screen.getByTestId('profile-save-button')); - expect(await screen.findByText(/profile updated/i, {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByText(/profile updated/i, {}, { timeout: 10000 })).toBeInTheDocument(); }); it('shows validation error for invalid email', async () => { diff --git a/frontend/src/pages/SettingsPage.test.tsx b/frontend/src/pages/SettingsPage.test.tsx index f8ba4c1..c8e3331 100644 --- a/frontend/src/pages/SettingsPage.test.tsx +++ b/frontend/src/pages/SettingsPage.test.tsx @@ -16,21 +16,21 @@ describe('SettingsPage', () => { mockAuthenticated(); renderApp('/settings'); - expect(await screen.findByTestId('settings-title', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByTestId('settings-title', {}, { timeout: 10000 })).toBeInTheDocument(); }); it('shows the current availability status badge', async () => { mockAuthenticated(); renderApp('/settings'); - expect(await screen.findByTestId('availability-badge', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByTestId('availability-badge', {}, { timeout: 10000 })).toBeInTheDocument(); }); it('renders all three availability mode buttons', async () => { mockAuthenticated(); renderApp('/settings'); - await screen.findByTestId('availability-mode-selector', {}, { timeout: 5000 }); + await screen.findByTestId('availability-mode-selector', {}, { timeout: 10000 }); expect(screen.getByTestId('mode-option-Available')).toBeInTheDocument(); expect(screen.getByTestId('mode-option-Maintenance')).toBeInTheDocument(); expect(screen.getByTestId('mode-option-NotAvailable')).toBeInTheDocument(); @@ -40,7 +40,7 @@ describe('SettingsPage', () => { mockAuthenticated(); renderApp('/settings'); - await screen.findByTestId('availability-mode-selector', {}, { timeout: 5000 }); + await screen.findByTestId('availability-mode-selector', {}, { timeout: 10000 }); const maintenanceBtn = screen.getByTestId('mode-option-Maintenance'); await userEvent.click(maintenanceBtn); @@ -51,10 +51,10 @@ describe('SettingsPage', () => { mockAuthenticated(); renderApp('/settings'); - await screen.findByTestId('availability-save-button', {}, { timeout: 5000 }); + await screen.findByTestId('availability-save-button', {}, { timeout: 10000 }); await userEvent.click(screen.getByTestId('availability-save-button')); - expect(await screen.findByText(/availability updated/i, {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByText(/availability updated/i, {}, { timeout: 10000 })).toBeInTheDocument(); }); it('shows error toast when save fails', async () => { @@ -66,10 +66,10 @@ describe('SettingsPage', () => { mockAuthenticated(); renderApp('/settings'); - await screen.findByTestId('availability-save-button', {}, { timeout: 5000 }); + await screen.findByTestId('availability-save-button', {}, { timeout: 10000 }); await userEvent.click(screen.getByTestId('availability-save-button')); - expect(await screen.findByText(/something went wrong/i, {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByText(/something went wrong/i, {}, { timeout: 10000 })).toBeInTheDocument(); }); it('disables the availability controls and shows a banner when master-controlled', async () => { @@ -86,7 +86,7 @@ describe('SettingsPage', () => { mockAuthenticated(); renderApp('/settings'); - expect(await screen.findByTestId('availability-master-controlled-banner', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByTestId('availability-master-controlled-banner', {}, { timeout: 10000 })).toBeInTheDocument(); expect(screen.getByTestId('mode-option-Available')).toBeDisabled(); expect(screen.getByTestId('mode-option-Maintenance')).toBeDisabled(); expect(screen.getByTestId('mode-option-NotAvailable')).toBeDisabled(); @@ -98,7 +98,7 @@ describe('SettingsPage', () => { mockAuthenticated(); renderApp('/settings'); - await screen.findByTestId('availability-save-button', {}, { timeout: 5000 }); + await screen.findByTestId('availability-save-button', {}, { timeout: 10000 }); expect(screen.queryByTestId('availability-master-controlled-banner')).not.toBeInTheDocument(); expect(screen.getByTestId('availability-save-button')).not.toBeDisabled(); }); @@ -112,17 +112,17 @@ describe('SettingsPage', () => { mockAuthenticated(); renderApp('/settings'); - await screen.findByTestId('availability-save-button', {}, { timeout: 5000 }); + await screen.findByTestId('availability-save-button', {}, { timeout: 10000 }); await userEvent.click(screen.getByTestId('availability-save-button')); - expect(await screen.findByText(/master cms controls this status/i, {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByText(/master cms controls this status/i, {}, { timeout: 10000 })).toBeInTheDocument(); }); it('renders all placeholder sections', async () => { mockAuthenticated(); renderApp('/settings'); - await screen.findByTestId('settings-title', {}, { timeout: 5000 }); + await screen.findByTestId('settings-title', {}, { timeout: 10000 }); expect(screen.getByTestId('placeholder-settings.modules.title')).toBeInTheDocument(); expect(screen.getByTestId('placeholder-settings.systemConfig.title')).toBeInTheDocument(); expect(screen.getByTestId('placeholder-settings.branding.title')).toBeInTheDocument(); diff --git a/frontend/src/pages/UsersPage.test.tsx b/frontend/src/pages/UsersPage.test.tsx index fa46b40..5d4a7dd 100644 --- a/frontend/src/pages/UsersPage.test.tsx +++ b/frontend/src/pages/UsersPage.test.tsx @@ -19,7 +19,7 @@ describe('UsersPage', () => { mockAuthenticated(); renderApp('/users'); - expect(await screen.findByTestId('users-page', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByTestId('users-page', {}, { timeout: 10000 })).toBeInTheDocument(); expect(screen.getByTestId('users-invite-button')).toBeInTheDocument(); }); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d04e770..6502da0 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -42,7 +42,7 @@ export default defineConfig(({ command }) => ({ globals: true, environment: 'jsdom', setupFiles: ['./src/test/setup.ts'], - testTimeout: 15000, + testTimeout: 20000, css: true, coverage: { provider: 'v8', From f10d7b983ed7876019d4b7594d0755946c4d8bb0 Mon Sep 17 00:00:00 2001 From: Sluijsens Date: Thu, 30 Jul 2026 13:38:31 +0200 Subject: [PATCH 09/10] Reverts deploy-test to push/workflow_dispatch only, drops the pull_request trigger The pull_request trigger was a temporary convenience to test the deploy pipeline itself against the real test environment on every PR push. Now that the pipeline is verified working end to end, going back to only deploying on a master push or manual dispatch avoids concurrent PRs silently overwriting the single shared test environment. --- .gitea/workflows/continuous_integration.yaml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/continuous_integration.yaml b/.gitea/workflows/continuous_integration.yaml index bf0eb15..93a9310 100644 --- a/.gitea/workflows/continuous_integration.yaml +++ b/.gitea/workflows/continuous_integration.yaml @@ -344,15 +344,10 @@ jobs: path: ${{ env.ARTIFACT_NAME_PRODUCTION }} retention-days: 1 - # Automatic test deploy on push to master, any workflow_dispatch run, or a pull_request event - # (FR-03, D-01, D-09 — pull_request added as a deliberate extension beyond the original scope, so - # PR pushes can be verified against the real test environment without a manual workflow_dispatch - # each time). There is only one shared test environment: with more than one PR open at once, each - # push to any of them will redeploy and overwrite it — fine for a single active PR, revisit - # (e.g. gate behind a label, or restrict to one branch) before that becomes a real scenario. + # Automatic test deploy on push to master, or on any workflow_dispatch run (FR-03, D-01, D-09). deploy-test: needs: [publish-test, config] - if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/master') + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/master') uses: ./.gitea/workflows/deploy-scp.yaml secrets: inherit with: From 084b94120461428410fbab2d8c5c2c4a5ecbcb78 Mon Sep 17 00:00:00 2001 From: Sluijsens Date: Thu, 30 Jul 2026 18:54:09 +0200 Subject: [PATCH 10/10] Pins actions/setup-node to v3, skips DB backup when no database exists yet actions/setup-node@v4's Post step attempts a cache-save that fails hard on this self-hosted Gitea Actions runner (no GHES cache API support), marking frontend-prepare as failed and blocking its dependents even though the actual install/build work succeeds. v3's older cache implementation doesn't hit this. Also cleans up two stale comments left over from the /admin MSBuild-target removal. Also updates the documented backup script to skip cleanly (exit 0) when the target database doesn't exist yet (MariaDB error 1049) rather than failing the whole deploy - expected on a brand-new environment's first production run, before migrations have ever had a chance to create it. Still fails hard on any other error, so a real backup failure against an existing database still blocks the deploy as intended. --- .gitea/workflows/continuous_integration.yaml | 26 +++++++++---------- .../deployment/deployment-instructions.md | 15 +++++++++++ 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/.gitea/workflows/continuous_integration.yaml b/.gitea/workflows/continuous_integration.yaml index 93a9310..5c06e5e 100644 --- a/.gitea/workflows/continuous_integration.yaml +++ b/.gitea/workflows/continuous_integration.yaml @@ -109,7 +109,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v3 with: node-version: ${{ env.NODE_VERSION }} - uses: pnpm/action-setup@v4 @@ -130,16 +130,16 @@ jobs: # --- Gate 4: frontend build --- # Validates the frontend compiles on its own, independent of any environment. The actual - # per-environment deployable artifact is produced later by `dotnet publish` (publish-test / - # publish-production jobs below), which triggers the same frontend build internally via - # SlpModularCms.Api.csproj's BuildAndCopyAdminFrontend MSBuild target, with the environment's - # Vite variables set on the process so that target picks them up. + # per-environment deployable artifact is produced later by each publish job's own "Build admin + # frontend" step (publish-test / publish-production, below), which builds frontend/ again with + # that environment's Vite variables set and copies the result into wwwroot/admin before + # `dotnet publish` runs — see that step's comment for why this isn't an MSBuild target instead. frontend-build: needs: frontend-prepare runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v3 with: node-version: ${{ env.NODE_VERSION }} - uses: pnpm/action-setup@v4 @@ -167,7 +167,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v3 with: node-version: ${{ env.NODE_VERSION }} - uses: pnpm/action-setup@v4 @@ -195,7 +195,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v3 with: node-version: ${{ env.NODE_VERSION }} - uses: pnpm/action-setup@v4 @@ -217,10 +217,8 @@ jobs: working-directory: frontend run: pnpm run lint - # Publishes the Api project for the test environment. The admin SPA is built as part of this - # publish (BuildAndCopyAdminFrontend, BeforeTargets="Publish"), so the VITE_* variables below are - # set on this step's environment, not passed as CLI arguments — pnpm build (invoked by MSBuild) - # reads them the same way `vite build` always does. + # Publishes the Api project for the test environment. The admin SPA is built by this job's own + # "Build admin frontend" step below, ahead of `dotnet publish` — see that step's comment for why. # # REF-U5-01: the Umami-origin drift check this gate performs exists because U3's own startup check # (BR-U3-22) can't work — the backend can never see VITE_UMAMI_WEBSITE_ID at runtime. It compares @@ -236,7 +234,7 @@ jobs: - uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v3 with: node-version: ${{ env.NODE_VERSION }} - uses: pnpm/action-setup@v4 @@ -299,7 +297,7 @@ jobs: - uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v3 with: node-version: ${{ env.NODE_VERSION }} - uses: pnpm/action-setup@v4 diff --git a/aidlc-docs/features/gitea-deployment-workflow/operations/deployment/deployment-instructions.md b/aidlc-docs/features/gitea-deployment-workflow/operations/deployment/deployment-instructions.md index 16ac21e..5339519 100644 --- a/aidlc-docs/features/gitea-deployment-workflow/operations/deployment/deployment-instructions.md +++ b/aidlc-docs/features/gitea-deployment-workflow/operations/deployment/deployment-instructions.md @@ -556,6 +556,21 @@ fi source "$CREDENTIALS_FILE" : "${DB_HOST:?}" "${DB_PORT:?}" "${DB_NAME:?}" "${DB_USER:?}" "${DB_PASSWORD:?}" +# A brand-new environment's very first production deploy runs this step before the app has ever +# started, so before EF Core's migrations have had a chance to create the database — there is +# nothing to back up yet, and that's fine, not a failure. Distinguish that specific case ("Unknown +# database", MariaDB error 1049) from every other failure (wrong credentials, network issue, +# permissions): only the former is safe to skip, since skipping any OTHER error would silently mask +# a real backup failure against a database that may hold real data. +USE_ERROR=$(mariadb -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASSWORD" -e "USE \`$DB_NAME\`;" 2>&1 >/dev/null) || true +if echo "$USE_ERROR" | grep -q "Unknown database"; then + echo "Database '$DB_NAME' does not exist yet — nothing to back up (expected on a first deploy). Skipping." + exit 0 +elif [[ -n "$USE_ERROR" ]]; then + echo "Could not verify database '$DB_NAME' exists: $USE_ERROR" >&2 + exit 1 +fi + BACKUP_DIR="$HOME/backups/slpsoftware/${ENVIRONMENT}" mkdir -p "$BACKUP_DIR" TIMESTAMP=$(date -u +%Y%m%d%H%M%S)