fix(ui): sidebar collapse crushed the page into a 220px column #62
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |