diff --git a/.github/workflows/build-agent.yml b/.github/workflows/build-agent.yml index 98830fe2..1b0dc2a2 100644 --- a/.github/workflows/build-agent.yml +++ b/.github/workflows/build-agent.yml @@ -32,10 +32,11 @@ on: required: false type: string +# Every job states its own permissions. This default is what the build job runs with: it +# executes third-party code (MSBuild targets, analyzers, test frameworks) and must never hold +# more than a read-only token. permissions: - contents: write - id-token: write # keyless Sigstore signing (artifact attestation) - attestations: write # store the provenance attestation + contents: read # Shared with publish-scripts.yml: both write version.json (this workflow the agent fields, # the script publisher bootstrapVersion under If-Match). Serialising them keeps a script push @@ -68,9 +69,19 @@ env: CODE_SIGN_TIMESTAMP_URL: http://timestamp.acs.microsoft.com jobs: - build: - runs-on: windows-latest - name: Build Agent + # 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 for the counter write + outputs: + build_number: ${{ steps.version.outputs.build_number }} + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} steps: - name: Checkout repository @@ -90,33 +101,6 @@ jobs: } Write-Host "Code-signing configuration present (certificate '$env:CODE_SIGN_CERTIFICATE_NAME')" - # Logs in FIRST and only to fetch the Key Vault token: azure/login sets the default az - # account, so the storage login below has to be the last one. Both federated credentials - # are bound to the ref, so this job must never be given an `environment:` -- that would - # change the OIDC subject claim and break both logins. - - name: Login to Azure (code-signing identity) - uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 - with: - client-id: ${{ env.CODE_SIGN_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - allow-no-subscriptions: true - - - name: Acquire Key Vault token for signing - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $token = az account get-access-token --scope https://vault.azure.net/.default --query accessToken --output tsv - if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) { - throw "Could not acquire a Key Vault token for the code-signing identity." - } - Write-Host "::add-mask::$token" - "CODE_SIGN_AKV_ACCESS_TOKEN=$token" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - # Every storage write below (counter blob, agent ZIP, manifests, AdminConfiguration) - # is authorized by this federated identity -- no SAS secret. One login covers the whole - # job: the federated credential is bound to the ref, and a release run takes ~2 minutes, - # well inside the token lifetime. Only the legacy mirror still needs a SAS: that account - # lives in the pre-migration tenant, where this principal has no identity. - name: Login to Azure (OIDC) uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 with: @@ -124,18 +108,6 @@ jobs: tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - # SDK 10 = single build toolchain (parity with local build.ps1 / global.json). - - name: Setup .NET - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - dotnet-version: '10.0.x' - - # Version pinned; roll-forward because the tool targets an older runtime than the SDK above. - - name: Install AzureSignTool - env: - DOTNET_ROLL_FORWARD: LatestMajor - run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1 - # Build numbers come from the shared counter blob -- one counter and one # implementation for CI and the local build (.github/scripts/Request-BuildNumber.ps1). # Reserve-before-build with ETag-CAS: concurrent builds can never mint the same @@ -174,6 +146,25 @@ jobs: "version=$version" >> $env:GITHUB_OUTPUT "tag=v$version" >> $env:GITHUB_OUTPUT + # Build zone: no login, no secret, no id-token. Its only output is the artifact; the + # release job treats that as data and never builds. + build: + name: Build and test + needs: reserve + runs-on: windows-latest + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # SDK 10 = single build toolchain (parity with local build.ps1 / global.json). + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: '10.0.x' + - name: Restore dependencies run: | dotnet restore ${{ env.AGENT_PROJECT }} @@ -189,7 +180,7 @@ jobs: - name: Run agent tests (all suites) shell: pwsh env: - BUILD_NUMBER: ${{ steps.version.outputs.build_number }} + BUILD_NUMBER: ${{ needs.reserve.outputs.build_number }} run: | foreach ($project in @($env:DECISIONCORE_TEST_PROJECT, $env:CORE_TEST_PROJECT, $env:DIALOG_TEST_PROJECT)) { dotnet test $project --configuration Release --no-restore --verbosity quiet @@ -199,11 +190,118 @@ jobs: - name: Build Agent run: dotnet build ${{ env.AGENT_PROJECT }} --configuration Release --no-restore env: - BUILD_NUMBER: ${{ steps.version.outputs.build_number }} + BUILD_NUMBER: ${{ needs.reserve.outputs.build_number }} - name: Build SummaryDialog run: dotnet build ${{ env.DIALOG_PROJECT }} --configuration Release --no-restore + # One archive per output folder, unsigned. ZipFile rather than Compress-Archive, which + # skips hidden files. + - name: Package build output + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + Add-Type -AssemblyName System.IO.Compression.FileSystem + $outDir = Join-Path $env:RUNNER_TEMP 'agent-unsigned' + New-Item -Path $outDir -ItemType Directory -Force | Out-Null + [System.IO.Compression.ZipFile]::CreateFromDirectory((Join-Path $env:GITHUB_WORKSPACE $env:AGENT_OUTPUT), (Join-Path $outDir 'agent.zip')) + [System.IO.Compression.ZipFile]::CreateFromDirectory((Join-Path $env:GITHUB_WORKSPACE $env:DIALOG_OUTPUT), (Join-Path $outDir 'dialog.zip')) + Get-ChildItem $outDir | ForEach-Object { Write-Host "$($_.Name): $([math]::Round($_.Length / 1MB, 2)) MB" } + + - name: Upload unsigned build output + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: agent-unsigned + path: ${{ runner.temp }}/agent-unsigned + if-no-files-found: error + retention-days: 1 + + # Trust zone: only this repository's scripts, pinned actions and the pinned signing tool + # run here -- never a restore or build. The build output arrives as data. + # + # No `environment:` yet: the code-signing credential is still bound to the ref, and a job + # carries exactly one OIDC subject, so an environment here would break the signing login. + release: + name: Sign and publish + needs: [reserve, build] + runs-on: windows-latest + permissions: + contents: write # tag + GitHub Release + id-token: write # code-signing and storage logins, keyless Sigstore signing + attestations: write # store the provenance attestation + env: + AGENT_VERSION: ${{ needs.reserve.outputs.version }} + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Download unsigned build output + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent-unsigned + path: ${{ runner.temp }}/agent-unsigned + + # Only the two expected archives, each into its own fresh output folder; the .NET + # extractor refuses entries that would land outside it. Never unpack into the checkout + # root: an archive could otherwise replace the scripts this job runs below. + - name: Unpack build output + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + Add-Type -AssemblyName System.IO.Compression.FileSystem + foreach ($pair in @(@('agent.zip', $env:AGENT_OUTPUT), @('dialog.zip', $env:DIALOG_OUTPUT))) { + $archive = Join-Path $env:RUNNER_TEMP "agent-unsigned/$($pair[0])" + $target = Join-Path $env:GITHUB_WORKSPACE $pair[1] + if (Test-Path $target) { throw "$($pair[1]) already exists -- refusing to unpack over it." } + [System.IO.Compression.ZipFile]::ExtractToDirectory($archive, $target) + Write-Host "Unpacked $($pair[0]) into $($pair[1])" + } + + # Logs in FIRST and only to fetch the Key Vault token: azure/login sets the default az + # account, so the storage login below has to be the last one. + - name: Login to Azure (code-signing identity) + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 + with: + client-id: ${{ env.CODE_SIGN_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + allow-no-subscriptions: true + + - name: Acquire Key Vault token for signing + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $token = az account get-access-token --scope https://vault.azure.net/.default --query accessToken --output tsv + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) { + throw "Could not acquire a Key Vault token for the code-signing identity." + } + Write-Host "::add-mask::$token" + "CODE_SIGN_AKV_ACCESS_TOKEN=$token" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + # Every storage write below (agent ZIP, manifests, AdminConfiguration) is authorized by + # this federated identity -- no SAS secret. Only the legacy mirror still needs a SAS: + # that account lives in the pre-migration tenant, where this principal has no identity. + - 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 }} + + # Runtime for AzureSignTool only; nothing is built in this job. + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: '10.0.x' + + # Version pinned; roll-forward because the tool targets an older runtime than the SDK above. + - name: Install AzureSignTool + env: + DOTNET_ROLL_FORWARD: LatestMajor + run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1 + # Authenticode over everything we author; third-party assemblies keep their own signature. # # THE POSITION IS LOAD-BEARING: signing rewrites the PE file, so it must run before the @@ -249,7 +347,7 @@ jobs: id: artifact shell: pwsh run: | - $version = "${{ steps.version.outputs.version }}" + $version = $env:AGENT_VERSION $stagingDir = "staging/agent" if (-not (Test-Path "$env:AGENT_OUTPUT/AutopilotMonitor.Agent.exe")) { @@ -383,6 +481,9 @@ jobs: - name: Upload to Azure Blob Storage if: inputs.upload_blob shell: pwsh + env: + ZIP_SHA256: ${{ steps.artifact.outputs.sha256 }} + EXE_SHA256: ${{ steps.artifact.outputs.exe_sha256 }} run: | $containerUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent" $authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1 @@ -452,9 +553,9 @@ jobs: if ('${{ inputs.publish_as_stable }}' -ne 'true') { Write-Host "SKIPPED: AdminConfiguration update (versioned-only run -- stable namespace unchanged)" } else { - $sha256 = "${{ steps.artifact.outputs.sha256 }}" - $exeSha256 = "${{ steps.artifact.outputs.exe_sha256 }}" - $agentVersion = "${{ steps.version.outputs.version }}" + $sha256 = $env:ZIP_SHA256 + $exeSha256 = $env:EXE_SHA256 + $agentVersion = $env:AGENT_VERSION $tableUrl = "https://autopilotmonitoreu.table.core.windows.net/AdminConfiguration(PartitionKey='GlobalConfig',RowKey='config')" $tableBody = @{ LatestAgentV2Version = $agentVersion @@ -489,7 +590,7 @@ jobs: - name: Upload build artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: AutopilotMonitor-Agent-v${{ steps.version.outputs.version }} + name: AutopilotMonitor-Agent-v${{ needs.reserve.outputs.version }} path: ${{ steps.artifact.outputs.versioned_zip_release }} - name: Upload version metadata @@ -512,15 +613,15 @@ jobs: shell: pwsh env: GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ steps.version.outputs.tag }} - RELEASE_NAME: Autopilot Monitor Agent ${{ steps.version.outputs.version }} + RELEASE_TAG: ${{ needs.reserve.outputs.tag }} + RELEASE_NAME: Autopilot Monitor Agent ${{ needs.reserve.outputs.version }} RELEASE_PRERELEASE: ${{ inputs.prerelease }} RELEASE_ZIP: ${{ steps.artifact.outputs.versioned_zip_release }} RELEASE_NOTES: ${{ inputs.release_notes }} RELEASE_BODY_TAIL: | ### Version Info - - **Version:** ${{ steps.version.outputs.version }} - - **Build Number:** ${{ steps.version.outputs.build_number }} + - **Version:** ${{ needs.reserve.outputs.version }} + - **Build Number:** ${{ needs.reserve.outputs.build_number }} - **SHA-256:** `${{ steps.artifact.outputs.sha256 }}` - **Built from:** ${{ github.sha }} - **Published as stable:** ${{ inputs.publish_as_stable }} @@ -528,7 +629,7 @@ jobs: ### Provenance The executables are Authenticode-signed and timestamped. The ZIP and both executables carry a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify: ``` - gh attestation verify AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}.zip --repo okieselbach/AutopilotMonitor + gh attestation verify AutopilotMonitor-Agent-v${{ needs.reserve.outputs.version }}.zip --repo okieselbach/AutopilotMonitor ``` run: | $ErrorActionPreference = 'Stop' diff --git a/.github/workflows/build-msi.yml b/.github/workflows/build-msi.yml index 91bd406f..c13b4cb5 100644 --- a/.github/workflows/build-msi.yml +++ b/.github/workflows/build-msi.yml @@ -16,10 +16,10 @@ on: type: boolean default: true +# Every job states its own permissions. This default is what the build job runs with: wix is +# third-party code and must never hold more than a read-only token. permissions: contents: read - id-token: write # keyless Sigstore signing (artifact attestation) - attestations: write # store the provenance attestation env: MSI_PROJECT_DIR: src/Agent/AutopilotMonitor.BootstrapMsi @@ -34,9 +34,19 @@ env: CODE_SIGN_TIMESTAMP_URL: http://timestamp.acs.microsoft.com jobs: - build: + # Signs the loader BEFORE the build: wix embeds the file as it finds it, so a loader signed + # afterwards would leave the copy inside the MSI unsigned. Devices under an AllSigned policy + # need the embedded loader to carry a signature of its own. + # + # Trust zone: only this repository's scripts, pinned actions and the pinned signing tool run + # here. No `environment:` yet: the code-signing credential is still bound to the ref, and a + # job carries exactly one OIDC subject. + sign-loader: + name: Sign bootstrap loader runs-on: windows-latest - name: Build Bootstrap MSI + permissions: + contents: read + id-token: write # code-signing login steps: - name: Checkout repository @@ -60,6 +70,147 @@ jobs: } Write-Host "Runner script parse + ASCII check passed" + # Every published MSI is signed; a missing variable stops the run before the build. + - name: Check code-signing configuration + shell: pwsh + run: | + foreach ($name in 'CODE_SIGN_CLIENT_ID', 'CODE_SIGN_CERTIFICATE_NAME', 'CODE_SIGN_KEY_VAULT_URL') { + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { + throw "Repository variable $name is not set. Every published MSI must be Authenticode-signed." + } + } + + - name: Login to Azure (code-signing identity) + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 + with: + client-id: ${{ env.CODE_SIGN_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + allow-no-subscriptions: true + + - name: Acquire Key Vault token for signing + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $token = az account get-access-token --scope https://vault.azure.net/.default --query accessToken --output tsv + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) { + throw "Could not acquire a Key Vault token for the code-signing identity." + } + Write-Host "::add-mask::$token" + "CODE_SIGN_AKV_ACCESS_TOKEN=$token" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + # Version pinned; roll-forward because the tool targets an older runtime than the SDK. + - name: Install AzureSignTool + env: + DOTNET_ROLL_FORWARD: LatestMajor + run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1 + + # A missing timestamp fails the run: without it the signature dies with the certificate. + - name: Sign bootstrap loader (Authenticode) + shell: pwsh + env: + DOTNET_ROLL_FORWARD: LatestMajor + run: | + $ErrorActionPreference = 'Stop' + & azuresigntool.exe sign --verbose ` + --azure-key-vault-url "$env:CODE_SIGN_KEY_VAULT_URL" ` + --azure-key-vault-accesstoken "$env:CODE_SIGN_AKV_ACCESS_TOKEN" ` + --azure-key-vault-certificate "$env:CODE_SIGN_CERTIFICATE_NAME" ` + --timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" ` + --file-digest sha256 ` + --description 'Autopilot Monitor bootstrap runner' ` + --description-url 'https://www.autopilotmonitor.com' ` + $env:RUNNER_SCRIPT + if ($LASTEXITCODE -ne 0) { throw "azuresigntool failed with exit code $LASTEXITCODE" } + + $sig = Get-AuthenticodeSignature -FilePath $env:RUNNER_SCRIPT + if ($sig.Status -ne 'Valid') { throw "Signature on $env:RUNNER_SCRIPT is '$($sig.Status)', expected 'Valid'." } + if (-not $sig.TimeStamperCertificate) { throw "Signature on $env:RUNNER_SCRIPT carries no timestamp." } + if ($env:CODE_SIGN_EXPECTED_SUBJECT -and $sig.SignerCertificate.Subject -notlike $env:CODE_SIGN_EXPECTED_SUBJECT) { + throw "$env:RUNNER_SCRIPT was signed by '$($sig.SignerCertificate.Subject)', expected '$env:CODE_SIGN_EXPECTED_SUBJECT'." + } + Write-Host "$env:RUNNER_SCRIPT : Valid, timestamped, signer $($sig.SignerCertificate.Subject.Split(',')[0])" + + - name: Upload signed loader + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: bootstrap-loader-signed + path: ${{ env.RUNNER_SCRIPT }} + if-no-files-found: error + retention-days: 1 + + # Build zone: no login, no secret, no id-token. Its only output is the artifact; the + # release job treats that as data and never builds. + build: + name: Build MSI + needs: sign-loader + runs-on: windows-latest + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Download signed loader + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: bootstrap-loader-signed + path: ${{ runner.temp }}/loader + + # The signed copy replaces the checkout's unsigned one before wix embeds it. + - name: Put the signed loader in place + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + Copy-Item -Path (Join-Path $env:RUNNER_TEMP 'loader/Start-AutopilotMonitor.ps1') -Destination $env:RUNNER_SCRIPT -Force + $sig = Get-AuthenticodeSignature -FilePath $env:RUNNER_SCRIPT + if ($sig.Status -ne 'Valid') { throw "Loader to embed is '$($sig.Status)', expected 'Valid'." } + Write-Host "Embedding the signed loader ($((Get-Item $env:RUNNER_SCRIPT).Length) bytes)" + + # Exact version, not a "5.*" range: wix writes the bytes of the MSI that customers + # install, so a compromised or simply broken point release must not flow into a build + # unnoticed. Bumping is a reviewed commit. + - name: Install WiX toolset + run: dotnet tool install --global wix --version 5.0.2 + + - name: Build MSI + shell: pwsh + run: | + # wix resolves File/@Source relative to the working directory, not the wxs — + # build from inside the project dir (same as the local build documented in the wxs). + $out = Join-Path (Get-Location) $env:STABLE_MSI + Push-Location $env:MSI_PROJECT_DIR + wix build Package.wxs -arch x64 -o $out + Pop-Location + if (-not (Test-Path $env:STABLE_MSI)) { Write-Error "wix build produced no MSI"; exit 1 } + Write-Host "Built $env:STABLE_MSI ($([math]::Round((Get-Item $env:STABLE_MSI).Length / 1KB)) KB)" + + - name: Upload unsigned MSI + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: bootstrap-msi-unsigned + path: ${{ env.STABLE_MSI }} + if-no-files-found: error + retention-days: 1 + + # Trust zone: signs, verifies and publishes the MSI as data; never builds. No + # `environment:` yet, for the same reason as sign-loader. + release: + name: Sign and publish MSI + needs: build + runs-on: windows-latest + permissions: + contents: read + id-token: write # code-signing and storage logins, keyless Sigstore signing + attestations: write # store the provenance attestation + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Read from this job's own checkout, never handed over by the build job. - name: Read MSI version from Package.wxs id: version shell: pwsh @@ -73,20 +224,19 @@ jobs: Write-Host "MSI version (from Package.wxs): $version" "version=$version" >> $env:GITHUB_OUTPUT - # Every published MSI is signed; a missing variable stops the run before the build. - - name: Check code-signing configuration + - name: Download unsigned MSI + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: bootstrap-msi-unsigned + path: ${{ runner.temp }}/msi + + # Only the expected file leaves the download folder. + - name: Take the MSI from the build shell: pwsh - run: | - foreach ($name in 'CODE_SIGN_CLIENT_ID', 'CODE_SIGN_CERTIFICATE_NAME', 'CODE_SIGN_KEY_VAULT_URL') { - if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { - throw "Repository variable $name is not set. Every published MSI must be Authenticode-signed." - } - } + run: Copy-Item -Path (Join-Path $env:RUNNER_TEMP "msi/$env:STABLE_MSI") -Destination $env:STABLE_MSI -ErrorAction Stop # Logs in FIRST and only to fetch the Key Vault token: azure/login sets the default az - # account, so the storage login below has to be the last one. Both federated credentials - # are bound to the ref, so this job must never be given an `environment:` -- that would - # change the OIDC subject claim and break both logins. + # account, so the storage login below has to be the last one. - name: Login to Azure (code-signing identity) uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 with: @@ -113,50 +263,12 @@ jobs: tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - # Exact version, not a "5.*" range: wix writes the bytes of the MSI that customers - # install, so a compromised or simply broken point release must not flow into a build - # unnoticed. Bumping is a reviewed commit. - - name: Install WiX toolset - run: dotnet tool install --global wix --version 5.0.2 - # Version pinned; roll-forward because the tool targets an older runtime than the SDK. - name: Install AzureSignTool env: DOTNET_ROLL_FORWARD: LatestMajor run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1 - # BEFORE the build: wix embeds the file as it finds it, so a loader signed afterwards - # would leave the copy inside the MSI unsigned. Devices under an AllSigned policy need - # the embedded loader to carry a signature of its own. - - name: Sign bootstrap loader (Authenticode) - shell: pwsh - env: - DOTNET_ROLL_FORWARD: LatestMajor - run: | - $ErrorActionPreference = 'Stop' - & azuresigntool.exe sign --verbose ` - --azure-key-vault-url "$env:CODE_SIGN_KEY_VAULT_URL" ` - --azure-key-vault-accesstoken "$env:CODE_SIGN_AKV_ACCESS_TOKEN" ` - --azure-key-vault-certificate "$env:CODE_SIGN_CERTIFICATE_NAME" ` - --timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" ` - --file-digest sha256 ` - --description 'Autopilot Monitor bootstrap runner' ` - --description-url 'https://www.autopilotmonitor.com' ` - $env:RUNNER_SCRIPT - if ($LASTEXITCODE -ne 0) { throw "azuresigntool failed with exit code $LASTEXITCODE" } - - - name: Build MSI - shell: pwsh - run: | - # wix resolves File/@Source relative to the working directory, not the wxs — - # build from inside the project dir (same as the local build documented in the wxs). - $out = Join-Path (Get-Location) $env:STABLE_MSI - Push-Location $env:MSI_PROJECT_DIR - wix build Package.wxs -arch x64 -o $out - Pop-Location - if (-not (Test-Path $env:STABLE_MSI)) { Write-Error "wix build produced no MSI"; exit 1 } - Write-Host "Built $env:STABLE_MSI ($([math]::Round((Get-Item $env:STABLE_MSI).Length / 1KB)) KB)" - # Signing rewrites the MSI, so it runs before the hash and the upload. A missing # timestamp fails the run: without it the signature dies with the certificate. - name: Sign MSI (Authenticode) @@ -176,26 +288,26 @@ jobs: $env:STABLE_MSI if ($LASTEXITCODE -ne 0) { throw "azuresigntool failed with exit code $LASTEXITCODE" } - # The MSI and the loader it carries -- verified here, before anything is published. - foreach ($file in @($env:STABLE_MSI, $env:RUNNER_SCRIPT)) { - $sig = Get-AuthenticodeSignature -FilePath $file - if ($sig.Status -ne 'Valid') { throw "Signature on $file is '$($sig.Status)', expected 'Valid'." } - if (-not $sig.TimeStamperCertificate) { throw "Signature on $file carries no timestamp." } - if ($env:CODE_SIGN_EXPECTED_SUBJECT -and $sig.SignerCertificate.Subject -notlike $env:CODE_SIGN_EXPECTED_SUBJECT) { - throw "$file was signed by '$($sig.SignerCertificate.Subject)', expected '$env:CODE_SIGN_EXPECTED_SUBJECT'." - } - Write-Host "$file : Valid, timestamped, signer $($sig.SignerCertificate.Subject.Split(',')[0])" + # The loader was verified in sign-loader; here the MSI, before anything is published. + $sig = Get-AuthenticodeSignature -FilePath $env:STABLE_MSI + if ($sig.Status -ne 'Valid') { throw "Signature on $env:STABLE_MSI is '$($sig.Status)', expected 'Valid'." } + if (-not $sig.TimeStamperCertificate) { throw "Signature on $env:STABLE_MSI carries no timestamp." } + if ($env:CODE_SIGN_EXPECTED_SUBJECT -and $sig.SignerCertificate.Subject -notlike $env:CODE_SIGN_EXPECTED_SUBJECT) { + throw "$env:STABLE_MSI was signed by '$($sig.SignerCertificate.Subject)', expected '$env:CODE_SIGN_EXPECTED_SUBJECT'." } + Write-Host "$env:STABLE_MSI : Valid, timestamped, signer $($sig.SignerCertificate.Subject.Split(',')[0])" Write-Host "SHA-256: $((Get-FileHash $env:STABLE_MSI -Algorithm SHA256).Hash.ToLowerInvariant())" - name: Upload to Azure Blob Storage if: inputs.upload_blob shell: pwsh + env: + MSI_VERSION: ${{ steps.version.outputs.version }} run: | $containerUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent" $authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1 - $version = "${{ steps.version.outputs.version }}" + $version = $env:MSI_VERSION # Same Send-Blob contract as build-agent.yml: no-cache on every blob because the # stable name rotates in place behind the download alias. No legacy mirror — the diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b52d98d..f988bf16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,6 @@ on: - '.github/workflows/deploy-mcp.yml' - '.github/workflows/deploy-web.yml' - '.github/workflows/indexnow-notify.yml' - - '.github/workflows/oidc-login-test.yml' - '.github/workflows/pester.yml' - '.github/workflows/publish-scripts.yml' pull_request: @@ -80,7 +79,6 @@ on: - '.github/workflows/deploy-mcp.yml' - '.github/workflows/deploy-web.yml' - '.github/workflows/indexnow-notify.yml' - - '.github/workflows/oidc-login-test.yml' - '.github/workflows/pester.yml' - '.github/workflows/publish-scripts.yml' workflow_dispatch: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2fec9106..fbf0d0d7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -37,7 +37,6 @@ on: - '.github/workflows/deploy-mcp.yml' - '.github/workflows/deploy-web.yml' - '.github/workflows/indexnow-notify.yml' - - '.github/workflows/oidc-login-test.yml' - '.github/workflows/pester.yml' - '.github/workflows/publish-scripts.yml' pull_request: @@ -69,7 +68,6 @@ on: - '.github/workflows/deploy-mcp.yml' - '.github/workflows/deploy-web.yml' - '.github/workflows/indexnow-notify.yml' - - '.github/workflows/oidc-login-test.yml' - '.github/workflows/pester.yml' - '.github/workflows/publish-scripts.yml' schedule: diff --git a/.github/workflows/combine-rules.yml b/.github/workflows/combine-rules.yml index 75533ebc..c52cdfda 100644 --- a/.github/workflows/combine-rules.yml +++ b/.github/workflows/combine-rules.yml @@ -1,7 +1,7 @@ name: Validate & Combine Rules on: push: - branches: [main, dev] + branches: [main] paths: - 'rules/gather/**' - 'rules/analyze/**' @@ -9,6 +9,7 @@ on: - 'rules/schema/**' - 'rules/scripts/**' - 'rules/guardrails.json' + - '.github/workflows/combine-rules.yml' pull_request: paths: - 'rules/gather/**' @@ -17,24 +18,27 @@ on: - 'rules/schema/**' - 'rules/scripts/**' - 'rules/guardrails.json' + - '.github/workflows/combine-rules.yml' + +permissions: + contents: read jobs: - validate-and-combine: + # Pull requests and pushes alike; read-only, because the validator is third-party code. + validate: runs-on: ubuntu-latest - permissions: - contents: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '22' - # Lockfile and no lifecycle scripts: this job holds contents:write and pushes to - # main, so an install-time script from a hijacked package would run with a token that - # can rewrite the repository. rules/scripts/package-lock.json pins ajv, ajv-formats - # and every transitive package (Dependabot keeps it current); ajv is pure JS and - # needs no install hooks. + # Lockfile and no lifecycle scripts: rules/scripts/package-lock.json pins ajv, + # ajv-formats and every transitive package (Dependabot keeps it current); ajv is pure + # JS and needs no install hooks. - name: Install the rules validator from the lockfile working-directory: rules/scripts run: npm ci --ignore-scripts @@ -48,13 +52,31 @@ jobs: - name: Combine rules into dist/ run: node rules/scripts/combine.js - # Plain git instead of a third-party action: this step runs with contents:write on - # main, so no code we do not control gets to push. Checkout leaves the job's - # GITHUB_TOKEN in the remote credential helper, which is all `git push` needs. + # The one job that can push to main, and it installs nothing: combine.js needs only node's + # built-ins, so no package code ever runs next to the write token. Keep it that way -- a + # dependency in combine.js would need its own job. + publish: + needs: validate + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + # Checkout leaves the job's GITHUB_TOKEN in the remote credential helper, which is all + # `git push` needs. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + + - name: Combine rules into dist/ + run: node rules/scripts/combine.js + + # Plain git instead of a third-party action, so no code we do not control gets to push. # NOTE: combine.js writes THREE outputs; committing only dist/ would silently discard # the two generated TS modules and leave the guardrails-in-sync CI check red. - name: Commit generated outputs - if: github.ref == 'refs/heads/main' && github.event_name == 'push' run: | set -euo pipefail git config user.name "github-actions[bot]" diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index 6ebce11d..09f47ad1 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -3,8 +3,10 @@ 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: - id-token: write # OIDC federated login (secret-less) contents: read env: @@ -14,9 +16,19 @@ env: CSPROJ_PATH: src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj jobs: - build-and-deploy: + # 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 - name: Build and Deploy Backend + 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 @@ -30,11 +42,6 @@ jobs: tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - name: Setup .NET 10 - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - dotnet-version: '10.0.x' - # 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 @@ -57,20 +64,86 @@ jobs: "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=${{ steps.version.outputs.build_number }} -p:BuildTimestampUtc=${{ steps.version.outputs.build_utc }} + 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=${{ steps.version.outputs.build_number }} -p:BuildTimestampUtc=${{ steps.version.outputs.build_utc }} + 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: ./publish + 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 @@ -78,9 +151,11 @@ jobs: # /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 = '${{ steps.version.outputs.version }}' + $expectedVersion = $env:EXPECTED_VERSION $deadline = [DateTime]::UtcNow.AddMinutes(3) $attempt = 0 @@ -107,12 +182,15 @@ jobs: # (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 = '${{ steps.version.outputs.version }}' + version = $env:BACKEND_VERSION commit = '${{ github.sha }}'.Substring(0, 7) - buildUtc = '${{ steps.version.outputs.build_utc }}' + buildUtc = $env:BACKEND_BUILD_UTC deployedUtc = [DateTime]::UtcNow.ToString('o') runId = '${{ github.run_id }}' } | ConvertTo-Json diff --git a/.github/workflows/deploy-mcp.yml b/.github/workflows/deploy-mcp.yml index a3927fac..035a3ec8 100644 --- a/.github/workflows/deploy-mcp.yml +++ b/.github/workflows/deploy-mcp.yml @@ -21,9 +21,15 @@ env: PACKAGE_JSON: src/McpServer/autopilot-monitor-mcp/package.json jobs: + # One job on purpose, unlike the other deploys: all third-party code (npm ci, the build, the + # embedding precompute) runs inside `docker build`, whose containers see neither the az + # login nor the registry credentials nor this job's OIDC request token. That container is + # the boundary -- never move npm onto the runner here, or the build gets the job's + # credentials. The deploy identity trusts the `production` environment, which admits main only. build-and-deploy: runs-on: ubuntu-latest name: Build and Deploy MCP Server + environment: production steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 3b1e9c29..f2b92cf5 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -5,6 +5,11 @@ name: Deploy Web (Static Web App) # 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. @@ -17,10 +22,11 @@ on: - '.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 - id-token: write # keyless Sigstore signing (artifact attestation) - attestations: write # store the provenance attestation env: PORTAL_VERSION_URL: https://portal.autopilotmonitor.com/version.json @@ -31,9 +37,14 @@ env: SWA_CLI_VERSION: 2.0.10 jobs: - build_and_deploy_job: + # 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 - name: Build and Deploy Job + outputs: + docs_sha: ${{ steps.docs.outputs.sha }} + steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -41,15 +52,6 @@ jobs: lfs: false fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates - # Only the version manifest at the very end needs Azure; logging in here rather than - # next to that step keeps a credential problem from surfacing after a live deploy. - - 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 }} - # 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. @@ -136,19 +138,37 @@ jobs: echo "archive=$archive" >> "$GITHUB_OUTPUT" sha256sum "$archive" - # 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-.tar.gz --repo okieselbach/AutopilotMonitor - - name: Attest build provenance (portal export) - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 - with: - subject-path: ${{ steps.package.outputs.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/). @@ -163,6 +183,44 @@ jobs: --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: 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-.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. @@ -190,18 +248,32 @@ jobs: 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 = '${{ steps.docs.outputs.sha }}'.Substring(0, 7) + docsCommit = $env:DOCS_SHA.Substring(0, 7) } | ConvertTo-Json $authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1 diff --git a/.github/workflows/oidc-login-test.yml b/.github/workflows/oidc-login-test.yml deleted file mode 100644 index ac63f520..00000000 --- a/.github/workflows/oidc-login-test.yml +++ /dev/null @@ -1,28 +0,0 @@ -# TEMPORARY: proves the secret-less OIDC federated login works end-to-end. -# Delete after the cross-tenant migration is verified (or keep as a cheap smoke test). -name: OIDC Login Test - -on: - workflow_dispatch: - -permissions: - id-token: write - contents: read - -jobs: - login-test: - runs-on: ubuntu-latest - name: Federated login + read check - steps: - - 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: Prove identity and RBAC scope - run: | - az account show --query "{subscription:name, subscriptionId:id, tenantId:tenantId, user:user.name}" -o json - echo "--- function app visible with Contributor scope? ---" - az functionapp list --query "[].name" -o json diff --git a/zizmor.yml b/zizmor.yml index 52fcdeeb..9c1ccd49 100644 --- a/zizmor.yml +++ b/zizmor.yml @@ -4,6 +4,7 @@ rules: artipacked: ignore: - # Pushes the regenerated rule outputs to main with the job token that checkout - # leaves in the credential helper -- the one job that needs persisted credentials. + # The publish job pushes the regenerated rule outputs to main with the job token that + # checkout leaves in the credential helper -- the one job that needs persisted + # credentials. It installs no packages; the validate job beside it is read-only. - combine-rules.yml