diff --git a/.github/workflows/bootstrap-script-gates.yml b/.github/workflows/bootstrap-script-gates.yml index 45847c1d..b2834f5d 100644 --- a/.github/workflows/bootstrap-script-gates.yml +++ b/.github/workflows/bootstrap-script-gates.yml @@ -1,11 +1,14 @@ -name: Bootstrap script gates +name: Bootstrap script gates (reusable) # Quality gate for the customer-facing PowerShell scripts, factored out so it exists -# once and both callers run the identical checks: -# * pester.yml -- pull requests (reports, does not block the merge button) -# * publish-scripts.yml -- pushes to main, where it BLOCKS the upload to the -# download alias. Devices execute these scripts as SYSTEM -# during enrollment, so a red suite must never ship. +# once and every caller runs the identical checks. Its runs show under the caller: +# * pester.yml -- every pull request and push to main that changes an input +# of these checks (reports, does not block the merge button) +# * publish-scripts.yml -- BLOCKS the upload to the download alias +# * build-agent.yml -- BLOCKS a stable release, which publishes the scripts too +# * build-msi.yml -- BLOCKS signing the loader the MSI embeds +# Devices execute these scripts as SYSTEM during enrollment, so a red suite must never +# ship: every job that signs or uploads them waits for this workflow. on: workflow_call: diff --git a/.github/workflows/build-agent.yml b/.github/workflows/build-agent.yml index fc5444ab..3bd16d2b 100644 --- a/.github/workflows/build-agent.yml +++ b/.github/workflows/build-agent.yml @@ -254,6 +254,12 @@ jobs: if-no-files-found: error retention-days: 1 + # A stable release publishes the customer scripts as well, so it waits for the gates their + # own publish waits for. Next to the build; skipped when nothing goes out as stable. + gates: + if: inputs.publish_as_stable + uses: $/.github/workflows/bootstrap-script-gates.yml + # 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. The runner # image's Windows SDK signtool is used read-only, to verify third-party signatures. @@ -263,7 +269,9 @@ jobs: # sign nor write storage, whatever its permissions say. release: name: Sign and publish - needs: [reserve, build] + needs: [reserve, build, gates] + # A skipped gates job (no stable publish) lets the release run; a failed or cancelled one stops it. + if: ${{ !cancelled() && needs.build.result == 'success' && (needs.gates.result == 'success' || needs.gates.result == 'skipped') }} runs-on: windows-latest environment: release permissions: @@ -726,9 +734,9 @@ jobs: Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:STABLE_ZIP -ContentType "application/zip" Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:STABLE_MANIFEST -ContentType "application/json" - # Customer-facing scripts: same publisher publish-scripts.yml runs on every push - # to main, so a release lands a set that is consistent with what is already live - # (dev render, version-bump guard, bootstrapVersion oracles, alias verification). + # Customer-facing scripts: same publisher and same gates (the gates job) as + # publish-scripts.yml, so a release lands a set that is consistent with what is + # already live (dev render, version-bump guard, bootstrapVersion oracles, alias verification). # Runs AFTER version.json so its bootstrapVersion read sees this build's manifest. # Signed with the same identity: the device-side loader verifies the publisher # of what it downloads, and the publisher refuses to upload unsigned scripts. diff --git a/.github/workflows/build-msi.yml b/.github/workflows/build-msi.yml index f0a83e5a..50eed75e 100644 --- a/.github/workflows/build-msi.yml +++ b/.github/workflows/build-msi.yml @@ -34,6 +34,12 @@ env: CODE_SIGN_EXPECTED_SUBJECT: ${{ vars.CODE_SIGN_EXPECTED_SUBJECT }} jobs: + # The loader executes as SYSTEM on every enrolling device, and the MSI's custom action runs + # it: nothing is signed before the bootstrap gates pass on this commit, since a signed copy + # of ours is trusted wherever it lands. + gates: + uses: $/.github/workflows/bootstrap-script-gates.yml + # 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. @@ -42,6 +48,7 @@ jobs: # here. The `release` environment is the only subject the code-signing identity trusts. sign-loader: name: Sign bootstrap loader + needs: gates runs-on: windows-latest environment: release permissions: @@ -59,22 +66,6 @@ jobs: shell: pwsh run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv - # The loader executes as SYSTEM on every enrolling device — a parse error or - # non-ASCII mojibake (PS 5.1 codepage roulette) must fail the build, not the fleet. - # bootstrap-script-gates.yml runs the same checks plus Pester on pushes; this job - # stands on its own because it can be dispatched at any time. - - name: Validate runner script (parse + ASCII-only) - shell: pwsh - run: | - $errs = $null - [System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $env:RUNNER_SCRIPT), [ref]$null, [ref]$errs) | Out-Null - if ($errs) { $errs | ForEach-Object { Write-Error $_.ToString() }; exit 1 } - if ((Get-Content $env:RUNNER_SCRIPT -Raw) -match '[^\x00-\x7F]') { - Write-Error "Runner script contains non-ASCII characters" - exit 1 - } - 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 diff --git a/.github/workflows/pester.yml b/.github/workflows/pester.yml index b79d76e1..cfc03e24 100644 --- a/.github/workflows/pester.yml +++ b/.github/workflows/pester.yml @@ -1,25 +1,44 @@ -name: Pester (Bootstrap scripts) +name: Bootstrap script gates -# Pull-request view of the bootstrap script gates. Like ci.yml it reports rather than -# blocks the merge button. +# The bootstrap script gates on every change to what they execute or read, on pull requests +# and on pushes to main, independent of any publish. Like ci.yml it reports rather than +# blocks the merge button; what blocks is the gates job each publisher runs in front of its +# upload. # -# There is deliberately no push trigger: publish-scripts.yml runs the very same -# reusable gate workflow on every push to main and blocks the upload to the download -# alias on it. A push trigger here would only duplicate that run, with no consequence. +# Both path lists are the gates' complete inputs and stay identical. They cover every push +# path of publish-scripts.yml, which leaves the check of a [skip publish] push to this run. on: + push: + branches: [main] + paths: + - 'scripts/Bootstrap/**' + - 'scripts/CustomerSetup/**' + - '.github/scripts/Publish-BootstrapScripts.ps1' + - '.github/scripts/Get-Endpoints.ps1' + - '.github/scripts/Invoke-BootstrapSystemPass.ps1' + - '.github/scripts/Test-BootstrapAttacker.ps1' + - '.github/workflows/bootstrap-script-gates.yml' + - '.github/workflows/pester.yml' + # The integration tests run the generated OOBE script and the MSI's custom action; the + # contract tests also read the MSI build. + - 'src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1' + - 'src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs' + - '.github/workflows/build-msi.yml' pull_request: branches: [main] paths: - 'scripts/Bootstrap/**' - 'scripts/CustomerSetup/**' - '.github/scripts/Publish-BootstrapScripts.ps1' + - '.github/scripts/Get-Endpoints.ps1' - '.github/scripts/Invoke-BootstrapSystemPass.ps1' - '.github/scripts/Test-BootstrapAttacker.ps1' - '.github/workflows/bootstrap-script-gates.yml' - # The integration tests run the generated OOBE script and the MSI's command line. + - '.github/workflows/pester.yml' - 'src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1' - 'src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs' + - '.github/workflows/build-msi.yml' workflow_dispatch: permissions: diff --git a/.github/workflows/publish-scripts.yml b/.github/workflows/publish-scripts.yml index 8eb39efc..d4075f66 100644 --- a/.github/workflows/publish-scripts.yml +++ b/.github/workflows/publish-scripts.yml @@ -55,7 +55,12 @@ env: jobs: # $/ is the self-repository form: this repository at the running commit, never a # path some earlier step could have written to the workspace. + # + # A push with [skip publish] in one of its commit messages runs neither job and uploads + # nothing; pester.yml, whose paths cover these, still checks it. Never [skip actions] for + # that: it drops every check of the push, CI and these gates included. gates: + if: github.event_name != 'push' || !contains(join(github.event.commits.*.message, ' '), '[skip publish]') uses: $/.github/workflows/bootstrap-script-gates.yml publish: