From 9f4ae475e795bb6508493c6d34db678d015fa841 Mon Sep 17 00:00:00 2001 From: Sluijsens Date: Tue, 28 Jul 2026 16:07:03 +0200 Subject: [PATCH] =?UTF-8?q?U5=20=E2=80=94=20the=20gate=20everything=20else?= =?UTF-8?q?=20has=20to=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit continuous_integration.yaml: six blocking checks, then a separate publish per environment so a Vite build never gets tagged for the wrong one, then a call into last commit's deploy workflow. Along the way: the lint list had drifted (two problems not in the requirement, one already fixed), and the Umami-origin gate needed a variable pair of its own since the backend's side of that comparison lives on the host, not in CI. Pinned the two vulnerable packages while at it. --- .gitea/workflows/continuous_integration.yaml | 349 ++++++++++++++++++ .../gitea-deployment-workflow/audit.md | 44 +++ .../u5-ci-workflow-code-generation-plan.md | 135 +++++++ .../u5-ci-workflow/code/generation-summary.md | 101 +++++ .../components/cms/AddCmsInstanceDialog.tsx | 12 +- .../src/components/cms/SetStatusDialog.tsx | 34 +- frontend/src/components/ui/badge.tsx | 1 + .../src/components/users/InviteUserDialog.tsx | 26 +- frontend/src/pages/SettingsPage.tsx | 10 +- .../SlpModularCms.Core.csproj | 11 + ...dularCms.Modules.Availability.Tests.csproj | 2 + .../SlpModularCms.Modules.Master.Tests.csproj | 2 + 12 files changed, 691 insertions(+), 36 deletions(-) create mode 100644 .gitea/workflows/continuous_integration.yaml create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/plans/u5-ci-workflow-code-generation-plan.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/u5-ci-workflow/code/generation-summary.md diff --git a/.gitea/workflows/continuous_integration.yaml b/.gitea/workflows/continuous_integration.yaml new file mode 100644 index 0000000..18ee877 --- /dev/null +++ b/.gitea/workflows/continuous_integration.yaml @@ -0,0 +1,349 @@ +name: Continuous Integration + +on: + workflow_dispatch: + inputs: + deploy_production: + description: 'Also deploy to production after a successful build/test (in addition to the automatic test deploy)' + type: boolean + default: false + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [master] + +# Reusable settings for this workflow. Change these in one place if the .NET/Node/pnpm version, +# artifact names, or deploy destinations change. The env context is NOT available inside a called +# reusable workflow's `with:` inputs (see the `config` job below, which works around this by passing +# these values through as job outputs to the `deploy-test` / `deploy-production` jobs). +env: + DOTNET_VERSION: '10.0.x' + NODE_VERSION: '20' + PNPM_VERSION: '9' + PUBLISH_RID: linux-arm64 + ARTIFACT_NAME_TEST: app-test + ARTIFACT_NAME_PRODUCTION: app-production + DEPLOY_ENVIRONMENT_TEST: test + DEPLOY_ENVIRONMENT_PRODUCTION: production + DEPLOY_PATH_TEST: ${{ vars.DEPLOY_PATH_TEST }} + DEPLOY_PATH_PRODUCTION: ${{ vars.DEPLOY_PATH_PRODUCTION }} + SERVICE_NAME_TEST: ${{ vars.SERVICE_NAME_TEST }} + SERVICE_NAME_PRODUCTION: ${{ vars.SERVICE_NAME_PRODUCTION }} + HEALTH_CHECK_URL_TEST: ${{ vars.HEALTH_CHECK_URL_TEST }} + HEALTH_CHECK_URL_PRODUCTION: ${{ vars.HEALTH_CHECK_URL_PRODUCTION }} + +jobs: + # Passes the env: values above through as job outputs, since the env context is unavailable in + # the `with:` block of a job that calls a reusable workflow (see deploy-test / deploy-production). + config: + runs-on: ubuntu-latest + outputs: + artifact_name_test: ${{ steps.set.outputs.artifact_name_test }} + artifact_name_production: ${{ steps.set.outputs.artifact_name_production }} + deploy_environment_test: ${{ steps.set.outputs.deploy_environment_test }} + deploy_environment_production: ${{ steps.set.outputs.deploy_environment_production }} + deploy_path_test: ${{ steps.set.outputs.deploy_path_test }} + deploy_path_production: ${{ steps.set.outputs.deploy_path_production }} + service_name_test: ${{ steps.set.outputs.service_name_test }} + service_name_production: ${{ steps.set.outputs.service_name_production }} + health_check_url_test: ${{ steps.set.outputs.health_check_url_test }} + health_check_url_production: ${{ steps.set.outputs.health_check_url_production }} + steps: + - id: set + run: | + echo "artifact_name_test=${{ env.ARTIFACT_NAME_TEST }}" >> "$GITHUB_OUTPUT" + echo "artifact_name_production=${{ env.ARTIFACT_NAME_PRODUCTION }}" >> "$GITHUB_OUTPUT" + echo "deploy_environment_test=${{ env.DEPLOY_ENVIRONMENT_TEST }}" >> "$GITHUB_OUTPUT" + echo "deploy_environment_production=${{ env.DEPLOY_ENVIRONMENT_PRODUCTION }}" >> "$GITHUB_OUTPUT" + echo "deploy_path_test=${{ env.DEPLOY_PATH_TEST }}" >> "$GITHUB_OUTPUT" + echo "deploy_path_production=${{ env.DEPLOY_PATH_PRODUCTION }}" >> "$GITHUB_OUTPUT" + echo "service_name_test=${{ env.SERVICE_NAME_TEST }}" >> "$GITHUB_OUTPUT" + echo "service_name_production=${{ env.SERVICE_NAME_PRODUCTION }}" >> "$GITHUB_OUTPUT" + echo "health_check_url_test=${{ env.HEALTH_CHECK_URL_TEST }}" >> "$GITHUB_OUTPUT" + echo "health_check_url_production=${{ env.HEALTH_CHECK_URL_PRODUCTION }}" >> "$GITHUB_OUTPUT" + + # --- Gate 1: backend build --- + backend-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Restore and build (Release) + run: dotnet build SlpModularCms.sln -c Release + + # --- Gate 2: backend tests --- + backend-test: + needs: backend-build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Test (Release) + run: dotnet test SlpModularCms.sln -c Release + + # --- Gate 3: vulnerability scan --- + # `dotnet list package --vulnerable` always exits 0, even when it reports vulnerabilities, so the + # step greps its own output and fails deliberately (FR-22, D-12, OPEN-03 — closed by pinning + # Microsoft.OpenApi and System.Security.Cryptography.Xml in the relevant .csproj files). + vulnerability-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Check for vulnerable packages + run: | + OUTPUT=$(dotnet list SlpModularCms.sln package --vulnerable --include-transitive 2>&1) + echo "$OUTPUT" + if echo "$OUTPUT" | grep -q "has the following vulnerable packages"; then + echo "Vulnerable packages detected — see above." + exit 1 + fi + + frontend-prepare: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + - name: Get pnpm store directory + id: pnpm-store + working-directory: frontend + run: echo "path=$(pnpm store path)" >> "$GITHUB_OUTPUT" + - name: Cache pnpm store + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: pnpm-${{ hashFiles('frontend/pnpm-lock.yaml') }} + - name: Install dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + + # --- 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. + frontend-build: + needs: frontend-prepare + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + - name: Get pnpm store directory + id: pnpm-store + working-directory: frontend + run: echo "path=$(pnpm store path)" >> "$GITHUB_OUTPUT" + - name: Restore pnpm store + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: pnpm-${{ hashFiles('frontend/pnpm-lock.yaml') }} + - name: Install dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + - name: Build + working-directory: frontend + run: pnpm run build + + # --- Gate 5: frontend tests --- + frontend-test: + needs: frontend-prepare + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + - name: Get pnpm store directory + id: pnpm-store + working-directory: frontend + run: echo "path=$(pnpm store path)" >> "$GITHUB_OUTPUT" + - name: Restore pnpm store + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: pnpm-${{ hashFiles('frontend/pnpm-lock.yaml') }} + - name: Install dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + - name: Unit tests + working-directory: frontend + run: pnpm run test + + # --- Gate 6: frontend lint / format-check --- + frontend-lint: + needs: frontend-prepare + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + - name: Get pnpm store directory + id: pnpm-store + working-directory: frontend + run: echo "path=$(pnpm store path)" >> "$GITHUB_OUTPUT" + - name: Restore pnpm store + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: pnpm-${{ hashFiles('frontend/pnpm-lock.yaml') }} + - name: Install dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + - name: Lint + 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. + # + # 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 + # two CI-time values instead: the frontend's Umami script origin, and a Gitea variable that MUST be + # kept in sync with the host's real SecurityHeaders__AllowedScriptOrigins__0 env var (D-16). This + # gate cannot see the actual runtime CSP configuration; it only catches drift between the frontend + # build and what this variable claims the CSP allows. + publish-test: + needs: [backend-build, backend-test, vulnerability-scan, frontend-build, frontend-test, frontend-lint] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Verify Umami origin is permitted (REF-U5-01) + if: ${{ vars.VITE_UMAMI_SCRIPT_URL != '' }} + run: | + ORIGIN=$(echo "${{ vars.VITE_UMAMI_SCRIPT_URL }}" | sed -E 's#^(https?://[^/]+).*#\1#') + if ! echo ",${{ vars.SECURITY_ALLOWED_SCRIPT_ORIGINS_TEST }}," | grep -qF ",$ORIGIN,"; then + echo "Umami origin '$ORIGIN' is not present in the SECURITY_ALLOWED_SCRIPT_ORIGINS_TEST Gitea variable." + echo "Fix the variable, or the test environment's SecurityHeaders CSP config it must mirror (D-16)." + exit 1 + fi + + - name: Publish (test) + working-directory: src/SlpModularCms.Api + 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: > + dotnet publish -c Release -r ${{ env.PUBLISH_RID }} --self-contained false + -o ${{ github.workspace }}/${{ env.ARTIFACT_NAME_TEST }} + + - name: Upload publish artifact + uses: actions/upload-artifact@v3 + with: + name: ${{ env.ARTIFACT_NAME_TEST }} + path: ${{ env.ARTIFACT_NAME_TEST }} + retention-days: 1 + + # Same as publish-test, but only for a manual workflow_dispatch run with deploy_production set — + # a distinct job rather than a parameterized reuse of publish-test, because VITE_APP_ENV is a + # Vite build-time value: one dist/ bundle cannot be tagged as both 'test' and 'production' (FR-05). + publish-production: + needs: [backend-build, backend-test, vulnerability-scan, frontend-build, frontend-test, frontend-lint] + if: github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_production == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Verify Umami origin is permitted (REF-U5-01) + if: ${{ vars.VITE_UMAMI_SCRIPT_URL != '' }} + run: | + ORIGIN=$(echo "${{ vars.VITE_UMAMI_SCRIPT_URL }}" | sed -E 's#^(https?://[^/]+).*#\1#') + if ! echo ",${{ vars.SECURITY_ALLOWED_SCRIPT_ORIGINS_PRODUCTION }}," | grep -qF ",$ORIGIN,"; then + echo "Umami origin '$ORIGIN' is not present in the SECURITY_ALLOWED_SCRIPT_ORIGINS_PRODUCTION Gitea variable." + echo "Fix the variable, or the production environment's SecurityHeaders CSP config it must mirror (D-16)." + exit 1 + fi + + - name: Publish (production) + working-directory: src/SlpModularCms.Api + 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: > + dotnet publish -c Release -r ${{ env.PUBLISH_RID }} --self-contained false + -o ${{ github.workspace }}/${{ env.ARTIFACT_NAME_PRODUCTION }} + + - name: Upload publish artifact + uses: actions/upload-artifact@v3 + with: + name: ${{ env.ARTIFACT_NAME_PRODUCTION }} + 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). + deploy-test: + needs: [publish-test, config] + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/master') + uses: ./.gitea/workflows/deploy-scp.yaml + secrets: inherit + with: + artifact_name: ${{ needs.config.outputs.artifact_name_test }} + environment: ${{ needs.config.outputs.deploy_environment_test }} + deploy_path: ${{ needs.config.outputs.deploy_path_test }} + service_name: ${{ needs.config.outputs.service_name_test }} + health_check_url: ${{ needs.config.outputs.health_check_url_test }} + run_db_backup: false + + # Production deploy ONLY on an explicit workflow_dispatch run with deploy_production checked + # (FR-04, D-09) — never reachable from a plain push to master, so pushing cannot deploy production + # under any circumstance. + deploy-production: + needs: [publish-production, config] + if: github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_production == 'true' + uses: ./.gitea/workflows/deploy-scp.yaml + secrets: inherit + with: + artifact_name: ${{ needs.config.outputs.artifact_name_production }} + environment: ${{ needs.config.outputs.deploy_environment_production }} + deploy_path: ${{ needs.config.outputs.deploy_path_production }} + service_name: ${{ needs.config.outputs.service_name_production }} + health_check_url: ${{ needs.config.outputs.health_check_url_production }} + run_db_backup: true diff --git a/aidlc-docs/features/gitea-deployment-workflow/audit.md b/aidlc-docs/features/gitea-deployment-workflow/audit.md index c0cf6c2..10d9eb2 100644 --- a/aidlc-docs/features/gitea-deployment-workflow/audit.md +++ b/aidlc-docs/features/gitea-deployment-workflow/audit.md @@ -1104,3 +1104,47 @@ by Infrastructure Design. U5 carries REF-U3-01's Umami origin gate plus FR-21 an **Context**: CONSTRUCTION — Round 3 — U6 Code Generation Part 2 complete, presenting completion message --- +## Code Generation — U6 Deploy Workflow (Approved) + +**Timestamp**: 2026-07-28T00:45:00Z + +**User Input**: "Continue to Next Stage" + +**AI Response**: "U6 Code Generation approved. Committed as two commits: 9a77eec (Infrastructure Design) and bd2a963 (Code Generation — deploy-scp.yaml). Updated aidlc-state.md. Proceeding to Code Generation planning for U5 CI Workflow & Quality Gates." + +**Context**: CONSTRUCTION — Round 3 — U6 fully complete; entering U5 Code Generation + +--- +## Code Generation Plan — U5 CI Workflow & Quality Gates (Planning + partial execution) + +**Timestamp**: 2026-07-28T01:10:00Z + +**User Input**: "(implicit — continuing after U6 approval)" + +**AI Response**: "Investigated FR-21 (lint) and FR-22 (vulnerable packages) directly, since resolving them required live toolchain output rather than design work: fixed the current pnpm lint state (5 errors + 1 warning from FR-21's list, plus 2 additional problems found by drift — badge.tsx and a previously-hidden SetStatusDialog effect the React Compiler plugin could not reach until the watch() incompatibility was fixed), and pinned Microsoft.OpenApi (2.11.0) and System.Security.Cryptography.Xml (10.0.10) via live NuGet advisory lookups, closing OPEN-03. Verified: 372 backend tests, 237 frontend tests, tsc clean, frontend build clean, 0 vulnerable packages solution-wide. Created u5-ci-workflow-code-generation-plan.md documenting both completed steps plus the pending step (the CI workflow YAML itself), including a new mechanism decision (REF-U5-01): the REF-U3-01 gate cannot read the live host env var per D-16, so it is implemented against a parallel pair of Gitea variables that must be kept in sync with the host manually. Presenting for approval before writing continuous_integration.yaml." + +**Context**: CONSTRUCTION — Round 3 — U5 Code Generation Part 1, prerequisite fixes done and verified; awaiting approval for the CI workflow YAML itself + +--- +## Code Generation Plan — U5 (Approved) + +**Timestamp**: 2026-07-28T01:15:00Z + +**User Input**: "Approve Plan" + +**AI Response**: "Proceeding to generate continuous_integration.yaml." + +**Context**: CONSTRUCTION — Round 3 — U5 Code Generation Part 2 starting + +--- +## Code Generation — U5 CI Workflow & Quality Gates (Complete) + +**Timestamp**: 2026-07-28T01:40:00Z + +**User Input**: "(implicit — plan approved, generation executed)" + +**AI Response**: "Generated .gitea/workflows/continuous_integration.yaml (12 jobs). Discovered and resolved REF-U5-01 during generation: the REF-U3-01 gate's design assumed the CI job could read the backend's live SecurityHeaders:AllowedScriptOrigins, but per D-16 that's a host env var invisible to the runner — resolved with a parallel Gitea variable pair that must be kept in sync with the host manually, carried to Operations. Clarified the artifact model: frontend-build is a standalone PR gate; the real per-environment artifacts come from dotnet publish, which triggers the embedded admin SPA build via the existing MSBuild target. Validated YAML (12 jobs, deploy-production confirmed unreachable without workflow_dispatch+flag). Re-confirmed full verification: 372 backend tests, 237 frontend tests, tsc clean, frontend build clean, lint clean, 0 vulnerable packages. Documented in construction/u5-ci-workflow/code/generation-summary.md." + +**Context**: CONSTRUCTION — Round 3 — U5 Code Generation complete, presenting completion message + +--- diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u5-ci-workflow-code-generation-plan.md b/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u5-ci-workflow-code-generation-plan.md new file mode 100644 index 0000000..57b8c24 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u5-ci-workflow-code-generation-plan.md @@ -0,0 +1,135 @@ +# Code Generation Plan — U5 CI Workflow & Quality Gates + +## Unit Context + +- **Unit**: U5 CI Workflow & Quality Gates (Pipeline-type — YAML + prerequisite frontend/package + fixes, no new C# business logic) +- **Component**: C-12 CI workflow (`.gitea/workflows/continuous_integration.yaml`) +- **Requirements**: FR-01, FR-04, FR-05, FR-21, FR-22 +- **Carried-in item**: REF-U3-01 — a blocking gate comparing `VITE_UMAMI_SCRIPT_URL` against that + environment's `SecurityHeaders:AllowedScriptOrigins`, since the backend cannot see the frontend's + build-time Umami variable itself +- **Depends on**: U3, U4 (both committed — gates must run against finished application code, per + `unit-of-work-dependency.md` Ordering Constraint 4) +- **Depended on by**: U6 (invoked by this workflow's `deploy-test` / `deploy-production` jobs) +- **No test project applies**: same category as U6 — `.gitea/workflows/` is pipeline + configuration. The lint fixes and package pins ARE ordinary application/frontend code changes and + are covered by the existing backend test suite and frontend lint/type-check, not a new test + project. + +## Note on sequencing (transparency) + +Two of this plan's steps — the lint fixes (FR-21) and the package pins (FR-22) — were investigated +and completed during initial analysis, before this plan document existed, because they required +running the actual toolchain (`pnpm run lint`, `dotnet list package --vulnerable`, live NuGet +advisory lookups) to discover the *current* facts (exact files/lines, exact patched versions) rather +than anything design-level. They are recorded here as completed steps for full traceability, and are +already build/test-verified (see Step 13.5 record below). **Step 3 — the CI workflow YAML itself — +is the step awaiting your review before being written.** + +## Steps + +- [x] Step 1: Fix the 5 lint errors + 1 warning (FR-21) + - `AddCmsInstanceDialog.tsx:55` and `InviteUserDialog.tsx:50` (`react-hooks/set-state-in-effect`): + replaced the close-triggered `useEffect` with a `handleOpenChange` wrapper around the `Dialog`'s + `onOpenChange`, since resetting state on close is an event response, not a render synchronization + - `InviteUserDialog.tsx:54` (`react-hooks/immutability`, `reset` used before its declaration): + resolved as part of the same change — `useForm()` now runs before the effect it used to feed + - `SettingsPage.tsx:40` (`react-hooks/set-state-in-effect`): replaced with the "adjust state + during render" pattern (compare fetched `availability` against a tracked previous value, call + `setState` directly in the render body) — the React-documented alternative when state is derived + from changing data rather than a user action + - `SetStatusDialog.tsx:72` (warning, `react-hooks/incompatible-library` on `watch()`): replaced + `watch('status')` with `useWatch({ control, name: 'status' })`, react-hook-form's own + compiler-compatible alternative + - **Drift found during analysis, fixed as part of the same requirement, not filed**: current + `pnpm run lint` no longer matched FR-21's original 6-item list exactly — + `SetStatusDialog.tsx:32` from the requirement was already clean, but two problems FR-21 did not + list were present: `badge.tsx:32` (`react-refresh/only-export-components`, fixed identically to + the existing precedent in `button.tsx` — an inline eslint-disable, not a file split, to match + established convention) and a second, previously-hidden effect in + `SetStatusDialog.tsx` (lines 74–86) that the React Compiler's lint plugin had not been able to + analyze until the `watch()` incompatibility above was resolved. Fixed with the same + render-time-sync pattern as `SettingsPage.tsx`, split so the close-triggered reset lives in + `handleOpenChange` and the instance-driven reset lives in the render-time sync + - Result: `pnpm run lint` — 0 problems + +- [x] Step 2: Pin vulnerable packages (FR-22, closing OPEN-03) + - `Microsoft.OpenApi` 2.0.0 (transitive via `Microsoft.AspNetCore.OpenApi` 10.0.9, high severity, + GHSA-v5pm-xwqc-g5wc) → pinned to `2.11.0` in `SlpModularCms.Core.csproj` (latest 2.x; patched at + 2.7.5+, deliberately not jumping to the 3.x major that `Microsoft.AspNetCore.OpenApi` 10.0.9 + does not target) + - `System.Security.Cryptography.Xml` 10.0.9 (transitive via `Microsoft.AspNetCore.DataProtection`, + five high-severity advisories, all patched at 10.0.10) → pinned to `10.0.10` in + `SlpModularCms.Core.csproj`, plus independently in `SlpModularCms.Modules.Master.Tests.csproj` + and `SlpModularCms.Modules.Availability.Tests.csproj`, which each carry their own direct + `Microsoft.AspNetCore.DataProtection` reference for testing and therefore have their own + unpinned path to the vulnerable transitive version that Core's pin alone does not reach + - Result: `dotnet list SlpModularCms.sln package --vulnerable --include-transitive` — clean across + all 10 projects + +- [x] Step 3: Generate `.gitea/workflows/continuous_integration.yaml` + - Triggers: `pull_request` (opened/synchronize/reopened, validation only), `push` to `master` + (validation + automatic test deploy), `workflow_dispatch` with `deploy_production` boolean + (default `false`) + - `config` job: turns environment-specific `env:` values into job outputs (same reason as the + reference project — the `env` context is unavailable inside a called reusable workflow's `with:` + block) + - Six blocking gates (FR-01, D-10): backend build (`dotnet build -c Release`), backend tests + (`dotnet test -c Release`), vulnerability scan (`dotnet list package --vulnerable`, now clean per + Step 2), frontend build, frontend tests, frontend lint/format-check (now clean per Step 1) + - **REF-U3-01 gate — a mechanism decision this unit must make, not just implement**: the pattern + doc says the CI job compares "both values" (frontend's `VITE_UMAMI_SCRIPT_URL` vs. backend's + `SecurityHeaders:AllowedScriptOrigins`), but per D-16 the live backend value is a **host** + environment variable the Gitea runner cannot read — no `appsettings.{Environment}.json` exists; + there's only the single, environment-agnostic `appsettings.json`. Reading the real runtime value + is therefore not possible from CI, the exact problem this gate exists to solve, one level up. + **Resolution (new, flagged as REF-U5-01)**: add a second pair of Gitea Actions variables, + `SECURITY_ALLOWED_SCRIPT_ORIGINS_TEST` / `_PRODUCTION`, that mirror the intended host env var + value and are compared against `VITE_UMAMI_SCRIPT_URL_TEST` / `_PRODUCTION` at CI time. This + makes the gate implementable today, at the cost of a second place that must be kept in sync with + the host's actual `SecurityHeaders__AllowedScriptOrigins__0` env var — carried to Operations + host-setup documentation as an explicit "these two must match" note, not silently assumed + - **Refinement during generation**: `VITE_UMAMI_SCRIPT_URL` is one shared Gitea variable for both + environments (D-24 — one Umami instance), not a `_TEST`/`_PRODUCTION` pair; only + `VITE_UMAMI_WEBSITE_ID` differs per environment, matching the reference project's exact pattern. + The REF-U5-01 gate therefore compares the same origin against each environment's own + `SECURITY_ALLOWED_SCRIPT_ORIGINS_*` variable — two gate runs, one shared input + - **Artifact clarification during generation**: the "frontend build" gate (`pnpm run build`) is a + standalone PR-time validation, independent of environment. The actual per-environment deployable + artifact is a full `dotnet publish` of `SlpModularCms.Api` (`publish-test` / `publish-production` + jobs), which triggers the admin SPA's build a second time internally via the existing + `BuildAndCopyAdminFrontend` MSBuild target — the environment's `VITE_*` variables are set on that + step's process environment, not passed as build arguments, since that target invokes `pnpm build` + directly + - Two environment-specific publish artifacts (FR-05): `publish-test` + (`VITE_APP_ENV=test`) and `publish-production` (gated on `workflow_dispatch` with + `deploy_production: true`), each with its own `VITE_UMAMI_WEBSITE_ID`, shared `VITE_SENTRY_DSN` + and `VITE_UMAMI_SCRIPT_URL` + - Toolchain pinned explicitly (`actions/setup-dotnet`, `pnpm/action-setup`), no `latest` tags (D-04) + - `deploy-test` / `deploy-production` jobs call `./.gitea/workflows/deploy-scp.yaml` (U6) with + `secrets: inherit`, supplying `artifact_name`, `environment`, `deploy_path`, `service_name`, + `health_check_url` from Gitea variables per `infrastructure-design.md` § 6, plus + `run_db_backup: true` only for `deploy-production` + - Production reachable only via `workflow_dispatch` with the flag set (FR-04) — `deploy-test` never + triggers a production deploy under any push condition + +- [x] Step 4: Validate YAML + - Parsed successfully: 12 jobs, `deploy-production`'s condition requires + `workflow_dispatch` AND `deploy_production == 'true'` — confirmed unreachable from a bare `push` + event (FR-04). Job graph: `config`/`backend-build`/`vulnerability-scan`/`frontend-prepare` run + independently → `backend-test` needs `backend-build` → `frontend-build`/`frontend-test`/ + `frontend-lint` need `frontend-prepare` → `publish-test`/`publish-production` need all six gates + → `deploy-test`/`deploy-production` need their publish job plus `config` + +- [x] Step 5: Documentation — `construction/u5-ci-workflow/code/generation-summary.md` + +## Story / Requirement Traceability + +| Step | Covers | +|---|---| +| 1 | FR-21 | +| 2 | FR-22, OPEN-03 | +| 3 | FR-01, FR-04, FR-05, REF-U3-01 | +| 4 | Definition of Done: "workflow YAML is syntactically valid" | +| 5 | Definition of Done: "all six gates pass locally against the current tree" — recorded from Steps 1–2's verification plus this unit's own build/test check (Step 13.5) | diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/u5-ci-workflow/code/generation-summary.md b/aidlc-docs/features/gitea-deployment-workflow/construction/u5-ci-workflow/code/generation-summary.md new file mode 100644 index 0000000..f3a9272 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/u5-ci-workflow/code/generation-summary.md @@ -0,0 +1,101 @@ +# Code Generation Summary — U5 CI Workflow & Quality Gates + +**Date**: 2026-07-28 + +## Files Created + +- `.gitea/workflows/continuous_integration.yaml` — 12-job CI workflow (C-12) + +## Files Modified (FR-21 — lint fixes) + +- `frontend/src/components/cms/AddCmsInstanceDialog.tsx` — close-triggered reset moved from a + `useEffect` into a `handleOpenChange` wrapper (event response, not a render synchronization) +- `frontend/src/components/users/InviteUserDialog.tsx` — same pattern, plus reordered `useForm()` + before its use +- `frontend/src/pages/SettingsPage.tsx` — replaced the prop-sync effect with the "adjust state + during render" pattern (comparing fetched `availability` against a tracked previous value) +- `frontend/src/components/cms/SetStatusDialog.tsx` — `watch('status')` replaced with + `useWatch({ control, name: 'status' })`; the instance-driven reset effect (which the React + Compiler plugin could not previously analyze past the `watch()` incompatibility) split into a + render-time sync (on `instance` change) and a `handleOpenChange` wrapper (on close) +- `frontend/src/components/ui/badge.tsx` — added an inline `eslint-disable-next-line + react-refresh/only-export-components`, matching the existing precedent in `button.tsx` rather than + introducing a new file-splitting convention + +## Files Modified (FR-22 — package pins, closing OPEN-03) + +- `src/SlpModularCms.Core/SlpModularCms.Core.csproj` — added `Microsoft.OpenApi` `2.11.0` and + `System.Security.Cryptography.Xml` `10.0.10` +- `src/SlpModularCms.Modules.Master.Tests/SlpModularCms.Modules.Master.Tests.csproj` and + `src/SlpModularCms.Modules.Availability.Tests/SlpModularCms.Modules.Availability.Tests.csproj` — + added `System.Security.Cryptography.Xml` `10.0.10` (both projects carry their own direct + `Microsoft.AspNetCore.DataProtection` reference for testing, an independent path to the vulnerable + transitive version that Core's pin alone does not reach) + +## What the CI Workflow Implements + +Twelve jobs: `config` (env→output passthrough), the six blocking gates (`backend-build`, +`backend-test`, `vulnerability-scan`, `frontend-build`, `frontend-test`, `frontend-lint`), +`publish-test`/`publish-production` (environment-specific `dotnet publish`, each running the +REF-U5-01 Umami-origin drift check first), and `deploy-test`/`deploy-production` (call +`deploy-scp.yaml` from U6 with `secrets: inherit`). + +Triggers: `pull_request` (validation only — the six gates, no publish/deploy jobs run since none of +them are reachable without `push`-to-`master` or `workflow_dispatch`), `push` to `master` +(validation + automatic test deploy), `workflow_dispatch` with `deploy_production` (default `false`). + +## New Decisions Raised During This Unit + +**REF-U5-01** — the design record said the REF-U3-01 gate compares "both values" without saying +how the CI job would read the backend side. It can't: per D-16, the live +`SecurityHeaders:AllowedScriptOrigins` is a **host** environment variable, invisible to the Gitea +runner, exactly the class of problem REF-U3-01 already exists to solve for the frontend's Umami ID. +Resolved by adding `SECURITY_ALLOWED_SCRIPT_ORIGINS_TEST` / `_PRODUCTION` Gitea variables that must +be **manually kept in sync** with the corresponding host env var — carried to Operations host-setup +documentation as an explicit two-places-must-match note, not silently assumed to always agree. + +**Artifact model clarified**: unlike the reference project (where the frontend `dist/` folder *is* +the deployable artifact), this repository's admin SPA is embedded into the .NET publish output by +the existing `BuildAndCopyAdminFrontend` MSBuild target. The CI workflow's `frontend-build` gate +therefore validates the frontend independently (PR-time signal only); the actual per-environment +deployable artifacts are produced by `publish-test` / `publish-production`, which set the +environment's `VITE_*` variables on the `dotnet publish` process so the embedded frontend build +picks them up exactly as it would during a local `dotnet publish`. + +## Gitea Variables and Secrets Introduced by This Unit + +Beyond what `infrastructure-design.md` § 6 already defined for U6: + +| Variable | Scope | Purpose | +|---|---|---| +| `VITE_SENTRY_DSN` | shared | Frontend Sentry DSN, same value both environments (D-19 — one Sentry project) | +| `VITE_UMAMI_SCRIPT_URL` | shared | Umami script host, same value both environments (D-24 — one Umami instance) | +| `VITE_UMAMI_WEBSITE_ID_TEST` / `_PRODUCTION` | per-environment | Umami website entry ID | +| `SECURITY_ALLOWED_SCRIPT_ORIGINS_TEST` / `_PRODUCTION` | per-environment | REF-U5-01 gate input — must mirror the host's `SecurityHeaders__AllowedScriptOrigins__0` | + +## Verification (Step 13.5 — this unit's own check) + +- **Lint (FR-21)**: `pnpm run lint` — 0 problems (was 6; 2 more found by drift, all fixed) +- **Vulnerability scan (FR-22)**: `dotnet list SlpModularCms.sln package --vulnerable + --include-transitive` — clean across all 10 projects (was 2 packages, multiple advisories each) +- **Backend build**: `dotnet build SlpModularCms.sln -c Release` — 0 errors +- **Backend tests**: 372 passed, 0 failed (196 Core, 82 Availability, 57 Master, 37 Identity) — + unchanged from the Round 2 baseline, confirming the package pins introduced no regressions +- **Frontend type-check**: `npx tsc -b` — clean +- **Frontend tests**: 237 passed, 0 failed — unchanged from the Round 2 baseline, confirming the + dialog refactors preserved behaviour +- **Frontend build**: `pnpm run build` — succeeds +- **CI workflow YAML**: parsed successfully — 12 jobs, `deploy-production` confirmed unreachable + without `workflow_dispatch` + the flag + +No C# test project applies to `.gitea/workflows/` itself (pipeline configuration, same exception as +U6 and the Slave API). Real execution — an actual Gitea instance running the workflow against the +Pi — is out of scope until Operations, matching U6's boundary. + +## Deferred to Operations (not built here, by design) + +- Actual values for all Gitea variables and secrets listed above and in + `infrastructure-design.md` § 6 +- Keeping `SECURITY_ALLOWED_SCRIPT_ORIGINS_*` in sync with the host's real CSP configuration + (REF-U5-01) +- Real pipeline run against the actual Gitea instance and self-hosted runner diff --git a/frontend/src/components/cms/AddCmsInstanceDialog.tsx b/frontend/src/components/cms/AddCmsInstanceDialog.tsx index 962c1c1..4a19476 100644 --- a/frontend/src/components/cms/AddCmsInstanceDialog.tsx +++ b/frontend/src/components/cms/AddCmsInstanceDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { toast } from 'sonner'; @@ -50,14 +50,14 @@ export function AddCmsInstanceDialog({ open, onOpenChange }: AddCmsInstanceDialo mode: 'onTouched', }); - useEffect(() => { - if (!open) { + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { setServerError(null); addInstance.reset(); reset(); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]); + onOpenChange(nextOpen); + }; const onSubmit = handleSubmit(async (values) => { setServerError(null); @@ -71,7 +71,7 @@ export function AddCmsInstanceDialog({ open, onOpenChange }: AddCmsInstanceDialo }); return ( - + {t('cms.add.title')} diff --git a/frontend/src/components/cms/SetStatusDialog.tsx b/frontend/src/components/cms/SetStatusDialog.tsx index 8253b65..f071075 100644 --- a/frontend/src/components/cms/SetStatusDialog.tsx +++ b/frontend/src/components/cms/SetStatusDialog.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react'; -import { useForm, Controller } from 'react-hook-form'; +import { useState } from 'react'; +import { useForm, useWatch, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; @@ -60,7 +60,6 @@ export function SetStatusDialog({ instance, open, onOpenChange }: SetStatusDialo register, handleSubmit, control, - watch, reset, formState: { errors, isSubmitting }, } = useForm({ @@ -69,21 +68,28 @@ export function SetStatusDialog({ instance, open, onOpenChange }: SetStatusDialo defaultValues: { status: 'Available', disableMessage: '' }, }); - const selectedStatus = watch('status'); + const selectedStatus = useWatch({ control, name: 'status' }); - useEffect(() => { - if (!open || !instance) { + // Adjusts form values when the target instance changes, without an Effect + // (https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes). + const [syncedInstance, setSyncedInstance] = useState(instance); + if (instance !== syncedInstance) { + setSyncedInstance(instance); + if (instance) { + reset({ status: instance.status, disableMessage: instance.disableMessage ?? '' }); + } else { + reset({ status: 'Available', disableMessage: '' }); + } + } + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { setServerError(null); updateStatus.reset(); reset({ status: 'Available', disableMessage: '' }); - } else { - reset({ - status: instance.status, - disableMessage: instance.disableMessage ?? '', - }); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, instance]); + onOpenChange(nextOpen); + }; const onSubmit = handleSubmit(async (values) => { if (!instance) return; @@ -100,7 +106,7 @@ export function SetStatusDialog({ instance, open, onOpenChange }: SetStatusDialo }); return ( - + {t('cms.setStatus.title')} diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx index 97d9ada..e5d446f 100644 --- a/frontend/src/components/ui/badge.tsx +++ b/frontend/src/components/ui/badge.tsx @@ -29,4 +29,5 @@ function Badge({ className, variant, ...props }: BadgeProps) { return
; } +// eslint-disable-next-line react-refresh/only-export-components export { Badge, badgeVariants }; diff --git a/frontend/src/components/users/InviteUserDialog.tsx b/frontend/src/components/users/InviteUserDialog.tsx index 4df85fa..ba62193 100644 --- a/frontend/src/components/users/InviteUserDialog.tsx +++ b/frontend/src/components/users/InviteUserDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { toast } from 'sonner'; @@ -45,17 +45,6 @@ export function InviteUserDialog({ open, onOpenChange }: InviteUserDialogProps) }, }); - useEffect(() => { - if (!open) { - setStep(1); - setInviteLink(null); - setServerError(null); - inviteUser.reset(); - reset(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]); - const { register, handleSubmit, @@ -66,6 +55,17 @@ export function InviteUserDialog({ open, onOpenChange }: InviteUserDialogProps) resolver: zodResolver(inviteUserSchema), }); + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { + setStep(1); + setInviteLink(null); + setServerError(null); + inviteUser.reset(); + reset(); + } + onOpenChange(nextOpen); + }; + const onSubmit = handleSubmit(async (values) => { setServerError(null); try { @@ -84,7 +84,7 @@ export function InviteUserDialog({ open, onOpenChange }: InviteUserDialogProps) }; return ( - + diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index b1779a1..641edff 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { Lock } from 'lucide-react'; @@ -35,12 +35,16 @@ export function SettingsPage() { const [selectedMode, setSelectedMode] = useState('Available'); const [reason, setReason] = useState(''); - useEffect(() => { + // Adjusts local edit state when the fetched availability changes, without an Effect + // (https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes). + const [syncedAvailability, setSyncedAvailability] = useState(availability); + if (availability !== syncedAvailability) { + setSyncedAvailability(availability); if (availability) { setSelectedMode(availability.status); setReason(availability.message ?? ''); } - }, [availability]); + } const isMasterControlled = availability?.isMasterControlled ?? false; diff --git a/src/SlpModularCms.Core/SlpModularCms.Core.csproj b/src/SlpModularCms.Core/SlpModularCms.Core.csproj index c36bc68..3777154 100644 --- a/src/SlpModularCms.Core/SlpModularCms.Core.csproj +++ b/src/SlpModularCms.Core/SlpModularCms.Core.csproj @@ -27,6 +27,17 @@ + + + diff --git a/src/SlpModularCms.Modules.Availability.Tests/SlpModularCms.Modules.Availability.Tests.csproj b/src/SlpModularCms.Modules.Availability.Tests/SlpModularCms.Modules.Availability.Tests.csproj index d700d35..b80f6ad 100644 --- a/src/SlpModularCms.Modules.Availability.Tests/SlpModularCms.Modules.Availability.Tests.csproj +++ b/src/SlpModularCms.Modules.Availability.Tests/SlpModularCms.Modules.Availability.Tests.csproj @@ -11,6 +11,8 @@ + + diff --git a/src/SlpModularCms.Modules.Master.Tests/SlpModularCms.Modules.Master.Tests.csproj b/src/SlpModularCms.Modules.Master.Tests/SlpModularCms.Modules.Master.Tests.csproj index 98edf8f..6dcb747 100644 --- a/src/SlpModularCms.Modules.Master.Tests/SlpModularCms.Modules.Master.Tests.csproj +++ b/src/SlpModularCms.Modules.Master.Tests/SlpModularCms.Modules.Master.Tests.csproj @@ -11,6 +11,8 @@ + +