U5 — the gate everything else has to pass

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.
This commit is contained in:
2026-07-28 16:07:03 +02:00
parent bd2a963498
commit 9f4ae475e7
12 changed files with 691 additions and 36 deletions
@@ -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