mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-23 20:26:10 +00:00
c26542776e
Every job that runs third-party code (restore, build, tests, npm ci, next build, wix) now holds only a read-only token and hands its output on as one archive. Signing, publishing and deploying happen in separate jobs on a fresh runner that run only this repository's scripts and pinned actions, unpack artifacts outside the checkout and take no build-job output into a script. - build-agent: reserve -> build -> release - build-msi: sign-loader -> build -> release - deploy-backend: reserve -> build -> deploy (publish output as one tar.gz) - deploy-web: build -> deploy (SWA token only, no id-token) -> record - combine-rules: validate (read-only, PRs too) -> publish (push to main only) - deploy-mcp: same shape; documents docker build as its boundary Deploy-only jobs run in the production environment. The jobs that sign stay without an environment for now: a job carries one OIDC subject, and the signing identity still trusts the ref. oidc-login-test.yml is removed; combine-rules no longer triggers on the dev branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
204 lines
8.8 KiB
YAML
204 lines
8.8 KiB
YAML
name: Deploy Backend (Azure Functions)
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
|
|
# Every job states its own permissions. This default is what the build job runs with: restore
|
|
# and build execute third-party code (NuGet MSBuild targets, the Functions worker SDK) and must
|
|
# never hold more than a read-only token.
|
|
permissions:
|
|
contents: read
|
|
|
|
env:
|
|
FUNCTION_APP: autopilotmonitor-api-eu
|
|
HEALTH_URL: https://autopilotmonitor-api-eu.azurewebsites.net/api/health
|
|
VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
|
|
CSPROJ_PATH: src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj
|
|
|
|
jobs:
|
|
# Runs only this repository's scripts. The deploy identity trusts the `production`
|
|
# environment, which admits main only.
|
|
reserve:
|
|
name: Reserve build number
|
|
runs-on: ubuntu-latest
|
|
environment: production
|
|
permissions:
|
|
contents: read
|
|
id-token: write # OIDC federated login (secret-less)
|
|
outputs:
|
|
build_number: ${{ steps.version.outputs.build_number }}
|
|
version: ${{ steps.version.outputs.version }}
|
|
build_utc: ${{ steps.version.outputs.build_utc }}
|
|
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- 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 }}
|
|
|
|
# Same counter mechanism as the agent line: reserve-before-build with ETag-CAS
|
|
# against a per-component counter blob. BuildTimestampUtc is pinned here rather
|
|
# than left to MSBuild's default so the published manifest and the value baked
|
|
# into the assembly (reported by /api/health) are the same instant.
|
|
- name: Reserve build number (counter blob CAS)
|
|
id: version
|
|
shell: pwsh
|
|
run: |
|
|
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/backend.txt"
|
|
|
|
$csproj = Get-Content ${{ env.CSPROJ_PATH }} -Raw
|
|
if ($csproj -notmatch '<VersionPrefix>(\d+\.\d+)</VersionPrefix>') {
|
|
throw "VersionPrefix not found in ${{ env.CSPROJ_PATH }}"
|
|
}
|
|
$version = "$($Matches[1]).$buildNumber"
|
|
$buildUtc = [DateTime]::UtcNow.ToString('o')
|
|
Write-Host "Backend version: $version (built $buildUtc)"
|
|
|
|
"build_number=$buildNumber" >> $env:GITHUB_OUTPUT
|
|
"version=$version" >> $env:GITHUB_OUTPUT
|
|
"build_utc=$buildUtc" >> $env:GITHUB_OUTPUT
|
|
|
|
# Build zone: no login, no secret, no id-token. Its only output is the artifact; the
|
|
# deploy job treats that as data and never builds.
|
|
build:
|
|
name: Build
|
|
needs: reserve
|
|
runs-on: ubuntu-latest
|
|
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Setup .NET 10
|
|
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
|
with:
|
|
dotnet-version: '10.0.x'
|
|
|
|
- name: Restore dependencies
|
|
run: dotnet restore ${{ env.CSPROJ_PATH }}
|
|
|
|
- name: Build
|
|
run: dotnet build ${{ env.CSPROJ_PATH }} --configuration Release --no-restore -p:SourceRevisionId=${{ github.sha }} -p:BuildNumber=${{ needs.reserve.outputs.build_number }} -p:BuildTimestampUtc=${{ needs.reserve.outputs.build_utc }}
|
|
|
|
- name: Publish
|
|
run: dotnet publish ${{ env.CSPROJ_PATH }} --configuration Release --output ./publish --no-build -p:SourceRevisionId=${{ github.sha }} -p:BuildNumber=${{ needs.reserve.outputs.build_number }} -p:BuildTimestampUtc=${{ needs.reserve.outputs.build_utc }}
|
|
|
|
# One archive, not a folder upload: upload-artifact skips hidden files, and the
|
|
# isolated worker cannot start without the publish output's .azurefunctions/ folder.
|
|
- name: Package publish output
|
|
run: tar -czf "$RUNNER_TEMP/backend-publish.tgz" -C publish .
|
|
|
|
- name: Upload publish output
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: backend-publish
|
|
path: ${{ runner.temp }}/backend-publish.tgz
|
|
if-no-files-found: error
|
|
retention-days: 1
|
|
|
|
# Trust zone: only this repository's scripts and pinned actions run here, never a restore
|
|
# or build. The package is unpacked outside the checkout, so nothing in it can replace the
|
|
# scripts this job runs.
|
|
deploy:
|
|
name: Deploy and verify
|
|
needs: [reserve, build]
|
|
runs-on: ubuntu-latest
|
|
environment: production
|
|
permissions:
|
|
contents: read
|
|
id-token: write # OIDC federated login (secret-less)
|
|
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Download publish output
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: backend-publish
|
|
path: ${{ runner.temp }}/backend-artifact
|
|
|
|
# GNU tar refuses members with '..' and strips leading '/'.
|
|
- name: Unpack publish output
|
|
run: |
|
|
mkdir "$RUNNER_TEMP/publish"
|
|
tar -xzf "$RUNNER_TEMP/backend-artifact/backend-publish.tgz" -C "$RUNNER_TEMP/publish"
|
|
|
|
- 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 }}
|
|
|
|
- name: Deploy to Azure Functions
|
|
uses: Azure/functions-action@c5060b3b8bb1ebbcb531abd00c822ecbaa8ea656 # v1.5.7
|
|
with:
|
|
app-name: ${{ env.FUNCTION_APP }}
|
|
package: ${{ runner.temp }}/publish
|
|
|
|
# A green deploy step only means the package was accepted. This gate makes the
|
|
# workflow assert what is actually being served, so the manifest published below
|
|
# can be trusted as "this is live" rather than "this was uploaded".
|
|
# /api/health is IP rate-limited at 30/min; one poll per 10s stays well under.
|
|
- name: Verify deployed build is live
|
|
shell: pwsh
|
|
env:
|
|
EXPECTED_VERSION: ${{ needs.reserve.outputs.version }}
|
|
run: |
|
|
$expectedCommit = '${{ github.sha }}'.Substring(0, 7)
|
|
$expectedVersion = $env:EXPECTED_VERSION
|
|
$deadline = [DateTime]::UtcNow.AddMinutes(3)
|
|
$attempt = 0
|
|
|
|
while ([DateTime]::UtcNow -lt $deadline) {
|
|
$attempt++
|
|
try {
|
|
$health = Invoke-RestMethod -Uri '${{ env.HEALTH_URL }}' -Method Get -TimeoutSec 30
|
|
if ($health.commitHash -eq $expectedCommit -and $health.version -eq $expectedVersion) {
|
|
Write-Host "Live after $attempt attempt(s): $($health.version) @ $($health.commitHash), built $($health.buildUtc)"
|
|
exit 0
|
|
}
|
|
Write-Host "Attempt ${attempt}: serving $($health.version) @ $($health.commitHash) -- waiting for $expectedVersion @ $expectedCommit"
|
|
} catch {
|
|
Write-Host "Attempt ${attempt}: /api/health not answering yet -- $($_.Exception.Message)"
|
|
}
|
|
Start-Sleep -Seconds 10
|
|
}
|
|
|
|
throw "Backend did not report $expectedVersion @ $expectedCommit within 3 minutes -- deployment not verified, version manifest NOT published"
|
|
|
|
# The manifest is the cross-component record of what is deployed where. It is
|
|
# written last and only on a verified deploy, so a difference between the
|
|
# manifest and /api/health means the running app drifted after the fact
|
|
# (rollback, stale instance) rather than a half-finished pipeline.
|
|
- name: Publish version manifest
|
|
shell: pwsh
|
|
env:
|
|
BACKEND_VERSION: ${{ needs.reserve.outputs.version }}
|
|
BACKEND_BUILD_UTC: ${{ needs.reserve.outputs.build_utc }}
|
|
run: |
|
|
$manifest = [ordered]@{
|
|
component = 'backend'
|
|
version = $env:BACKEND_VERSION
|
|
commit = '${{ github.sha }}'.Substring(0, 7)
|
|
buildUtc = $env:BACKEND_BUILD_UTC
|
|
deployedUtc = [DateTime]::UtcNow.ToString('o')
|
|
runId = '${{ github.run_id }}'
|
|
} | ConvertTo-Json
|
|
|
|
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
|
|
$url = "${{ env.VERSIONS_CONTAINER }}/backend.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/backend.json"
|
|
Write-Host $manifest
|