Skip to content

feat: implement PoBrawl presidents ladder leaderboard with CRUD opera… #173

feat: implement PoBrawl presidents ladder leaderboard with CRUD opera…

feat: implement PoBrawl presidents ladder leaderboard with CRUD opera… #173

Workflow file for this run

name: Deploy to Azure
# CI/CD policy (2026-07-04):
# - Unit tests (tests/PoMiniGames.Unit) DO run here — they have zero infra
# dependencies (no Azurite, no Playwright) so they are a cheap regression gate
# on the one branch that auto-deploys. Deploy needs both `build` and `test`.
# - Integration + E2E (API/UI) tests remain out-of-band: they require Azurite
# (Testcontainers) and Playwright Chromium and run locally via scripts/test-all.ps1.
# - master is the only branch that triggers deploy. Local + remote feature
# branches are merged into master via PRs. Branch-hygiene invariant:
# scripts/branch-hygiene.ps1.
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
workflow_dispatch:
permissions:
contents: read
id-token: write
concurrency:
group: deploy-${{ github.repository }}-${{ github.ref }}
cancel-in-progress: true
env:
DOTNET_VERSION: '10.0.x'
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
# `az deployment sub what-if` requires --location (the deployment record
# region; *not* the resource region, which lives in main.parameters.json).
# Must also match infra/main.bicep targetScope='subscription'.
AZURE_LOCATION: 'westus2'
jobs:
build:
name: Build (master only — no tests)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Required for MinVer to read the most recent v* tag (instead of
# stamping every build with 0.0.0-alpha.0.0). fetch-depth=0 is the
# cheapest setting that still gives MinVer a tag list.
fetch-depth: 0
- name: Check MinVer v* tag at HEAD
shell: bash
run: |
# MinVer stamps the assembly version from the nearest reachable v* tag.
# A tag exactly at HEAD produces a clean SemVer (e.g. v1.2.3); a commit
# ahead of the nearest tag produces a pre-release suffix (e.g. 1.2.4-alpha.1.0).
# Both are valid — pre-release builds are still identifiable in App Insights.
# We emit a warning (not a failure) when HEAD is untagged so hotfixes and
# config-only pushes are never blocked by a missing release tag.
git fetch --tags --force origin '+refs/tags/*:refs/tags/*'
TAGS=$(git tag --points-at HEAD | grep -E '^v[0-9]' || true)
if [ -z "$TAGS" ]; then
echo "::warning::No v* tag at HEAD ($(git rev-parse --short HEAD)). MinVer will stamp this build with a pre-release version."
echo "::warning::To create a release build: git tag -a vX.Y.Z <sha> && git push origin vX.Y.Z"
else
echo "MinVer will resolve to: $TAGS"
fi
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props', '**/global.json') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Restore
run: dotnet restore PoMiniGames.slnx
# Compile every project in the solution — src + tests — so a broken test
# file cannot slip into master. TreatWarningsAsErrors=true (Directory.Build.props)
# ensures zero warnings reach the artifact stage.
#
# PR builds stay in Debug to keep CI fast (no publish, no artifact upload);
# only push-to-master runs the Release publish + upload that feeds the
# deploy job. This halves PR build minutes without sacrificing the
# "broken test file fails master" guarantee.
- name: Build
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
dotnet build PoMiniGames.slnx --no-restore --configuration Debug
else
dotnet build PoMiniGames.slnx --no-restore --configuration Release
fi
# §7 #NET_CLEAN_10: trim-audit for the WASM client. The host's `dotnet publish`
# exercises server-side AOT but never compiles the Blazor client with trimming
# on, so a regression in PoShared / PoMiniGamesClient trim safety would only
# surface at the consumer's first deploy. This step runs the same
# PublishTrimmed + EnableTrimAnalyzer pipeline locally — if the analyzer
# surfaces IL2xxx warnings under Debug, TreatWarningsAsErrors fails the build.
# Release adds trimming at publish time; both must pass.
- name: Trim audit (WASM client)
run: |
dotnet publish src/PoMiniGames.Client/PoMiniGamesClient.csproj \
-c ${{ github.event_name == 'pull_request' && 'Debug' || 'Release' }} \
-p:PublishTrimmed=true \
-p:TrimMode=partial \
-o ./client-trim-audit \
--no-restore
# §6.6 mobile-first budget: the trimmed WASM _framework payload gates first
# paint on phones. Fail the build if it regresses past the budget so a new
# reflection-heavy DTO can't silently bloat the download. Baseline 2026-07-04
# ≈ 20 MB uncompressed — inherent to shipping the PoSurvive client-side
# simulation engine (by design; see AGENT.MD). Budget set with headroom to
# catch regression, not to squeeze the baseline.
- name: WASM bundle-size budget
shell: bash
env:
FRAMEWORK_BUDGET_MB: 25
run: |
SIZE_MB=$(du -sm ./client-trim-audit/wwwroot/_framework | cut -f1)
echo "Trimmed _framework size: ${SIZE_MB} MB (budget ${FRAMEWORK_BUDGET_MB} MB)"
if [ "$SIZE_MB" -gt "$FRAMEWORK_BUDGET_MB" ]; then
echo "::error::WASM _framework ${SIZE_MB} MB exceeds budget ${FRAMEWORK_BUDGET_MB} MB."
exit 1
fi
- name: Verify formatting (non-blocking)
run: dotnet format PoMiniGames.slnx --verify-no-changes --verbosity minimal
continue-on-error: true
- name: Publish (push-to-master only)
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
run: dotnet publish src/PoMiniGames/PoMiniGames/PoMiniGames.csproj -c Release -o ./publish
- name: Upload publish artifact (push-to-master only)
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
uses: actions/upload-artifact@v4
with:
name: api-publish
path: ./publish
test:
name: Unit tests (no infra)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props', '**/global.json') }}
restore-keys: |
${{ runner.os }}-nuget-
# Unit tier only — zero external dependencies. Integration/E2E stay local
# (Azurite + Playwright). See the CI/CD policy header.
- name: Run unit tests
run: dotnet test tests/PoMiniGames.Unit/PoMiniGames.Unit.csproj --configuration Debug
deploy:
name: Deploy to Azure App Service
needs: [build, test]
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
timeout-minutes: 20
environment:
name: production
permissions:
id-token: write
contents: read
env:
# Override at job scope in case the workflow env is overridden by a
# caller (workflow_dispatch with custom inputs).
AZURE_LOCATION: 'westus2'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Required for Bicep what-if: the deploy job otherwise only has the
# api-publish artifact and `infra/main.bicep` doesn't exist on the
# runner (run 28288927457 failed with ENOENT on it).
fetch-depth: 0
- name: Download publish artifact
uses: actions/download-artifact@v4
with:
name: api-publish
path: ./publish
- name: Azure Login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Bicep what-if (preview infra changes)
shell: bash
# Preview infra changes before any artifact reaches App Service. If
# Bicep would delete, recreate, or downgrade a shared resource (KV,
# App Insights, AI Foundry) we surface it in the run log here.
#
# continue-on-error: this is a SOFT gate. The deploy SP needs
# Microsoft.Authorization/roleAssignments/write on the target
# resources (and the subscription) to even *simulate* the
# assignments, and that permission is granted out-of-band on the
# Azure portal — bootstrapping it from CI is the chicken-and-egg
# problem that blocks the deploy entirely if this is a hard gate.
# A failed what-if is logged as a warning; a clean what-if shows
# the change set. Either way the App Service deploy proceeds.
run: |
az deployment sub what-if \
--location "${{ env.AZURE_LOCATION }}" \
--template-file infra/main.bicep \
--parameters infra/main.parameters.json \
--name "pominigames-preview-${{ github.run_id }}" \
--result-format FullResourcePayloads || \
echo "::warning::Bicep what-if failed (likely missing Microsoft.Authorization/roleAssignments/write on the deploy SP). The App Service deploy proceeds anyway."
continue-on-error: true
- name: Deploy to App Service
shell: bash
run: |
# Self-healing target resolution: discover the API web app from its
# resource group instead of hard-coding a name. A future resource
# rename can no longer silently 404 the deploy (the original failure mode).
APP_NAME=$(az webapp list --resource-group PoMiniGames --query "[0].name" -o tsv)
if [ -z "$APP_NAME" ]; then echo "::error::No web app found in resource group PoMiniGames"; exit 1; fi
echo "Resolved App Service: $APP_NAME"
echo "APP_NAME=$APP_NAME" >> "$GITHUB_ENV"
# Zip the publish *contents* (not the parent folder) so the host finds
# PoMiniGames.dll at the deployment root, not nested under publish/.
# Combined with WEBSITE_RUN_FROM_PACKAGE=1 (set in resources.bicep), the
# zip is mounted atomically — no file-copy step on the host.
( cd publish && zip -qr ../app-deploy.zip . )
az webapp deploy \
--resource-group PoMiniGames \
--name "$APP_NAME" \
--src-path app-deploy.zip \
--type zip \
--track-status true
- name: Prewarm (absorb F1 cold-start)
shell: bash
# F1 cannot enable AlwaysOn; the first request after a fresh deploy pays
# the full cold-start tax (~10–30s on a Windows F1). Hammering the cold
# code paths here keeps the retry-loop failures out of the next four
# smoke steps. We probe ping (cheapest), liveness (alias), structured
# /api/health, and /api/diag in order — every health surface must be
# warmed before the smoke tests run.
run: |
for path in "/api/health/ping" "/api/health/liveness" "/api/health" "/api/diag"; do
curl -fsS --retry 15 --retry-all-errors --retry-delay 15 \
"https://${APP_NAME}.azurewebsites.net${path}" || true
done
- name: Smoke Test - Health Ping
shell: bash
run: |
curl -fsS --retry 6 --retry-all-errors --retry-delay 10 \
"https://${APP_NAME}.azurewebsites.net/api/health/ping" || exit 1
- name: Smoke Test - Health Liveness
shell: bash
run: |
curl -fsS --retry 6 --retry-all-errors --retry-delay 10 \
"https://${APP_NAME}.azurewebsites.net/api/health/liveness" || exit 1
- name: Smoke Test - Full Health
shell: bash
run: |
curl -fsS --retry 6 --retry-all-errors --retry-delay 10 \
"https://${APP_NAME}.azurewebsites.net/api/health" || exit 1
- name: Smoke Test - Diag (masked KV + subsystem integrations)
shell: bash
run: |
curl -fsS --retry 6 --retry-all-errors --retry-delay 10 \
"https://${APP_NAME}.azurewebsites.net/api/diag" || exit 1