Skip to content

fix(ui): sidebar collapse crushed the page into a 220px column #62

fix(ui): sidebar collapse crushed the page into a 220px column

fix(ui): sidebar collapse crushed the page into a 220px column #62

Workflow file for this run

name: CI/CD — Build, Test & Deploy
on:
push:
branches: [master]
pull_request:
branches: [master]
workflow_dispatch:
inputs:
deploy_infra:
description: 'Apply infra/main.bicep (az deployment sub create) after build'
type: boolean
default: false
# Block any other workflow or process from acquiring this lock while a deploy is
# in flight — App Service restarts are exclusive, so a parallel run can leave the
# slot in a half-upgraded state.
concurrency:
group: deploy-${{ github.repository }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
# OIDC federated credential — the workflow exchanges the GitHub OIDC token
# for an Azure access token via azure/login@v2; no client secret is stored.
id-token: write
env:
DOTNET_VERSION: '10.0.x'
AZURE_WEBAPP_NAME: app-porepolinetracker
API_PROJECT_PATH: src/PoRepoLineTracker.Api/PoRepoLineTracker.Api.csproj
jobs:
# Compile every Bicep file to ARM on every run — fast, no Azure login, and it
# catches template/type errors (and the kind of drift that broke prod) before
# any merge. Runs in parallel with the build so it does not add wall-clock time.
lint-infra:
name: Lint Infra (Bicep)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- name: Install Bicep CLI
run: az bicep install
- name: Compile Bicep → ARM (catches template errors)
run: |
az bicep build --file infra/main.bicep --stdout > /dev/null
az bicep build --file infra/resources.bicep --stdout > /dev/null
build:
name: Build
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
# NuGet cache — keyed on csproj + Directory.Packages.props, so a code-only
# change still reuses the cache and skips the network restore.
- name: Cache NuGet packages
uses: actions/cache@v5
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', 'Directory.Packages.props') }}
restore-keys: nuget-${{ runner.os }}-
- name: Restore
run: dotnet restore
- name: Build (Release, no-restore, treat-warnings-as-errors)
run: dotnet build --no-restore -c Release
# Unit tier gates the deploy: no I/O, no Docker, ~1s, so there is no reason to ship
# without it. The Integration/E2E tiers still run locally and in the Test environment —
# they need Azurite via Testcontainers and a live host.
- name: Test (Unit)
run: dotnet test tests/PoRepoLineTracker.Unit --no-build -c Release --verbosity minimal
# Publish fresh every run. (Publish-output caching was removed: its key did not
# hash *.cs, so pure C# changes hit the cache and shipped stale binaries.)
- name: Publish API
run: dotnet publish ${{ env.API_PROJECT_PATH }} -c Release --no-restore -o publish
- name: Upload webapp artifact
uses: actions/upload-artifact@v7
with:
name: webapp
path: publish
retention-days: 1
deploy:
name: Deploy to Azure
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
timeout-minutes: 15
steps:
- uses: actions/download-artifact@v8
with:
name: webapp
path: publish
# OIDC federated identity — no client secrets stored in GitHub.
# Federated credential subject: repo:punkouter26/PoRepoLineTracker:ref:refs/heads/master
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Package and zip deploy
run: |
cd publish
zip -r ../webapp.zip . -q
cd ..
- name: Deploy to App Service
uses: azure/webapps-deploy@v3
with:
app-name: ${{ env.AZURE_WEBAPP_NAME }}
package: webapp.zip
# Async deploy returns once the deployment is accepted; the smoke-test
# step below handles liveness. Removes the synchronous Kudu 504 risk.
# The DOTNETCORE runtime image has no git, but GitClient shells out to it for
# clone/pull — without this the site runs and every analysis fails with
# "git is not installed or not in PATH". startup.sh installs git, then execs the app.
# Setting it here (rather than by hand) also keeps the entry assembly correct across
# the .Api -> .API rename: the old literal `dotnet PoRepoLineTracker.Api.dll` startup
# command does not exist in the new build, and Linux paths are case-sensitive.
- name: Ensure startup command runs startup.sh
run: |
CURRENT=$(az webapp config show -n "${{ env.AZURE_WEBAPP_NAME }}" -g PoRepoLineTracker \
--query appCommandLine -o tsv)
WANT="/home/site/wwwroot/startup.sh"
if [ "$CURRENT" != "$WANT" ]; then
echo "startup command is '$CURRENT' — setting to '$WANT'"
az webapp config set -n "${{ env.AZURE_WEBAPP_NAME }}" -g PoRepoLineTracker \
--startup-file "$WANT" -o none
else
echo "startup command already '$WANT'"
fi
# Rule 5 post-deploy smoke test — all three required checks:
# 1. /health returns 200.
# 2. The Blazor render tree can initialise: the served shell must carry the WASM
# bootstrap script and the #app root the renderer attaches to, and the runtime
# files it pulls (blazor.boot.json, the framework dll) must be retrievable.
# 3. /diag is retrievable *safely* — it must be authenticated, and must never emit an
# unmasked secret. Production has no FakeAuthHandler, so CI cannot log in; asserting
# the endpoint refuses anonymous callers IS the safe-retrieval check. Masked-body
# content is asserted in the Integration tier, which can authenticate.
- name: Post-deploy smoke test
run: |
set -uo pipefail
BASE="https://${{ env.AZURE_WEBAPP_NAME }}.azurewebsites.net"
sleep 20
ok=""
for i in 1 2 3 4 5; do
HEALTH=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/health" --max-time 30 || echo 000)
HOME=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/" --max-time 30 || echo 000)
echo "Attempt $i: /health=$HEALTH /=$HOME"
if [ "$HEALTH" = "200" ] && [ "$HOME" != "000" ] && [ "${HOME:0:1}" != "5" ]; then
ok=1; break
fi
sleep 15
done
if [ -z "$ok" ]; then
echo "::error::Smoke test failed — /health not 200 or home page returned 5xx."
exit 1
fi
echo "✓ /health healthy, home page non-5xx."
# --- 2. Blazor render tree initialisation -------------------------------------
SHELL_HTML=$(curl -sL "$BASE/" --max-time 30 -H 'Accept: text/html')
for marker in 'id="app"' '_framework/blazor.webassembly.js'; do
if ! printf '%s' "$SHELL_HTML" | grep -qF "$marker"; then
echo "::error::Blazor shell is missing '$marker' — the render tree cannot initialise."
exit 1
fi
done
BOOT=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/_framework/blazor.boot.json" --max-time 30 || echo 000)
BOOTJS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/_framework/blazor.webassembly.js" --max-time 30 || echo 000)
echo "blazor.boot.json=$BOOT blazor.webassembly.js=$BOOTJS"
if [ "$BOOT" != "200" ] || [ "$BOOTJS" != "200" ]; then
echo "::error::Blazor runtime assets not served (boot.json=$BOOT, webassembly.js=$BOOTJS)."
exit 1
fi
echo "✓ Blazor render tree can initialise (shell markers + runtime assets present)."
# --- 3. Safe retrieval of masked configuration from /diag ---------------------
DIAG=$(curl -s -o /tmp/diag.txt -w "%{http_code}" "$BASE/diag" \
--max-time 30 -H 'Accept: application/json' || echo 000)
echo "/diag=$DIAG"
case "$DIAG" in
401|403|302) echo "✓ /diag is authenticated (HTTP $DIAG) — no anonymous config disclosure." ;;
200)
# Reachable anonymously: only acceptable if every secret is masked.
if grep -qE '"Value"[[:space:]]*:[[:space:]]*"(\*\*\*\*[^"]*)?"' /tmp/diag.txt \
&& ! grep -qE '"Value"[[:space:]]*:[[:space:]]*"[^"*]{5,}"' /tmp/diag.txt; then
echo "✓ /diag returned 200 with all secret values masked."
else
echo "::error::/diag returned 200 with an UNMASKED secret value."
exit 1
fi
;;
*)
echo "::error::/diag returned unexpected status $DIAG."
exit 1
;;
esac
echo "Deployment verified: health, Blazor render tree, and masked /diag all OK."
# Apply infrastructure (resource group, app settings, role assignments) from Bicep.
# Manual only: run the workflow via "Run workflow" with deploy_infra=true. This keeps
# surprise infra changes out of routine code deploys while making provisioning repeatable.
apply-infra:
name: Apply Infra (manual)
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' && inputs.deploy_infra
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
# Unique, per-run deployment name. A subscription-scoped deployment object is
# pinned to the location it was first created in, so a fixed name (e.g. "main")
# cannot move regions — use a fresh name each run to avoid InvalidDeploymentLocation.
- name: What-if (preview changes)
run: az deployment sub what-if --name "infra-${{ github.run_id }}" --location eastus2 --template-file infra/main.bicep || true
- name: Deploy
run: az deployment sub create --name "infra-${{ github.run_id }}" --location eastus2 --template-file infra/main.bicep