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, any workflow_dispatch run, or a pull_request event # (FR-03, D-01, D-09 — pull_request added as a deliberate extension beyond the original scope, so # PR pushes can be verified against the real test environment without a manual workflow_dispatch # each time). There is only one shared test environment: with more than one PR open at once, each # push to any of them will redeploy and overwrite it — fine for a single active PR, revisit # (e.g. gate behind a label, or restrict to one branch) before that becomes a real scenario. deploy-test: needs: [publish-test, config] if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || (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