name: Continuous Integration on: workflow_dispatch: inputs: deploy_test: description: 'Deploy to Test' type: boolean default: false deploy_production: description: 'Deploy to Production' 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" # Path-based skip: keeps the pipeline short when a change only touches one side of the repo. # fetch-depth: 0 gives paths-filter full local git history to diff against, rather than relying # on its GitHub-API fallback for missing history, which targets GitHub's REST API and cannot be # assumed to work against a Gitea remote. Changes under the workflow files themselves count as # both backend and frontend, so a workflow edit is never left partially untested. A manual # workflow_dispatch run has no `before` commit to diff against and is always an explicit request # to run everything, so it force-reports both sides as changed rather than trusting the filter. changes: runs-on: ubuntu-latest outputs: backend: ${{ github.event_name == 'workflow_dispatch' || steps.filter.outputs.backend == 'true' }} frontend: ${{ github.event_name == 'workflow_dispatch' || steps.filter.outputs.frontend == 'true' }} steps: # Skipped on workflow_dispatch: there's no `before` commit to diff against, and a manual run # always force-reports both sides changed anyway (see the outputs above), so the diff would be # wasted work. - uses: actions/checkout@v4 if: github.event_name != 'workflow_dispatch' with: fetch-depth: 0 - uses: dorny/paths-filter@v3 if: github.event_name != 'workflow_dispatch' id: filter with: filters: | workflow: &workflow - '.gitea/workflows/**' backend: - *workflow - 'src/**' - 'tests/**' - '*.sln' - '**/*.csproj' frontend: - *workflow - 'frontend/**' # --- Gate 1: backend build --- backend-build: needs: changes if: needs.changes.outputs.backend == 'true' 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: [changes, backend-build] if: needs.changes.outputs.backend == 'true' 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: needs: changes if: needs.changes.outputs.backend == 'true' 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: needs: changes if: needs.changes.outputs.frontend == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 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 each publish job's own "Build admin # frontend" step (publish-test / publish-production, below), which builds frontend/ again with # that environment's Vite variables set and copies the result into wwwroot/admin before # `dotnet publish` runs — see that step's comment for why this isn't an MSBuild target instead. frontend-build: needs: [changes, frontend-prepare] if: needs.changes.outputs.frontend == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 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: [changes, frontend-prepare] if: needs.changes.outputs.frontend == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 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: [changes, frontend-prepare] if: needs.changes.outputs.frontend == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 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 by this job's own # "Build admin frontend" step below, ahead of `dotnet publish` — see that step's comment for why. # # REF-U5-01: the Umami-origin drift check this gate performs exists because U3's own startup check # (BR-U3-22) can't work — the backend can never see VITE_UMAMI_WEBSITE_ID at runtime. It compares # 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: [changes, backend-build, backend-test, vulnerability-scan, frontend-build, frontend-test, frontend-lint] # always() bypasses the automatic skip-cascade from a gate job that was itself skipped (because # its side of the repo didn't change). Gitea Actions only implements always() from the GitHub # Actions status-check functions — success()/failure()/cancelled() are not supported — so each # gate's outcome is checked explicitly via needs..result instead: a skipped gate is fine, # but a gate that actually ran and failed or was cancelled still blocks the publish. The final # changes.outputs check makes sure there's something to publish at all: a run that touched # neither backend nor frontend has nothing new to deploy. if: | always() && needs.changes.result == 'success' && needs.backend-build.result != 'failure' && needs.backend-build.result != 'cancelled' && needs.backend-test.result != 'failure' && needs.backend-test.result != 'cancelled' && needs.vulnerability-scan.result != 'failure' && needs.vulnerability-scan.result != 'cancelled' && needs.frontend-build.result != 'failure' && needs.frontend-build.result != 'cancelled' && needs.frontend-test.result != 'failure' && needs.frontend-test.result != 'cancelled' && needs.frontend-lint.result != 'failure' && needs.frontend-lint.result != 'cancelled' && (needs.changes.outputs.backend == 'true' || needs.changes.outputs.frontend == '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@v3 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 # Builds the admin SPA and lands it in wwwroot/admin *before* dotnet publish runs, as its own # step rather than an MSBuild target hooked to Build/Publish. That was tried and reliably # failed two different ways (see SlpModularCms.Api.csproj's comment on the topic): the SDK's # wwwroot static-web-asset item set is fixed at project evaluation time, before any target # runs, so files a target creates afterward never make it into the publish output; and forcing # them in as an explicit Content item collided with the SDK's own static-web-asset resolution. # Physically existing on disk before dotnet publish/build ever runs is the only thing that # worked, verified locally against a clean obj/bin and a fresh node_modules. - name: Build admin frontend working-directory: frontend env: VITE_APP_ENV: test VITE_UMAMI_SCRIPT_URL: ${{ vars.VITE_UMAMI_SCRIPT_URL }} VITE_UMAMI_WEBSITE_ID: ${{ vars.VITE_UMAMI_WEBSITE_ID_TEST }} VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} run: | pnpm install --frozen-lockfile pnpm build mkdir -p ../src/SlpModularCms.Api/wwwroot/admin cp -r dist/. ../src/SlpModularCms.Api/wwwroot/admin/ - name: Publish (test) working-directory: src/SlpModularCms.Api run: > dotnet publish -c Release -r ${{ env.PUBLISH_RID }} --self-contained false -o ${{ github.workspace }}/${{ env.ARTIFACT_NAME_TEST }} - 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: [changes, backend-build, backend-test, vulnerability-scan, frontend-build, frontend-test, frontend-lint] # See publish-test's comment above on why this checks needs..result explicitly instead of # failure()/cancelled(), which Gitea Actions doesn't support. if: | always() && needs.changes.result == 'success' && needs.backend-build.result != 'failure' && needs.backend-build.result != 'cancelled' && needs.backend-test.result != 'failure' && needs.backend-test.result != 'cancelled' && needs.vulnerability-scan.result != 'failure' && needs.vulnerability-scan.result != 'cancelled' && needs.frontend-build.result != 'failure' && needs.frontend-build.result != 'cancelled' && needs.frontend-test.result != 'failure' && needs.frontend-test.result != 'cancelled' && needs.frontend-lint.result != 'failure' && needs.frontend-lint.result != 'cancelled' && github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_production == 'true' && (needs.changes.outputs.backend == 'true' || needs.changes.outputs.frontend == '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@v3 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 # See publish-test's "Build admin frontend" step for why this runs as its own step ahead of # dotnet publish rather than an MSBuild target. - name: Build admin frontend working-directory: frontend env: VITE_APP_ENV: production VITE_UMAMI_SCRIPT_URL: ${{ vars.VITE_UMAMI_SCRIPT_URL }} VITE_UMAMI_WEBSITE_ID: ${{ vars.VITE_UMAMI_WEBSITE_ID_PRODUCTION }} VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} run: | pnpm install --frozen-lockfile pnpm build mkdir -p ../src/SlpModularCms.Api/wwwroot/admin cp -r dist/. ../src/SlpModularCms.Api/wwwroot/admin/ - name: Publish (production) working-directory: src/SlpModularCms.Api run: > dotnet publish -c Release -r ${{ env.PUBLISH_RID }} --self-contained false -o ${{ github.workspace }}/${{ env.ARTIFACT_NAME_PRODUCTION }} - 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 every push to master (unchanged — a merge should reach test without # extra steps). A manual workflow_dispatch run only deploys to test when deploy_test is checked, # since a dispatch run is often just re-running CI/production, not a new test build (FR-03, D-01, # D-09). deploy-test: needs: [publish-test, config] if: (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_test == 'true') || (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