Continuous Integration / config (pull_request) Successful in 10s
Continuous Integration / backend-build (pull_request) Successful in 4m52s
Continuous Integration / vulnerability-scan (pull_request) Successful in 4m50s
Continuous Integration / frontend-prepare (pull_request) Successful in 1m44s
Continuous Integration / backend-test (pull_request) Successful in 5m37s
Continuous Integration / frontend-build (pull_request) Successful in 2m11s
Continuous Integration / frontend-test (pull_request) Successful in 4m33s
Continuous Integration / frontend-lint (pull_request) Successful in 2m2s
Continuous Integration / publish-production (pull_request) Skipped
Continuous Integration / deploy-production (pull_request) Skipped
Continuous Integration / publish-test (pull_request) Successful in 6m14s
Deploy (SCP) / deploy (pull_request) Successful in 1m20s
Continuous Integration / deploy-test (pull_request) Successful in 1m21s
The BuildAndCopyAdminFrontend MSBuild target (BeforeTargets=Publish) never actually worked: files it created after project evaluation were silently absent from the publish output, and forcing them in via an explicit Content item collided with the SDK's own static-web-asset resolution. Verified locally with a clean obj/bin and a fresh node_modules - every deploy so far genuinely shipped without wwwroot/admin. Moves the frontend build + copy into its own CI step ahead of dotnet publish, so the SDK's ordinary wwwroot handling picks it up with no custom MSBuild involved. Also fixes unreadable <code> badges on the website placeholder page (no explicit text color, relying on inherited body color pairing unreliably with the badge background).
381 lines
17 KiB
YAML
381 lines
17 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, 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
|