Merge pull request 'Adds Monitoring Setup docs and deploy-scp troubleshooting/debug fixes' (#2) from feature/gitea-deployment-workflow into master
Continuous Integration / config (push) Successful in 10s
Continuous Integration / backend-build (push) Successful in 5m6s
Continuous Integration / vulnerability-scan (push) Successful in 4m40s
Continuous Integration / frontend-prepare (push) Successful in 1m51s
Continuous Integration / backend-test (push) Successful in 5m33s
Continuous Integration / frontend-build (push) Successful in 2m8s
Continuous Integration / frontend-test (push) Successful in 4m26s
Continuous Integration / frontend-lint (push) Successful in 1m58s
Continuous Integration / publish-production (push) Skipped
Continuous Integration / deploy-production (push) Skipped
Continuous Integration / publish-test (push) Successful in 6m27s
Deploy (SCP) / deploy (push) Successful in 1m24s
Continuous Integration / deploy-test (push) Successful in 1m25s

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-07-30 23:57:51 +02:00
18 changed files with 576 additions and 96 deletions
+42 -18
View File
@@ -109,7 +109,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v3
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
@@ -130,16 +130,16 @@ jobs:
# --- Gate 4: frontend build --- # --- Gate 4: frontend build ---
# Validates the frontend compiles on its own, independent of any environment. The actual # 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 / # per-environment deployable artifact is produced later by each publish job's own "Build admin
# publish-production jobs below), which triggers the same frontend build internally via # frontend" step (publish-test / publish-production, below), which builds frontend/ again with
# SlpModularCms.Api.csproj's BuildAndCopyAdminFrontend MSBuild target, with the environment's # that environment's Vite variables set and copies the result into wwwroot/admin before
# Vite variables set on the process so that target picks them up. # `dotnet publish` runs — see that step's comment for why this isn't an MSBuild target instead.
frontend-build: frontend-build:
needs: frontend-prepare needs: frontend-prepare
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v3
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
@@ -167,7 +167,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v3
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
@@ -195,7 +195,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v3
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
@@ -217,10 +217,8 @@ jobs:
working-directory: frontend working-directory: frontend
run: pnpm run lint run: pnpm run lint
# Publishes the Api project for the test environment. The admin SPA is built as part of this # Publishes the Api project for the test environment. The admin SPA is built by this job's own
# publish (BuildAndCopyAdminFrontend, BeforeTargets="Publish"), so the VITE_* variables below are # "Build admin frontend" step below, ahead of `dotnet publish` — see that step's comment for why.
# 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.
# #
# REF-U5-01: the Umami-origin drift check this gate performs exists because U3's own startup check # 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 # (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 - uses: actions/setup-dotnet@v4
with: with:
dotnet-version: ${{ env.DOTNET_VERSION }} dotnet-version: ${{ env.DOTNET_VERSION }}
- uses: actions/setup-node@v4 - uses: actions/setup-node@v3
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
@@ -253,13 +251,29 @@ jobs:
exit 1 exit 1
fi fi
- name: Publish (test) # Builds the admin SPA and lands it in wwwroot/admin *before* dotnet publish runs, as its own
working-directory: src/SlpModularCms.Api # 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: env:
VITE_APP_ENV: test VITE_APP_ENV: test
VITE_UMAMI_SCRIPT_URL: ${{ vars.VITE_UMAMI_SCRIPT_URL }} VITE_UMAMI_SCRIPT_URL: ${{ vars.VITE_UMAMI_SCRIPT_URL }}
VITE_UMAMI_WEBSITE_ID: ${{ vars.VITE_UMAMI_WEBSITE_ID_TEST }} VITE_UMAMI_WEBSITE_ID: ${{ vars.VITE_UMAMI_WEBSITE_ID_TEST }}
VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} 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: > run: >
dotnet publish -c Release -r ${{ env.PUBLISH_RID }} --self-contained false dotnet publish -c Release -r ${{ env.PUBLISH_RID }} --self-contained false
-o ${{ github.workspace }}/${{ env.ARTIFACT_NAME_TEST }} -o ${{ github.workspace }}/${{ env.ARTIFACT_NAME_TEST }}
@@ -283,7 +297,7 @@ jobs:
- uses: actions/setup-dotnet@v4 - uses: actions/setup-dotnet@v4
with: with:
dotnet-version: ${{ env.DOTNET_VERSION }} dotnet-version: ${{ env.DOTNET_VERSION }}
- uses: actions/setup-node@v4 - uses: actions/setup-node@v3
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
@@ -300,13 +314,23 @@ jobs:
exit 1 exit 1
fi fi
- name: Publish (production) # See publish-test's "Build admin frontend" step for why this runs as its own step ahead of
working-directory: src/SlpModularCms.Api # dotnet publish rather than an MSBuild target.
- name: Build admin frontend
working-directory: frontend
env: env:
VITE_APP_ENV: production VITE_APP_ENV: production
VITE_UMAMI_SCRIPT_URL: ${{ vars.VITE_UMAMI_SCRIPT_URL }} VITE_UMAMI_SCRIPT_URL: ${{ vars.VITE_UMAMI_SCRIPT_URL }}
VITE_UMAMI_WEBSITE_ID: ${{ vars.VITE_UMAMI_WEBSITE_ID_PRODUCTION }} VITE_UMAMI_WEBSITE_ID: ${{ vars.VITE_UMAMI_WEBSITE_ID_PRODUCTION }}
VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} 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: > run: >
dotnet publish -c Release -r ${{ env.PUBLISH_RID }} --self-contained false dotnet publish -c Release -r ${{ env.PUBLISH_RID }} --self-contained false
-o ${{ github.workspace }}/${{ env.ARTIFACT_NAME_PRODUCTION }} -o ${{ github.workspace }}/${{ env.ARTIFACT_NAME_PRODUCTION }}
+84 -21
View File
@@ -56,14 +56,23 @@ jobs:
# The backup script itself lives on the host (created once during Operations host setup) and # 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 # 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. # 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 }} 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: | run: |
echo "Environment: ${{ inputs.environment }}"
sudo apt-get update && sudo apt-get install -y sshpass sudo apt-get update && sudo apt-get install -y sshpass
sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \ sshpass -p "$SSH_PASS" ssh \
-p ${{ secrets.PI_MAIN_PORT }} \ -p "$SSH_PORT" \
-o StrictHostKeyChecking=no \ -o StrictHostKeyChecking=no \
${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \ "$SSH_USER@$SSH_HOST" \
"bash ~/scripts/backup-slpsoftware-db.sh ${{ inputs.environment }}" "bash ~/scripts/backup-slpsoftware-db.sh ${{ inputs.environment }}"
# Uploads the published output to a fresh, timestamped release directory rather than # Uploads the published output to a fresh, timestamped release directory rather than
@@ -74,20 +83,27 @@ jobs:
# runner with "failed to attach to container: unable to upgrade to tcp, received 409", a known # 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 # 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. # 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
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: | run: |
echo "Environment: ${{ inputs.environment }}, transport: ${{ inputs.transport }}"
sudo apt-get update && sudo apt-get install -y sshpass sudo apt-get update && sudo apt-get install -y sshpass
RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}" RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}"
sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \ sshpass -p "$SSH_PASS" ssh \
-p ${{ secrets.PI_MAIN_PORT }} \ -p "$SSH_PORT" \
-o StrictHostKeyChecking=no \ -o StrictHostKeyChecking=no \
${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \ "$SSH_USER@$SSH_HOST" \
"mkdir -p $RELEASE_DIR" "mkdir -p $RELEASE_DIR"
sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" scp \ sshpass -p "$SSH_PASS" scp \
-P ${{ secrets.PI_MAIN_PORT }} \ -P "$SSH_PORT" \
-o StrictHostKeyChecking=no \ -o StrictHostKeyChecking=no \
-r ${{ inputs.artifact_name }}/* \ -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). # 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 # It lives outside the swapped release directory in a persistent shared/ folder, and is
@@ -95,29 +111,71 @@ jobs:
# first-ever deploy, before any website workspace has published anything there (U1 already # 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 # 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 # 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 - 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: | run: |
RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}" RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}"
sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \ sshpass -p "$SSH_PASS" ssh \
-p ${{ secrets.PI_MAIN_PORT }} \ -p "$SSH_PORT" \
-o StrictHostKeyChecking=no \ -o StrictHostKeyChecking=no \
${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \ "$SSH_USER@$SSH_HOST" \
"mkdir -p ${{ inputs.deploy_path }}/shared/wwwroot-web && \ "mkdir -p ${{ inputs.deploy_path }}/shared/wwwroot-web && \
mkdir -p $RELEASE_DIR/wwwroot && \
rm -rf $RELEASE_DIR/wwwroot/web && \ rm -rf $RELEASE_DIR/wwwroot/web && \
ln -s ../../../shared/wwwroot-web $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 # 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 # 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 # 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. # process holds on to the ones it already loaded.
- name: Switch current release and restart service - 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: | run: |
RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}" RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}"
sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \ sshpass -p "$SSH_PASS" ssh \
-p ${{ secrets.PI_MAIN_PORT }} \ -p "$SSH_PORT" \
-o StrictHostKeyChecking=no \ -o StrictHostKeyChecking=no \
${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \ "$SSH_USER@$SSH_HOST" \
"ln -sfn $RELEASE_DIR ${{ inputs.deploy_path }}/current && \ "ln -sfn $RELEASE_DIR ${{ inputs.deploy_path }}/current && \
systemctl --user restart ${{ inputs.service_name }}" systemctl --user restart ${{ inputs.service_name }}"
@@ -142,9 +200,14 @@ jobs:
# a re-point-and-restart away without rebuilding. Only runs after a passing health check — # 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. # pruning after a failed check could leave the only other release as the sole survivor.
- name: Prune old releases - 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: | run: |
sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \ sshpass -p "$SSH_PASS" ssh \
-p ${{ secrets.PI_MAIN_PORT }} \ -p "$SSH_PORT" \
-o StrictHostKeyChecking=no \ -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" "cd ${{ inputs.deploy_path }}/releases && ls -1t | tail -n +3 | xargs -r rm -rf"
@@ -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/` - [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 ### OPERATIONS
- [ ] Deployment Setup — **EXECUTE** - [x] Deployment Setup — **EXECUTE** — Complete 2026-07-28, extended 2026-07-29 (MariaDB migration,
- [ ] Monitoring Setup — **EXECUTE** 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) - [ ] Production Readiness Validation — **EXECUTE** (includes the `dotnet-appsettings` compliance gate)
## Execution Plan Summary ## Execution Plan Summary
@@ -190,9 +197,13 @@ Execution rounds (Q4 = B): **R1** = U1 + U2 · **R2** = U3 + U4 · **R3** = U5 +
## Current Status ## Current Status
- **Lifecycle Phase**: OPERATIONS - **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 - **Current Stage**: Deployment Setup complete (2026-07-28, extended 2026-07-29). Monitoring Setup
- **Next Stage**: Deployment Setup (Operations Configuration = Yes, decided at Requirements Analysis) complete 2026-07-29 — user applied real values in Gitea, UptimeRobot and Sentry
- **Status**: Rounds 14 all done, nothing pushed. Entering Operations phase - **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) ## Round 2 Design Record (2026-07-28)
- Functional Design U3 + U4 complete and committed (`357d395`) - Functional Design U3 + U4 complete and committed (`357d395`)
@@ -148,6 +148,28 @@ sudo chgrp -R webshared /mnt/storage1/www/html/slpsoftware/<env>
and make sure webadmin's FTP server creates new uploads group-readable (`g+rx`, not just 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. 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/<env>/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.<Name>.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/<env>/shared/modules/`, then either wait for the next
deploy or restart the service by hand:
```bash
systemctl --user restart slpsoftware-<env>.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 ### 1.5 Runtime Configuration File
One file per environment, **outside** the release directory so it survives every switch: One file per environment, **outside** the release directory so it survives every switch:
@@ -182,6 +204,14 @@ MasterModule__MasterUrl=https://<this-environment-domain>
# continues (see Observability section in README.md). # continues (see Observability section in README.md).
Observability__SentryDsn=<sentry-dsn-or-empty> Observability__SentryDsn=<sentry-dsn-or-empty>
# 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=<test-or-production>
# Optional — both only needed if this instance uses Umami analytics (VITE_UMAMI_SCRIPT_URL set for # 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 # the frontend build). Leave both lines out entirely if you don't use Umami; there is no other
# origin either one needs by default. # origin either one needs by default.
@@ -362,6 +392,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) ### 1.8 Database Backup Credentials (§ 4 depends on this)
👤 **pi-main / `gitea-workflow`:** 👤 **pi-main / `gitea-workflow`:**
@@ -414,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 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) 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 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 `releases/{timestamp}/`, links `shared/wwwroot-web` and any `shared/modules/*.dll` in, switches
`slpsoftware-test.service`, verifies `https://test.slpsoftware.nl/health`, then prunes old `current`, restarts `slpsoftware-test.service`, verifies `https://test.slpsoftware.nl/health`,
releases (only test — `run_db_backup: false`) 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 4. `deploy-production` (only with the flag) does the same, plus a database backup first
(`run_db_backup: true`) — see § 4 (`run_db_backup: true`) — see § 4
@@ -450,6 +556,21 @@ fi
source "$CREDENTIALS_FILE" source "$CREDENTIALS_FILE"
: "${DB_HOST:?}" "${DB_PORT:?}" "${DB_NAME:?}" "${DB_USER:?}" "${DB_PASSWORD:?}" : "${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}" BACKUP_DIR="$HOME/backups/slpsoftware/${ENVIRONMENT}"
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date -u +%Y%m%d%H%M%S) TIMESTAMP=$(date -u +%Y%m%d%H%M%S)
@@ -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 <value>`
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) |
@@ -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_<ENV>`, 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)
@@ -16,7 +16,7 @@ beforeEach(() => {
async function openDialog() { async function openDialog() {
mockAuthenticated(); mockAuthenticated();
renderApp('/cms'); 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.click(screen.getByTestId('cms-add-button'));
expect(screen.getByTestId('add-cms-name')).toBeInTheDocument(); expect(screen.getByTestId('add-cms-name')).toBeInTheDocument();
} }
@@ -13,7 +13,7 @@ beforeEach(() => {
async function openSetStatusDialog() { async function openSetStatusDialog() {
mockAuthenticated(); mockAuthenticated();
renderApp('/cms'); 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(triggers[0]);
await userEvent.click(await screen.findByTestId('cms-instance-set-status')); await userEvent.click(await screen.findByTestId('cms-instance-set-status'));
expect(screen.getByTestId('set-status-select')).toBeInTheDocument(); expect(screen.getByTestId('set-status-select')).toBeInTheDocument();
@@ -14,7 +14,7 @@ beforeEach(() => {
async function openDialog() { async function openDialog() {
mockAuthenticated(); mockAuthenticated();
renderApp('/users'); 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')); await userEvent.click(screen.getByTestId('users-invite-button'));
expect(screen.getByTestId('invite-dialog-email')).toBeInTheDocument(); expect(screen.getByTestId('invite-dialog-email')).toBeInTheDocument();
} }
+3 -3
View File
@@ -12,21 +12,21 @@ describe('AccessDeniedPage', () => {
it('renders the 403 title and message', async () => { it('renders the 403 title and message', async () => {
renderApp('/403'); 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(); expect(screen.getByTestId('access-denied-message')).toBeInTheDocument();
}); });
it('renders the back to dashboard button', async () => { it('renders the back to dashboard button', async () => {
renderApp('/403'); 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 () => { it('navigates to login when back button is clicked as guest', async () => {
mockGuest(); mockGuest();
renderApp('/403'); 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); await userEvent.click(button);
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument(); expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
+6 -6
View File
@@ -20,7 +20,7 @@ describe('CmsPage', () => {
mockAuthenticated(); mockAuthenticated();
renderApp('/cms'); 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(); expect(screen.getByTestId('cms-add-button')).toBeInTheDocument();
}); });
@@ -28,7 +28,7 @@ describe('CmsPage', () => {
mockAuthenticated(); mockAuthenticated();
renderApp('/cms'); 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); expect(rows.length).toBe(2);
}); });
@@ -37,7 +37,7 @@ describe('CmsPage', () => {
server.use(http.get(CMS_URL, () => HttpResponse.json([]))); server.use(http.get(CMS_URL, () => HttpResponse.json([])));
renderApp('/cms'); 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(); expect(screen.getByTestId('cms-empty-add-button')).toBeInTheDocument();
}); });
@@ -45,7 +45,7 @@ describe('CmsPage', () => {
mockAuthenticated(); mockAuthenticated();
renderApp('/cms'); 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.click(screen.getByTestId('cms-add-button'));
expect(await screen.findByTestId('add-cms-name')).toBeInTheDocument(); expect(await screen.findByTestId('add-cms-name')).toBeInTheDocument();
@@ -55,7 +55,7 @@ describe('CmsPage', () => {
mockAuthenticated(); mockAuthenticated();
renderApp('/cms'); 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.click(screen.getByTestId('cms-add-button'));
await userEvent.type(screen.getByTestId('add-cms-name'), 'New CMS'); 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.type(screen.getByTestId('add-cms-apikey-input'), 'secret-key');
await userEvent.click(screen.getByTestId('add-cms-submit')); 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); expect(rows.length).toBe(3);
}); });
+3 -3
View File
@@ -12,21 +12,21 @@ describe('NotFoundPage', () => {
it('renders for an unknown route', async () => { it('renders for an unknown route', async () => {
renderApp('/this-route-does-not-exist'); 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(); expect(screen.getByTestId('not-found-message')).toBeInTheDocument();
}); });
it('renders the back to dashboard button', async () => { it('renders the back to dashboard button', async () => {
renderApp('/some/nonexistent/path'); 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 () => { it('navigates to login when back button is clicked as guest', async () => {
mockGuest(); mockGuest();
renderApp('/this-does-not-exist'); 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); await userEvent.click(button);
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument(); expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
+2 -2
View File
@@ -16,7 +16,7 @@ describe('ProfilePage', () => {
mockAuthenticated(); mockAuthenticated();
renderApp('/profile'); 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-name-input')).toBeInTheDocument();
expect(screen.getByTestId('profile-email-input')).toBeInTheDocument(); expect(screen.getByTestId('profile-email-input')).toBeInTheDocument();
expect(screen.getByTestId('profile-role-badge')).toBeInTheDocument(); expect(screen.getByTestId('profile-role-badge')).toBeInTheDocument();
@@ -61,7 +61,7 @@ describe('ProfilePage', () => {
await userEvent.type(nameInput, 'New Name'); await userEvent.type(nameInput, 'New Name');
await userEvent.click(screen.getByTestId('profile-save-button')); 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 () => { it('shows validation error for invalid email', async () => {
+13 -13
View File
@@ -16,21 +16,21 @@ describe('SettingsPage', () => {
mockAuthenticated(); mockAuthenticated();
renderApp('/settings'); 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 () => { it('shows the current availability status badge', async () => {
mockAuthenticated(); mockAuthenticated();
renderApp('/settings'); 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 () => { it('renders all three availability mode buttons', async () => {
mockAuthenticated(); mockAuthenticated();
renderApp('/settings'); 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-Available')).toBeInTheDocument();
expect(screen.getByTestId('mode-option-Maintenance')).toBeInTheDocument(); expect(screen.getByTestId('mode-option-Maintenance')).toBeInTheDocument();
expect(screen.getByTestId('mode-option-NotAvailable')).toBeInTheDocument(); expect(screen.getByTestId('mode-option-NotAvailable')).toBeInTheDocument();
@@ -40,7 +40,7 @@ describe('SettingsPage', () => {
mockAuthenticated(); mockAuthenticated();
renderApp('/settings'); 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'); const maintenanceBtn = screen.getByTestId('mode-option-Maintenance');
await userEvent.click(maintenanceBtn); await userEvent.click(maintenanceBtn);
@@ -51,10 +51,10 @@ describe('SettingsPage', () => {
mockAuthenticated(); mockAuthenticated();
renderApp('/settings'); 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')); 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 () => { it('shows error toast when save fails', async () => {
@@ -66,10 +66,10 @@ describe('SettingsPage', () => {
mockAuthenticated(); mockAuthenticated();
renderApp('/settings'); 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')); 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 () => { it('disables the availability controls and shows a banner when master-controlled', async () => {
@@ -86,7 +86,7 @@ describe('SettingsPage', () => {
mockAuthenticated(); mockAuthenticated();
renderApp('/settings'); 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-Available')).toBeDisabled();
expect(screen.getByTestId('mode-option-Maintenance')).toBeDisabled(); expect(screen.getByTestId('mode-option-Maintenance')).toBeDisabled();
expect(screen.getByTestId('mode-option-NotAvailable')).toBeDisabled(); expect(screen.getByTestId('mode-option-NotAvailable')).toBeDisabled();
@@ -98,7 +98,7 @@ describe('SettingsPage', () => {
mockAuthenticated(); mockAuthenticated();
renderApp('/settings'); 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.queryByTestId('availability-master-controlled-banner')).not.toBeInTheDocument();
expect(screen.getByTestId('availability-save-button')).not.toBeDisabled(); expect(screen.getByTestId('availability-save-button')).not.toBeDisabled();
}); });
@@ -112,17 +112,17 @@ describe('SettingsPage', () => {
mockAuthenticated(); mockAuthenticated();
renderApp('/settings'); 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')); 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 () => { it('renders all placeholder sections', async () => {
mockAuthenticated(); mockAuthenticated();
renderApp('/settings'); 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.modules.title')).toBeInTheDocument();
expect(screen.getByTestId('placeholder-settings.systemConfig.title')).toBeInTheDocument(); expect(screen.getByTestId('placeholder-settings.systemConfig.title')).toBeInTheDocument();
expect(screen.getByTestId('placeholder-settings.branding.title')).toBeInTheDocument(); expect(screen.getByTestId('placeholder-settings.branding.title')).toBeInTheDocument();
+1 -1
View File
@@ -19,7 +19,7 @@ describe('UsersPage', () => {
mockAuthenticated(); mockAuthenticated();
renderApp('/users'); 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(); expect(screen.getByTestId('users-invite-button')).toBeInTheDocument();
}); });
+1 -1
View File
@@ -42,7 +42,7 @@ export default defineConfig(({ command }) => ({
globals: true, globals: true,
environment: 'jsdom', environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'], setupFiles: ['./src/test/setup.ts'],
testTimeout: 15000, testTimeout: 20000,
css: true, css: true,
coverage: { coverage: {
provider: 'v8', provider: 'v8',
@@ -20,7 +20,7 @@
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body { background: #141414; color: #ededed; } body { background: #141414; color: #ededed; }
code { background: #262626; } code { background: #262626; color: #141414; }
a { color: #ff6b6b; } a { color: #ff6b6b; }
} }
main { max-width: 34rem; } main { max-width: 34rem; }
@@ -28,6 +28,7 @@
p { margin: 0 0 1rem; } p { margin: 0 0 1rem; }
code { code {
background: #ececec; background: #ececec;
color: #1a1a1a;
padding: 0.15em 0.4em; padding: 0.15em 0.4em;
border-radius: 4px; border-radius: 4px;
font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
+15 -16
View File
@@ -35,22 +35,21 @@
</ItemGroup> </ItemGroup>
<!-- <!--
Builds the CMS admin SPA (frontend/) and copies its output into wwwroot/admin so it's served The admin SPA (frontend/) is served at /admin (see Program.cs) from wwwroot/admin, but is
at the /admin path (see Program.cs). Only runs on `dotnet publish`, not on every dev build. deliberately NOT built by an MSBuild target hooked to Build/Publish here. Both attempts tried
The public website ('/') lives outside this repo and is deployed into wwwroot separately. (a plain <Copy> after a BeforeTargets="Publish" pnpm build, and an explicit <Content> item with
a custom TargetPath) were verified locally to fail: 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
are silently absent from the publish output; and forcing them in as a Content item collided with
the SDK's own static-web-asset resolution ("Two assets found targeting the same path with
incompatible asset kinds: admin/admin/..."), reproducibly, even from a clean obj/bin and a fresh
node_modules. The only combination that published wwwroot/admin correctly was the files already
physically existing on disk *before* dotnet publish/build ever runs — see the CI workflow
(continuous_integration.yaml), which builds frontend/ and copies frontend/dist into
wwwroot/admin as its own step, ahead of `dotnet publish`. Do the same locally before publishing:
run `pnpm install` then `pnpm build` inside frontend/, then copy
`frontend/dist/*` into `wwwroot/admin/` (that folder is gitignored, so nothing to clean up
afterward), then `dotnet publish` picks it up via the SDK's ordinary wwwroot handling.
--> -->
<PropertyGroup>
<AdminFrontendDir>$(MSBuildProjectDirectory)/../../frontend</AdminFrontendDir>
</PropertyGroup>
<Target Name="BuildAndCopyAdminFrontend" BeforeTargets="Publish">
<Message Importance="high" Text="Building CMS admin frontend ($(AdminFrontendDir))..." />
<Exec Command="pnpm install --frozen-lockfile" WorkingDirectory="$(AdminFrontendDir)" />
<Exec Command="pnpm build" WorkingDirectory="$(AdminFrontendDir)" />
<ItemGroup>
<AdminFrontendFiles Include="$(AdminFrontendDir)/dist/**/*" />
</ItemGroup>
<Copy SourceFiles="@(AdminFrontendFiles)" DestinationFolder="$(MSBuildProjectDirectory)/wwwroot/admin/%(RecursiveDir)" SkipUnchangedFiles="true" />
</Target>
</Project> </Project>