Files
slp-modular-cms/.gitea/workflows/continuous_integration.yaml
T
Sluijsens f10d7b983e
Continuous Integration / config (pull_request) Successful in 9s
Continuous Integration / backend-build (pull_request) Successful in 5m22s
Continuous Integration / vulnerability-scan (pull_request) Successful in 4m44s
Continuous Integration / frontend-prepare (pull_request) Successful in 1m39s
Continuous Integration / backend-test (pull_request) Successful in 5m11s
Continuous Integration / frontend-build (pull_request) Successful in 2m10s
Continuous Integration / frontend-test (pull_request) Successful in 4m27s
Continuous Integration / frontend-lint (pull_request) Successful in 1m56s
Continuous Integration / publish-production (pull_request) Skipped
Continuous Integration / deploy-production (pull_request) Skipped
Continuous Integration / publish-test (pull_request) Successful in 6m24s
Continuous Integration / deploy-test (pull_request) Skipped
Reverts deploy-test to push/workflow_dispatch only, drops the pull_request trigger
The pull_request trigger was a temporary convenience to test the deploy
pipeline itself against the real test environment on every PR push. Now
that the pipeline is verified working end to end, going back to only
deploying on a master push or manual dispatch avoids concurrent PRs
silently overwriting the single shared test environment.
2026-07-30 13:38:31 +02:00

376 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
# 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: [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
# 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 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