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.
350 lines
15 KiB
YAML
350 lines
15 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, 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
|