-
Notifications
You must be signed in to change notification settings - Fork 0
275 lines (257 loc) · 13 KB
/
Copy pathdeploy.yml
File metadata and controls
275 lines (257 loc) · 13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
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
# One deploy at a time — App Service restarts are exclusive, and a parallel run can leave the
# site half-upgraded.
#
# cancel-in-progress is FALSE, deliberately. It used to be true, which caused the very thing the
# comment above says the lock prevents: push twice in quick succession and the first run was
# killed, potentially mid-upload, leaving the site serving a partially-written package. Queueing
# costs a few minutes; cancelling a deploy costs a broken site.
concurrency:
group: deploy-${{ github.repository }}-${{ github.ref }}
cancel-in-progress: false
# Action majors are pinned at v4 (checkout, setup-dotnet, cache, upload/download-artifact).
# Upgrading checkout/setup-dotnet/cache to their current majors is safe independently, but
# upload-artifact and download-artifact must be upgraded TOGETHER: an artifact written by one
# major is not readable by the other, and the failure is a deploy that cannot find the package
# it just built.
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
AZURE_WEBAPP_URL: https://app-porepolinetracker.azurewebsites.net
# Casing matters: the runner is Linux, and the project is .API (not .Api).
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@v4
# Cached: `az bicep install` pulls the CLI from GitHub releases on every run otherwise,
# which was ~20s of the job's total.
- name: Cache Bicep CLI
uses: actions/cache@v4
with:
path: ~/.azure/bin
key: bicep-${{ runner.os }}
- name: Install Bicep CLI
run: az bicep install
# Every .bicep in the folder, not a hand-listed pair. The list version compiled main and
# resources only, so a template added later was linted by nobody — and `find` cannot fall
# out of date.
- name: Compile Bicep → ARM (catches template errors)
run: |
set -euo pipefail
find infra -name '*.bicep' -print0 | while IFS= read -r -d '' f; do
echo "→ $f"
az bicep build --file "$f" --stdout > /dev/null
done
build:
name: Build & Test
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
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@v4
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-only on purpose. See docs/ci.md for the full rationale.
- name: Test (Unit only — see docs/ci.md for why)
run: dotnet test tests/PoRepoLineTracker.Unit --no-build -c Release --verbosity minimal -p:RunSettingsFilePath=
# --no-build, not just --no-restore. Without it publish re-ran the entire Release build that
# the step above had already done, compiling every project twice per pipeline.
- name: Publish API
run: dotnet publish ${{ env.API_PROJECT_PATH }} -c Release --no-build -o publish
# Zip HERE, not in the deploy job. The published output is a Blazor WASM site: 475 files /
# 102 MB, which compresses to a single 44 MB zip.
#
# This is NOT a speed fix, and the earlier version of this comment claimed it was. Measured
# on run 84: upload 4s, download 1s, zip-in-deploy 4s. Artifact transfer is runner-local and
# was never the bottleneck — the time in this job is Build (39s) and Publish (44s). What it
# buys is that the deploy job ships the exact bytes the build job produced and tested,
# instead of re-zipping a folder it just unpacked, and that the retained artifact is 44 MB
# rather than 102 MB for the 14 days it is kept.
- name: Package (zip)
run: |
set -euo pipefail
cd publish
zip -r ../webapp.zip . -q
- name: Upload webapp artifact
uses: actions/upload-artifact@v4
with:
name: webapp
path: webapp.zip
# Already compressed — re-compressing a zip costs CPU and saves nothing.
compression-level: 0
# Long enough to redeploy a known-good build by hand after a bad one. At the previous
# single day, the only way back from a broken deploy was a revert commit through the
# whole pipeline — F1 has no deployment slots to swap.
retention-days: 14
deploy:
name: Deploy to Azure
runs-on: ubuntu-latest
# lint-infra as well as build: this job applies infra/ when it changed, and without the
# dependency a template the lint job had just rejected could still be applied.
needs: [build, lint-infra]
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
timeout-minutes: 15
# Records the deploy in GitHub's Environments view and gives the URL a home. Add a required
# reviewer on the environment to gate production without touching this file.
environment:
name: production
url: ${{ env.AZURE_WEBAPP_URL }}
steps:
# Full history, not the default shallow clone. The infra-changed check below diffs against
# github.event.before, and a depth-1 checkout does not contain that commit — the diff fails,
# the guard falls through to "changed", and infra is applied on every push exactly as it was
# before the guard existed.
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Lands webapp.zip in the workspace root — the build job already packaged it, so this job
# deploys the exact bytes that were tested rather than re-zipping a folder it just unpacked.
- uses: actions/download-artifact@v4
with:
name: webapp
path: .
# 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 }}
# Infra is applied only when the templates actually changed. (Applying it by hand is the
# separate apply-infra job below; this job never runs on workflow_dispatch.)
#
# It used to run on every push. That was added because the App Service had gone missing and
# nothing in the pipeline could recreate it — a real problem, fixed the blunt way. The cost
# was that every code-only deploy paid for a subscription-scoped deployment, and a Bicep
# edit reached production with no what-if in front of it. Gating on the diff keeps the
# self-healing property for the case that motivated it while taking it off the hot path.
- name: Did infra change?
id: infra
run: |
if git diff --quiet ${{ github.event.before }} ${{ github.sha }} -- infra/ 2>/dev/null; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Provision infra (Bicep)
if: steps.infra.outputs.changed == 'true'
run: |
az deployment sub create \
--location eastus2 \
--template-file infra/main.bicep \
--name porepolinetracker-${{ github.run_id }} \
-o none
- name: Deploy to App Service
uses: azure/webapps-deploy@v3
with:
app-name: ${{ env.AZURE_WEBAPP_NAME }}
package: webapp.zip
# The deploy step returns once the package is ACCEPTED, not once the site is serving it —
# so without this a crash-on-startup shipped green and nobody knew until someone opened the
# page. Polls /health (anonymous, and the same endpoint Azure's own probe uses) until it
# answers, then checks that an app route and a static asset are really being served.
- name: Smoke test the deployed site
run: |
set -euo pipefail
echo "Waiting for ${{ env.AZURE_WEBAPP_URL }}/health …"
for attempt in $(seq 1 30); do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 "${{ env.AZURE_WEBAPP_URL }}/health" || echo 000)
if [ "$code" = "200" ]; then
echo "health OK after ${attempt} attempt(s)"
break
fi
if [ "$attempt" = "30" ]; then
echo "::error::Site did not become healthy after the deploy (last status: $code)"
exit 1
fi
sleep 10
done
# Each probe retries for up to three minutes. /health answering 200 straight after the
# deploy step does not mean the NEW build is serving: the old process answers it (and the
# first probes) until App Service swaps the container, and for the half-minute or more of
# that swap every route answers 5xx. A single-shot check, and then a 25-second retry,
# both failed good deploys on exactly that ("unknown API route returned 500") while the
# site came up correct moments later. A wrong status that persists still fails.
expect() {
local path="$1" want="$2" what="$3" got=000
for attempt in $(seq 1 18); do
got=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 "${{ env.AZURE_WEBAPP_URL }}$path" || echo 000)
[ "$got" = "$want" ] && { echo "$what OK ($got)"; return 0; }
sleep 10
done
echo "::error::$what returned $got, expected $want"
exit 1
}
# The shell must be reachable without signing in, or the sign-in page cannot load.
expect /login 200 "/login"
# A protected API route must say 401, not redirect an XHR off-origin.
expect /api/repositories 401 "/api/repositories"
# An unknown API route must say 404 — not 401, and above all not 200 carrying the HTML
# shell, which is what a JSON caller used to receive for a typo. GET only: see the known
# limit documented on ApiNotFoundMiddleware.
expect /api/no-such-route-smoke-test 404 "unknown API route"
# An unauthenticated page must land on THIS origin's login page, not at the OAuth
# provider — the installed PWA starts at "/" and would otherwise leave its own scope.
root_target=$(curl -s -o /dev/null -w '%{redirect_url}' --max-time 20 "${{ env.AZURE_WEBAPP_URL }}/")
case "$root_target" in
""|"${{ env.AZURE_WEBAPP_URL }}"*) echo "root OK (${root_target:-200 served})" ;;
*) echo "::error::/ redirected off-origin to $root_target"; exit 1 ;;
esac
echo "Smoke test passed."
# Apply infrastructure (resource group, app settings, role assignments) from Bicep.
# Manual only: run the workflow via "Run workflow" with deploy_infra=true. This is the path for
# previewing a change with what-if before it reaches production.
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@v4
- 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