Files
slp-modular-cms/.gitea/workflows/continuous_integration.yaml
T
SluijsensandClaude Sonnet 5 92547efd47
Continuous Integration / config (pull_request) Canceled after 0s
Continuous Integration / backend-build (pull_request) Canceled after 0s
Continuous Integration / backend-test (pull_request) Canceled after 0s
Continuous Integration / vulnerability-scan (pull_request) Canceled after 0s
Continuous Integration / frontend-prepare (pull_request) Canceled after 0s
Continuous Integration / frontend-build (pull_request) Canceled after 0s
Continuous Integration / frontend-test (pull_request) Canceled after 0s
Continuous Integration / frontend-lint (pull_request) Canceled after 0s
Continuous Integration / publish-test (pull_request) Canceled after 0s
Continuous Integration / publish-production (pull_request) Canceled after 0s
Continuous Integration / deploy-test (pull_request) Canceled after 0s
Continuous Integration / deploy-production (pull_request) Canceled after 0s
Allows deploy-test to run on pull_request events too
So PR pushes deploy straight to the test environment, without a manual
workflow_dispatch each time. Only one test environment exists, so this
means concurrent PRs would overwrite each other's deploy - acceptable
with a single active PR, worth revisiting once that's no longer true.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FffvxxJp5wG34Ru48GBig
2026-07-29 19:41:17 +02:00

355 lines
16 KiB
YAML

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