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
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
355 lines
16 KiB
YAML
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
|