Repository navigation
298 lines (266 loc) · 14.1 KB
/
Copy pathdeploy-web.yml
File metadata and controls
298 lines (266 loc) · 14.1 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
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
name: Deploy Web (Static Web App)
# Build here, upload with the pinned SWA CLI. No third-party action sees the deploy token
# (the CLI is Microsoft-published npm code at an exact version), the build runs on the same
# path ci.yml tests (setup-node 22 + npm ci + next build) instead of Oryx guessing inside the
# action's container, and the exported site is attested like the agent ZIP and the MSI.
#
# Three jobs, each holding only what it needs: build runs all third-party code without any
# credential, deploy holds the SWA token alone, record attests, verifies and writes the
# manifest. A failed record job is re-run on its own; it writes only once the live site
# serves this commit.
#
# PR preview environments were dropped with the action: their per-PR URLs are never
# registered as redirect URIs, so nobody could log in on them anyway.
on:
push:
branches:
- main
paths:
- 'src/Web/autopilot-monitor-web/**'
- '.github/workflows/deploy-web.yml'
workflow_dispatch:
# Every job states its own permissions. This default is what the build job runs with: npm ci
# (install scripts included) and next build execute third-party code and must never hold more
# than a read-only token.
permissions:
contents: read
# Hosts (API, portal, storage) come from .github/scripts/Get-Endpoints.ps1, never from a
# repository variable.
env:
WEB_DIR: src/Web/autopilot-monitor-web
# Exact version: `npx --package` resolves from the registry at run time, so the pin is the
# only thing that keeps the deploy step's code fixed. Bump deliberately.
SWA_CLI_VERSION: 2.0.10
jobs:
# Build zone: no login, no id-token. The one secret here is the Application Insights
# connection string, which ships inside the public bundle anyway.
build:
name: Build
runs-on: ubuntu-latest
outputs:
docs_sha: ${{ steps.docs.outputs.sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
lfs: false
fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates
- name: Load endpoints
shell: pwsh
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
# The customer docs repo is the single source of the "What's new" panel: the prebuild
# step (scripts/whats-new/build-whats-new.js) turns changelog/*.md into
# public/whats-new.json and dates every bullet via git blame — hence the full history.
# Public repo, no token. Editing the docs does NOT update the live panel until this
# workflow runs again (push under src/Web/** or a manual dispatch); versions/web.json
# records which docs commit is live.
- name: Check out documentation bundle
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
repository: autopilotmonitor/autopilotmonitor-docs
path: whats-new-docs
fetch-depth: 0
- name: Resolve docs commit
id: docs
run: echo "sha=$(git -C whats-new-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: npm
cache-dependency-path: ${{ env.WEB_DIR }}/package-lock.json
- name: Install dependencies
working-directory: ${{ env.WEB_DIR }}
run: npm ci
# Star counter for the GitHub star link (utils/githubStars.ts). Baked into the static HTML
# so visitors' browsers never call GitHub; stale until the next web deploy. The token stays
# in this step, never in the build. Fail-soft: without a number the link has no counter.
- name: Read GitHub star count (fail-soft)
id: stars
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
count=$(gh api "repos/$REPO" --jq .stargazers_count 2>/dev/null || true)
if [[ "$count" =~ ^[0-9]+$ ]]; then
echo "count=$count" >> "$GITHUB_OUTPUT"
echo "GitHub stars: $count"
else
echo "::warning::GitHub star count unavailable - the star link renders without a counter"
fi
# next.config output:'export' -- `next build` writes the static site to out/
# (prebuild: sync-error-codes, generate-lastmod, generate-version, whats-new).
- name: Build
working-directory: ${{ env.WEB_DIR }}
env:
NEXT_PUBLIC_ENTRA_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_CLIENT_ID }}
# Dual app-reg window: previous app registration (unset = single-app behaviour)
# and the default app for browsers without a stored selection (legacy|primary).
NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID }}
NEXT_PUBLIC_ENTRA_DEFAULT_APP: ${{ vars.NEXT_PUBLIC_ENTRA_DEFAULT_APP }}
NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING: ${{ secrets.NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING }}
# Build stamp for public/version.json (scripts/generate-version.js in prebuild);
# the verify step below polls the deployed file for exactly this commit.
GITHUB_SHA: ${{ github.sha }}
# Docs checkout for public/whats-new.json; the prebuild fails in CI without it.
WHATS_NEW_DOCS_DIR: ${{ github.workspace }}/whats-new-docs
# Star counter from the step above; empty when the lookup failed.
NEXT_PUBLIC_GITHUB_STARS: ${{ steps.stars.outputs.count }}
run: |
# All from the endpoint registry (Load endpoints). Empty, the portal would silently call
# localhost:7071. The stats manifest is what browsers load the live numbers from, and the
# build reads it once to bake the numbers of this moment into the landing page's static
# HTML (lib/platformStats.ts). The redirect URIs are only the fallback for the static
# render; in the browser lib/msalConfig.ts uses window.location.origin.
export NEXT_PUBLIC_API_BASE_URL="$API_BASE_URL"
export NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL="$STATS_CONTAINER_URL"
export NEXT_PUBLIC_ENTRA_REDIRECT_URI="$WWW_BASE_URL"
export NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI="$WWW_BASE_URL"
test -n "$NEXT_PUBLIC_API_BASE_URL" || { echo "::error::API_BASE_URL is empty - the portal would call localhost"; exit 1; }
test -n "$NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL" || { echo "::error::STATS_CONTAINER_URL is empty"; exit 1; }
test -n "$NEXT_PUBLIC_ENTRA_REDIRECT_URI" || { echo "::error::WWW_BASE_URL is empty"; exit 1; }
npm run build
test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; }
test -f out/whats-new.json || { echo "out/whats-new.json missing - whats-new prebuild did not run"; exit 1; }
# Fail-soft like the star count: browsers still load the stats live; only crawlers and
# LLM fetchers, which run no JS, see no numbers until the next deploy.
grep -q 'data-stats-snapshot=' out/index.html || echo "::warning::Platform stats were not baked into the landing page (reason in the build log) - crawlers and LLM fetchers see no numbers until the next deploy"
# One archive of the exact bytes that get uploaded, so the attestation has a single subject.
- name: Package export
id: package
run: |
archive="portal-${GITHUB_SHA::7}.tar.gz"
tar -C "$WEB_DIR/out" -czf "$archive" .
echo "archive=$archive" >> "$GITHUB_OUTPUT"
sha256sum "$archive"
- name: Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: portal-${{ github.sha }}
path: ${{ steps.package.outputs.archive }}
if-no-files-found: error
# Holds the SWA deployment token and nothing else -- deliberately no id-token: the SWA CLI
# is third-party npm code whose dependencies resolve at run time.
deploy:
name: Deploy to Static Web App
needs: build
runs-on: ubuntu-latest
environment: production
steps:
# For staticwebapp.config.json only.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download build artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: portal-${{ github.sha }}
path: ${{ runner.temp }}/portal
- name: Unpack export
run: |
mkdir "$WEB_DIR/out"
tar -xzf "$RUNNER_TEMP/portal/portal-${GITHUB_SHA::7}.tar.gz" -C "$WEB_DIR/out"
# Upload only -- out/ is already built. --swa-config-location points at the app root,
# where staticwebapp.config.json lives (next export does not copy it into out/).
- name: Deploy to Static Web App
env:
SWA_CLI_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
run: |
npx --yes --package "@azure/static-web-apps-cli@${SWA_CLI_VERSION}" swa deploy \
--app-location "$WEB_DIR" \
--output-location out \
--swa-config-location "$WEB_DIR" \
--env production \
--no-use-keychain
# Trust zone: only this repository's scripts and pinned actions run here. The deploy
# identity trusts the `production` environment, which admits main only.
record:
name: Attest, verify and record
needs: [build, deploy]
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write # keyless Sigstore signing, OIDC login for the manifest
attestations: write # store the provenance attestation
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Load endpoints
shell: pwsh
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
- name: Download build artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: portal-${{ github.sha }}
path: ${{ runner.temp }}/portal
- name: Locate the export archive
id: archive
run: |
path="$RUNNER_TEMP/portal/portal-${GITHUB_SHA::7}.tar.gz"
test -f "$path" || { echo "::error::Export archive for this commit is missing"; exit 1; }
echo "path=$path" >> "$GITHUB_OUTPUT"
# Sigstore keyless provenance: binds the export digest to this workflow run (repo, ref,
# commit, workflow file) in a public transparency log.
# Verify: gh attestation verify portal-<sha>.tar.gz --repo okieselbach/AutopilotMonitor
- name: Attest build provenance (portal export)
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: ${{ steps.archive.outputs.path }}
# The CLI reporting success proves the upload, not that the production host serves the
# new build -- verify against the live /version.json (Cache-Control: no-store via
# staticwebapp.config.json) before publishing the version manifest.
- name: Verify deployed portal build is live
shell: pwsh
run: |
$expectedCommit = '${{ github.sha }}'.Substring(0, 7)
$deadline = [DateTime]::UtcNow.AddMinutes(5)
$attempt = 0
while ([DateTime]::UtcNow -lt $deadline) {
$attempt++
try {
$v = Invoke-RestMethod -Uri "$env:PORTAL_BASE_URL/version.json" -Method Get -TimeoutSec 30 -Headers @{ 'Cache-Control' = 'no-cache' }
if ($v.commit -eq $expectedCommit) {
Write-Host "Live after $attempt attempt(s): web @ $($v.commit), built $($v.buildUtc)"
exit 0
}
Write-Host "Attempt ${attempt}: serving $($v.commit) -- waiting for $expectedCommit"
} catch {
Write-Host "Attempt ${attempt}: /version.json not answering yet -- $($_.Exception.Message)"
}
Start-Sleep -Seconds 15
}
throw "Portal did not serve commit $expectedCommit within 5 minutes -- deployment not verified, version manifest NOT published"
- name: Login to Azure (OIDC)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# Cross-component deploy record, written last and only on a verified deploy --
# same contract as versions/backend.json and versions/mcp.json. docsCommit says which
# docs revision the live What's new panel was generated from (as for the MCP corpus).
# The docs commit comes from the build job, so it arrives as data and is checked
# against the shape of a commit id before it is used.
- name: Publish version manifest
shell: pwsh
env:
DOCS_SHA: ${{ needs.build.outputs.docs_sha }}
run: |
if ($env:DOCS_SHA -notmatch '^[0-9a-f]{40}$') {
throw "The build job handed over no valid docs commit -- version manifest NOT published."
}
$manifest = [ordered]@{
component = 'web'
commit = '${{ github.sha }}'.Substring(0, 7)
deployedUtc = [DateTime]::UtcNow.ToString('o')
runId = '${{ github.run_id }}'
docsCommit = $env:DOCS_SHA.Substring(0, 7)
} | ConvertTo-Json
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
$url = "$env:VERSIONS_CONTAINER_URL/web.json"
# no-cache: rotating pointer, must never be served stale by any fronting cache.
Invoke-RestMethod -Uri $url -Method Put -Headers ($authHeaders + @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' }) -Body $manifest -ContentType 'application/json' | Out-Null
Write-Host "Published versions/web.json"
Write-Host $manifest