From ac527542c6455f195bf521f1ea1cea9b2c549994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Mon, 4 May 2026 12:09:45 -0700 Subject: [PATCH 01/11] chore: x-compile aarch64 bt.exe on windows amd64 azure artifact signing doesn't support running on aarch64, need to check if cross compilation is possible --- .github/workflows/release-canary.yml | 6 ++++++ .github/workflows/release.yml | 6 ++++++ dist-workspace.toml | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index 4a414b9f..35b42fc1 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -160,6 +160,12 @@ jobs: - name: Install dependencies run: ${{ matrix.packages_install }} + - name: Set up MSVC cross-compilation for Windows ARM64 + if: ${{ contains(matrix.targets, 'aarch64-pc-windows-msvc') }} + uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0 + with: + arch: amd64_arm64 + - name: Build artifacts shell: bash run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 199bf570..bb530b73 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -146,6 +146,12 @@ jobs: - name: Install dependencies run: ${{ matrix.packages_install }} + - name: Set up MSVC cross-compilation for Windows ARM64 + if: ${{ contains(matrix.targets, 'aarch64-pc-windows-msvc') }} + uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0 + with: + arch: amd64_arm64 + - name: Configure SSL.com signing env if: ${{ runner.os == 'Windows' && env.HAS_SSLDOTCOM_SIGNING == 'true' && !fromJson(needs.plan.outputs.val).announcement_is_prerelease }} shell: bash diff --git a/dist-workspace.toml b/dist-workspace.toml index 81796755..a5763a88 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -29,4 +29,4 @@ windows-archive = ".zip" install-success-msg = "" [dist.github-custom-runners] -aarch64-pc-windows-msvc = "windows-11-arm" +aarch64-pc-windows-msvc = "windows-2022" From ea69f42f71cde034b511437b9700343b0b5b8ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Mon, 4 May 2026 13:29:20 -0700 Subject: [PATCH 02/11] fix: git shadows link.exe on aarch64 xcompiled on amd64 --- .github/workflows/release-canary.yml | 7 +++++++ .github/workflows/release.yml | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index 35b42fc1..cd64da0a 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -166,6 +166,13 @@ jobs: with: arch: amd64_arm64 + - name: Remove Git usr/bin from PATH (prevents Git link.exe shadowing MSVC link.exe) + if: ${{ contains(matrix.targets, 'aarch64-pc-windows-msvc') }} + shell: pwsh + run: | + $clean = $env:PATH -split ';' | Where-Object { $_ -ine 'C:\Program Files\Git\usr\bin' } + "PATH=$($clean -join ';')" >> $env:GITHUB_ENV + - name: Build artifacts shell: bash run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb530b73..724c3ebb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -152,6 +152,13 @@ jobs: with: arch: amd64_arm64 + - name: Remove Git usr/bin from PATH (prevents Git link.exe shadowing MSVC link.exe) + if: ${{ contains(matrix.targets, 'aarch64-pc-windows-msvc') }} + shell: pwsh + run: | + $clean = $env:PATH -split ';' | Where-Object { $_ -ine 'C:\Program Files\Git\usr\bin' } + "PATH=$($clean -join ';')" >> $env:GITHUB_ENV + - name: Configure SSL.com signing env if: ${{ runner.os == 'Windows' && env.HAS_SSLDOTCOM_SIGNING == 'true' && !fromJson(needs.plan.outputs.val).announcement_is_prerelease }} shell: bash From 8a765bc0d8dc1466e098c8ade7b8af202a53d159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Mon, 4 May 2026 13:41:26 -0700 Subject: [PATCH 03/11] fix: set correct link.exe path --- .github/workflows/release-canary.yml | 6 +++--- .github/workflows/release.yml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index cd64da0a..51e0d090 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -166,12 +166,12 @@ jobs: with: arch: amd64_arm64 - - name: Remove Git usr/bin from PATH (prevents Git link.exe shadowing MSVC link.exe) + - name: Set MSVC ARM64 linker for cargo cross-compilation if: ${{ contains(matrix.targets, 'aarch64-pc-windows-msvc') }} shell: pwsh run: | - $clean = $env:PATH -split ';' | Where-Object { $_ -ine 'C:\Program Files\Git\usr\bin' } - "PATH=$($clean -join ';')" >> $env:GITHUB_ENV + $link = (Get-Command link.exe -ErrorAction Stop).Source + "CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER=$link" >> $env:GITHUB_ENV - name: Build artifacts shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 724c3ebb..ee43fe19 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -152,12 +152,12 @@ jobs: with: arch: amd64_arm64 - - name: Remove Git usr/bin from PATH (prevents Git link.exe shadowing MSVC link.exe) + - name: Set MSVC ARM64 linker for cargo cross-compilation if: ${{ contains(matrix.targets, 'aarch64-pc-windows-msvc') }} shell: pwsh run: | - $clean = $env:PATH -split ';' | Where-Object { $_ -ine 'C:\Program Files\Git\usr\bin' } - "PATH=$($clean -join ';')" >> $env:GITHUB_ENV + $link = (Get-Command link.exe -ErrorAction Stop).Source + "CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER=$link" >> $env:GITHUB_ENV - name: Configure SSL.com signing env if: ${{ runner.os == 'Windows' && env.HAS_SSLDOTCOM_SIGNING == 'true' && !fromJson(needs.plan.outputs.val).announcement_is_prerelease }} From f23d2df64eeedcb6b039067a01d50031d57b8a70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Mon, 4 May 2026 17:26:09 -0700 Subject: [PATCH 04/11] feat: sign bt.exe with Azure artifact signing WIP Lack credentials in github secrets so does not work at the moment --- .github/workflows/release.yml | 68 +++++++++++++++++++++-------------- dist-workspace.toml | 1 - 2 files changed, 42 insertions(+), 27 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee43fe19..6a9cc03e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,7 @@ on: permissions: contents: write + id-token: write env: CARGO_NET_GIT_FETCH_WITH_CLI: true @@ -98,7 +99,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json - HAS_SSLDOTCOM_SIGNING: ${{ secrets.SSLDOTCOM_USERNAME != '' && secrets.SSLDOTCOM_PASSWORD != '' && secrets.SSLDOTCOM_CREDENTIAL_ID != '' && secrets.SSLDOTCOM_TOTP_SECRET != '' }} + HAS_AZURE_SIGNING: ${{ secrets.AZURE_CLIENT_ID != '' && secrets.AZURE_TENANT_ID != '' && secrets.AZURE_SUBSCRIPTION_ID != '' }} steps: - name: Enable windows longpaths run: git config --global core.longpaths true @@ -159,31 +160,6 @@ jobs: $link = (Get-Command link.exe -ErrorAction Stop).Source "CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER=$link" >> $env:GITHUB_ENV - - name: Configure SSL.com signing env - if: ${{ runner.os == 'Windows' && env.HAS_SSLDOTCOM_SIGNING == 'true' && !fromJson(needs.plan.outputs.val).announcement_is_prerelease }} - shell: bash - env: - SSLDOTCOM_USERNAME: ${{ secrets.SSLDOTCOM_USERNAME }} - SSLDOTCOM_PASSWORD: ${{ secrets.SSLDOTCOM_PASSWORD }} - SSLDOTCOM_CREDENTIAL_ID: ${{ secrets.SSLDOTCOM_CREDENTIAL_ID }} - SSLDOTCOM_TOTP_SECRET: ${{ secrets.SSLDOTCOM_TOTP_SECRET }} - run: | - write_github_env() { - local key="$1" - local value="$2" - local delimiter="EOF_${key}_$$" - { - echo "${key}<<${delimiter}" - echo "${value}" - echo "${delimiter}" - } >> "$GITHUB_ENV" - } - - write_github_env "SSLDOTCOM_USERNAME" "$SSLDOTCOM_USERNAME" - write_github_env "SSLDOTCOM_PASSWORD" "$SSLDOTCOM_PASSWORD" - write_github_env "SSLDOTCOM_CREDENTIAL_ID" "$SSLDOTCOM_CREDENTIAL_ID" - write_github_env "SSLDOTCOM_TOTP_SECRET" "$SSLDOTCOM_TOTP_SECRET" - - name: Build artifacts shell: bash run: | @@ -192,6 +168,46 @@ jobs: dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json echo "dist ran successfully" + - name: Azure login for code signing + if: ${{ runner.os == 'Windows' && env.HAS_AZURE_SIGNING == 'true' && !fromJson(needs.plan.outputs.val).announcement_is_prerelease }} + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Sign Windows executables + if: ${{ runner.os == 'Windows' && env.HAS_AZURE_SIGNING == 'true' && !fromJson(needs.plan.outputs.val).announcement_is_prerelease }} + uses: azure/artifact-signing-action@v0 + with: + azure-endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }} + trusted-signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ vars.AZURE_SIGNING_CERT_PROFILE }} + files-folder: target/distrib + files-folder-filter: exe + files-folder-recurse: true + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Refresh zip archives with signed executables + if: ${{ runner.os == 'Windows' && env.HAS_AZURE_SIGNING == 'true' && !fromJson(needs.plan.outputs.val).announcement_is_prerelease }} + shell: pwsh + run: | + Get-ChildItem -Path "target/distrib" -Filter "*.zip" | ForEach-Object { + $zipPath = $_.FullName + $stagingDir = Join-Path $_.DirectoryName $_.BaseName + if (Test-Path $stagingDir) { + Remove-Item $zipPath -Force + Compress-Archive -Path "$stagingDir\*" -DestinationPath $zipPath + $shaFile = "$zipPath.sha256" + if (Test-Path $shaFile) { + $hash = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLower() + "$hash $($_.Name)" | Set-Content $shaFile -NoNewline + } + } + } + - id: dist-files name: Post-build shell: bash diff --git a/dist-workspace.toml b/dist-workspace.toml index a5763a88..3b115aef 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -13,7 +13,6 @@ ci = "github" create-release = true # Which actions to run on pull requests pr-run-mode = "plan" -ssldotcom-windows-sign = "test" # The installers to generate for each app installers = ["shell", "powershell", "homebrew"] homepage = "https://github.com/braintrustdata/bt" From 0e57484ec76b1d1121d251c76767bfebdfe42df3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Fri, 22 May 2026 14:42:44 -0700 Subject: [PATCH 05/11] chore: sign bt.exe via Azure trusted signing --- .github/workflows/release.yml | 10 +- .github/workflows/test-azure-oidc.yml | 33 +++++ .../workflows/test-signing-self-signed.yml | 116 ++++++++++++++++++ 3 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/test-azure-oidc.yml create mode 100644 .github/workflows/test-signing-self-signed.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a9cc03e..b49e17dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,6 +96,10 @@ jobs: matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} runs-on: ${{ matrix.runner }} container: ${{ matrix.container && matrix.container.image || null }} + # Azure federated identity credentials require an exact-subject match; we + # scope OIDC tokens via this environment instead of branch/tag patterns. + # Azure secrets and signing config live on the environment, not the repo. + environment: release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json @@ -170,7 +174,7 @@ jobs: - name: Azure login for code signing if: ${{ runner.os == 'Windows' && env.HAS_AZURE_SIGNING == 'true' && !fromJson(needs.plan.outputs.val).announcement_is_prerelease }} - uses: azure/login@v2 + uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -178,9 +182,9 @@ jobs: - name: Sign Windows executables if: ${{ runner.os == 'Windows' && env.HAS_AZURE_SIGNING == 'true' && !fromJson(needs.plan.outputs.val).announcement_is_prerelease }} - uses: azure/artifact-signing-action@v0 + uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 with: - azure-endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }} + endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }} trusted-signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }} certificate-profile-name: ${{ vars.AZURE_SIGNING_CERT_PROFILE }} files-folder: target/distrib diff --git a/.github/workflows/test-azure-oidc.yml b/.github/workflows/test-azure-oidc.yml new file mode 100644 index 00000000..d61cc58f --- /dev/null +++ b/.github/workflows/test-azure-oidc.yml @@ -0,0 +1,33 @@ +name: test-azure-oidc + +# Validates that the federated identity credentials and GitHub repo secrets +# are wired up correctly. Does NOT invoke Trusted Signing — only confirms the +# OIDC handshake and that az can authenticate as the configured principal. +# Safe to run before the (paid) Trusted Signing Account is created. + +on: + workflow_dispatch: + +permissions: + id-token: write + contents: read + +jobs: + oidc: + runs-on: ubuntu-latest + # Sandbox AAD app's federated credential trusts this environment only. + environment: sandbox-release + steps: + - name: Azure login (OIDC) + uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Confirm authenticated identity + run: | + az account show + echo + echo "Signed-in service principal:" + az ad sp show --id "${{ secrets.AZURE_CLIENT_ID }}" --query "{displayName: displayName, appId: appId}" -o table diff --git a/.github/workflows/test-signing-self-signed.yml b/.github/workflows/test-signing-self-signed.yml new file mode 100644 index 00000000..e4b88289 --- /dev/null +++ b/.github/workflows/test-signing-self-signed.yml @@ -0,0 +1,116 @@ +name: test-signing-self-signed + +# Zero-cost validation of the Windows signing pipeline. +# Mirrors the release.yml signing flow but uses a self-signed certificate +# via signtool directly instead of azure/artifact-signing-action. This proves +# the signing mechanics and the zip-refresh logic work end to end without +# any Azure resources or costs. + +on: + workflow_dispatch: + pull_request: + paths: + - ".github/workflows/test-signing-self-signed.yml" + +permissions: + contents: read + +jobs: + signing-mechanics: + runs-on: windows-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Set up fake distrib layout (mirrors release.yml output) + shell: pwsh + run: | + New-Item -ItemType Directory -Path target/distrib/bt-x86_64-pc-windows-msvc -Force | Out-Null + # Use a real signable PE — copy a system exe so signtool has something legitimate to sign + Copy-Item C:\Windows\System32\where.exe target/distrib/bt-x86_64-pc-windows-msvc/bt.exe + # Build the matching zip that release.yml's "Refresh zip" step expects to find + Compress-Archive -Path target/distrib/bt-x86_64-pc-windows-msvc/* -DestinationPath target/distrib/bt-x86_64-pc-windows-msvc.zip + $hash = (Get-FileHash target/distrib/bt-x86_64-pc-windows-msvc.zip -Algorithm SHA256).Hash.ToLower() + "$hash bt-x86_64-pc-windows-msvc.zip" | Set-Content target/distrib/bt-x86_64-pc-windows-msvc.zip.sha256 -NoNewline + + - name: Create self-signed code-signing certificate + shell: pwsh + run: | + $cert = New-SelfSignedCertificate ` + -Subject "CN=BT Self-Signed Test" ` + -Type CodeSigningCert ` + -CertStoreLocation Cert:\CurrentUser\My ` + -KeyUsage DigitalSignature ` + -HashAlgorithm SHA256 ` + -NotAfter (Get-Date).AddDays(7) + $pfxPath = Join-Path $env:RUNNER_TEMP "test-signing.pfx" + $pwd = ConvertTo-SecureString -String "testpassword" -Force -AsPlainText + Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($cert.Thumbprint)" -FilePath $pfxPath -Password $pwd | Out-Null + "PFX_PATH=$pfxPath" >> $env:GITHUB_ENV + + - name: Locate signtool + shell: pwsh + run: | + $signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } | + Sort-Object FullName -Descending | + Select-Object -First 1 + if (-not $signtool) { throw "signtool.exe not found on runner" } + "SIGNTOOL=$($signtool.FullName)" >> $env:GITHUB_ENV + + - name: Sign exe (mirrors azure/artifact-signing-action behavior) + shell: pwsh + run: | + Get-ChildItem -Path target/distrib -Filter *.exe -Recurse | ForEach-Object { + Write-Host "Signing: $($_.FullName)" + & $env:SIGNTOOL sign ` + /f $env:PFX_PATH ` + /p "testpassword" ` + /fd SHA256 ` + /tr http://timestamp.digicert.com ` + /td SHA256 ` + $_.FullName + if ($LASTEXITCODE -ne 0) { throw "signtool sign failed" } + } + + - name: Verify signature on raw exe + shell: pwsh + run: | + # /pa would require a trusted root — self-signed certs won't pass that, but the + # signature itself must still be structurally valid and timestamped. + & $env:SIGNTOOL verify /v target/distrib/bt-x86_64-pc-windows-msvc/bt.exe + if ($LASTEXITCODE -ne 0) { throw "Signature verification failed on raw exe" } + + - name: Refresh zip archives with signed executables (verbatim from release.yml) + shell: pwsh + run: | + Get-ChildItem -Path "target/distrib" -Filter "*.zip" | ForEach-Object { + $zipPath = $_.FullName + $stagingDir = Join-Path $_.DirectoryName $_.BaseName + if (Test-Path $stagingDir) { + Remove-Item $zipPath -Force + Compress-Archive -Path "$stagingDir\*" -DestinationPath $zipPath + $shaFile = "$zipPath.sha256" + if (Test-Path $shaFile) { + $hash = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLower() + "$hash $($_.Name)" | Set-Content $shaFile -NoNewline + } + } + } + + - name: Verify signature survives the zip refresh + shell: pwsh + run: | + $verifyDir = Join-Path $env:RUNNER_TEMP "verify" + New-Item -ItemType Directory -Path $verifyDir -Force | Out-Null + Expand-Archive -Path target/distrib/bt-x86_64-pc-windows-msvc.zip -DestinationPath $verifyDir -Force + & $env:SIGNTOOL verify /v (Join-Path $verifyDir "bt.exe") + if ($LASTEXITCODE -ne 0) { throw "Signature was lost after zip refresh" } + Write-Host "Signature survived the zip refresh" + + - name: Verify sha256 file matches the refreshed zip + shell: pwsh + run: | + $expected = (Get-Content target/distrib/bt-x86_64-pc-windows-msvc.zip.sha256 -Raw).Split(' ')[0].Trim() + $actual = (Get-FileHash target/distrib/bt-x86_64-pc-windows-msvc.zip -Algorithm SHA256).Hash.ToLower() + if ($expected -ne $actual) { throw "sha256 mismatch: expected=$expected actual=$actual" } + Write-Host "sha256 file matches the refreshed zip" From e45a88749f69e9f9dcfbe47d3c285835e602d7b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Fri, 22 May 2026 15:42:02 -0700 Subject: [PATCH 06/11] fix: trust self-signed code for the test --- .../workflows/test-signing-self-signed.yml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-signing-self-signed.yml b/.github/workflows/test-signing-self-signed.yml index e4b88289..0308a934 100644 --- a/.github/workflows/test-signing-self-signed.yml +++ b/.github/workflows/test-signing-self-signed.yml @@ -42,6 +42,15 @@ jobs: -KeyUsage DigitalSignature ` -HashAlgorithm SHA256 ` -NotAfter (Get-Date).AddDays(7) + + # signtool verify always validates the chain to a trusted root — there's + # no flag for structural-only validation. Import the public cert into the + # runner's trusted root store so the chain check passes for this self- + # signed cert. Test workflow only; never do this on a real machine. + $cerPath = Join-Path $env:RUNNER_TEMP "test-signing.cer" + Export-Certificate -Cert "Cert:\CurrentUser\My\$($cert.Thumbprint)" -FilePath $cerPath | Out-Null + Import-Certificate -FilePath $cerPath -CertStoreLocation Cert:\CurrentUser\Root | Out-Null + $pfxPath = Join-Path $env:RUNNER_TEMP "test-signing.pfx" $pwd = ConvertTo-SecureString -String "testpassword" -Force -AsPlainText Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($cert.Thumbprint)" -FilePath $pfxPath -Password $pwd | Out-Null @@ -75,9 +84,11 @@ jobs: - name: Verify signature on raw exe shell: pwsh run: | - # /pa would require a trusted root — self-signed certs won't pass that, but the - # signature itself must still be structurally valid and timestamped. - & $env:SIGNTOOL verify /v target/distrib/bt-x86_64-pc-windows-msvc/bt.exe + # /pa selects the default authenticode verification policy, which is the + # correct policy for code signing. The chain check passes because the + # cert generation step above imported the self-signed cert into + # CurrentUser\Root. + & $env:SIGNTOOL verify /pa /v target/distrib/bt-x86_64-pc-windows-msvc/bt.exe if ($LASTEXITCODE -ne 0) { throw "Signature verification failed on raw exe" } - name: Refresh zip archives with signed executables (verbatim from release.yml) @@ -103,7 +114,7 @@ jobs: $verifyDir = Join-Path $env:RUNNER_TEMP "verify" New-Item -ItemType Directory -Path $verifyDir -Force | Out-Null Expand-Archive -Path target/distrib/bt-x86_64-pc-windows-msvc.zip -DestinationPath $verifyDir -Force - & $env:SIGNTOOL verify /v (Join-Path $verifyDir "bt.exe") + & $env:SIGNTOOL verify /pa /v (Join-Path $verifyDir "bt.exe") if ($LASTEXITCODE -ne 0) { throw "Signature was lost after zip refresh" } Write-Host "Signature survived the zip refresh" From 9d1979eb4f9b68d50acf83bf168f05e84cc96bc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Fri, 22 May 2026 16:17:31 -0700 Subject: [PATCH 07/11] fix: needing to approve installing a cert --- .github/workflows/test-signing-self-signed.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test-signing-self-signed.yml b/.github/workflows/test-signing-self-signed.yml index 0308a934..1abd8ff6 100644 --- a/.github/workflows/test-signing-self-signed.yml +++ b/.github/workflows/test-signing-self-signed.yml @@ -44,12 +44,15 @@ jobs: -NotAfter (Get-Date).AddDays(7) # signtool verify always validates the chain to a trusted root — there's - # no flag for structural-only validation. Import the public cert into the - # runner's trusted root store so the chain check passes for this self- - # signed cert. Test workflow only; never do this on a real machine. - $cerPath = Join-Path $env:RUNNER_TEMP "test-signing.cer" - Export-Certificate -Cert "Cert:\CurrentUser\My\$($cert.Thumbprint)" -FilePath $cerPath | Out-Null - Import-Certificate -FilePath $cerPath -CertStoreLocation Cert:\CurrentUser\Root | Out-Null + # no flag for structural-only validation. Add the public cert to the + # runner's CurrentUser\Root store so the chain check passes for this + # self-signed cert. Use X509Store directly (not Import-Certificate), + # which triggers a CryptUI confirmation dialog that hangs headless + # runners forever. Test workflow only; never do this on a real machine. + $store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'CurrentUser') + $store.Open('ReadWrite') + $store.Add($cert) + $store.Close() $pfxPath = Join-Path $env:RUNNER_TEMP "test-signing.pfx" $pwd = ConvertTo-SecureString -String "testpassword" -Force -AsPlainText From 4871fc135b200270729656edc1fcfda27cb76611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Fri, 22 May 2026 16:25:43 -0700 Subject: [PATCH 08/11] fix: needing to approve installing a cert --- .../workflows/test-signing-self-signed.yml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test-signing-self-signed.yml b/.github/workflows/test-signing-self-signed.yml index 1abd8ff6..c18cd584 100644 --- a/.github/workflows/test-signing-self-signed.yml +++ b/.github/workflows/test-signing-self-signed.yml @@ -44,15 +44,16 @@ jobs: -NotAfter (Get-Date).AddDays(7) # signtool verify always validates the chain to a trusted root — there's - # no flag for structural-only validation. Add the public cert to the - # runner's CurrentUser\Root store so the chain check passes for this - # self-signed cert. Use X509Store directly (not Import-Certificate), - # which triggers a CryptUI confirmation dialog that hangs headless - # runners forever. Test workflow only; never do this on a real machine. - $store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'CurrentUser') - $store.Open('ReadWrite') - $store.Add($cert) - $store.Close() + # no flag for structural-only validation. Trust the self-signed cert at + # the runner level so the chain check passes. Both Import-Certificate + # and X509Store.Add() against a Root store trigger a CryptUI consent + # dialog that hangs headless runners. certutil is a subprocess that + # bypasses CryptUI entirely. Test workflow only; never do this on a + # real machine. + $cerPath = Join-Path $env:RUNNER_TEMP "test-signing.cer" + Export-Certificate -Cert "Cert:\CurrentUser\My\$($cert.Thumbprint)" -FilePath $cerPath | Out-Null + & certutil.exe -addstore -f -user Root $cerPath + if ($LASTEXITCODE -ne 0) { throw "certutil -addstore failed with exit code $LASTEXITCODE" } $pfxPath = Join-Path $env:RUNNER_TEMP "test-signing.pfx" $pwd = ConvertTo-SecureString -String "testpassword" -Force -AsPlainText From e5e3413fe2a2b31be6e9fe231e31840f6ae7528b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Fri, 22 May 2026 16:48:07 -0700 Subject: [PATCH 09/11] fix: needing to approve installing a cert --- .../workflows/test-signing-self-signed.yml | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/.github/workflows/test-signing-self-signed.yml b/.github/workflows/test-signing-self-signed.yml index c18cd584..eb2cf899 100644 --- a/.github/workflows/test-signing-self-signed.yml +++ b/.github/workflows/test-signing-self-signed.yml @@ -42,19 +42,6 @@ jobs: -KeyUsage DigitalSignature ` -HashAlgorithm SHA256 ` -NotAfter (Get-Date).AddDays(7) - - # signtool verify always validates the chain to a trusted root — there's - # no flag for structural-only validation. Trust the self-signed cert at - # the runner level so the chain check passes. Both Import-Certificate - # and X509Store.Add() against a Root store trigger a CryptUI consent - # dialog that hangs headless runners. certutil is a subprocess that - # bypasses CryptUI entirely. Test workflow only; never do this on a - # real machine. - $cerPath = Join-Path $env:RUNNER_TEMP "test-signing.cer" - Export-Certificate -Cert "Cert:\CurrentUser\My\$($cert.Thumbprint)" -FilePath $cerPath | Out-Null - & certutil.exe -addstore -f -user Root $cerPath - if ($LASTEXITCODE -ne 0) { throw "certutil -addstore failed with exit code $LASTEXITCODE" } - $pfxPath = Join-Path $env:RUNNER_TEMP "test-signing.pfx" $pwd = ConvertTo-SecureString -String "testpassword" -Force -AsPlainText Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($cert.Thumbprint)" -FilePath $pfxPath -Password $pwd | Out-Null @@ -88,12 +75,20 @@ jobs: - name: Verify signature on raw exe shell: pwsh run: | - # /pa selects the default authenticode verification policy, which is the - # correct policy for code signing. The chain check passes because the - # cert generation step above imported the self-signed cert into - # CurrentUser\Root. - & $env:SIGNTOOL verify /pa /v target/distrib/bt-x86_64-pc-windows-msvc/bt.exe - if ($LASTEXITCODE -ne 0) { throw "Signature verification failed on raw exe" } + # Get-AuthenticodeSignature returns a structured status. We accept + # NotTrusted (untrusted self-signed root, but signature itself is valid + # and timestamped) — that's the expected state for this test. signtool + # verify can't be used because every supported way to install a self- + # signed cert into the runner's Root store hangs on a CryptUI prompt. + # Production signing uses Trusted Signing, which chains to a trusted + # Microsoft root and verifies cleanly without any of this. + $sig = Get-AuthenticodeSignature target/distrib/bt-x86_64-pc-windows-msvc/bt.exe + if ($sig.Status -notin @('Valid', 'NotTrusted')) { + throw "Signature invalid (status=$($sig.Status)): $($sig.StatusMessage)" + } + if (-not $sig.SignerCertificate) { throw "Signature missing signer certificate" } + if (-not $sig.TimeStamperCertificate) { throw "Signature missing RFC3161 timestamp" } + Write-Host "Raw exe signature OK (status=$($sig.Status), signer=$($sig.SignerCertificate.Subject))" - name: Refresh zip archives with signed executables (verbatim from release.yml) shell: pwsh @@ -118,9 +113,13 @@ jobs: $verifyDir = Join-Path $env:RUNNER_TEMP "verify" New-Item -ItemType Directory -Path $verifyDir -Force | Out-Null Expand-Archive -Path target/distrib/bt-x86_64-pc-windows-msvc.zip -DestinationPath $verifyDir -Force - & $env:SIGNTOOL verify /pa /v (Join-Path $verifyDir "bt.exe") - if ($LASTEXITCODE -ne 0) { throw "Signature was lost after zip refresh" } - Write-Host "Signature survived the zip refresh" + $sig = Get-AuthenticodeSignature (Join-Path $verifyDir "bt.exe") + if ($sig.Status -notin @('Valid', 'NotTrusted')) { + throw "Signature lost after zip refresh (status=$($sig.Status)): $($sig.StatusMessage)" + } + if (-not $sig.SignerCertificate) { throw "Signature missing signer certificate after zip refresh" } + if (-not $sig.TimeStamperCertificate) { throw "Signature missing RFC3161 timestamp after zip refresh" } + Write-Host "Signature survived the zip refresh (status=$($sig.Status))" - name: Verify sha256 file matches the refreshed zip shell: pwsh From 5a88a758f3f87158524c8ad87d7f6f2e065dd1c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Fri, 22 May 2026 18:53:19 -0700 Subject: [PATCH 10/11] chore: update azure login action to v3 --- .github/workflows/release.yml | 8 ++++---- .github/workflows/test-azure-oidc.yml | 2 +- .github/workflows/test-signing-self-signed.yml | 4 ++-- dist-workspace.toml | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b49e17dc..d722d15e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -173,15 +173,15 @@ jobs: echo "dist ran successfully" - name: Azure login for code signing - if: ${{ runner.os == 'Windows' && env.HAS_AZURE_SIGNING == 'true' && !fromJson(needs.plan.outputs.val).announcement_is_prerelease }} - uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2 + if: ${{ runner.os == 'Windows' && env.HAS_AZURE_SIGNING == 'true' }} + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - name: Sign Windows executables - if: ${{ runner.os == 'Windows' && env.HAS_AZURE_SIGNING == 'true' && !fromJson(needs.plan.outputs.val).announcement_is_prerelease }} + if: ${{ runner.os == 'Windows' && env.HAS_AZURE_SIGNING == 'true' }} uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 with: endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }} @@ -195,7 +195,7 @@ jobs: timestamp-digest: SHA256 - name: Refresh zip archives with signed executables - if: ${{ runner.os == 'Windows' && env.HAS_AZURE_SIGNING == 'true' && !fromJson(needs.plan.outputs.val).announcement_is_prerelease }} + if: ${{ runner.os == 'Windows' && env.HAS_AZURE_SIGNING == 'true' }} shell: pwsh run: | Get-ChildItem -Path "target/distrib" -Filter "*.zip" | ForEach-Object { diff --git a/.github/workflows/test-azure-oidc.yml b/.github/workflows/test-azure-oidc.yml index d61cc58f..1a3800fd 100644 --- a/.github/workflows/test-azure-oidc.yml +++ b/.github/workflows/test-azure-oidc.yml @@ -19,7 +19,7 @@ jobs: environment: sandbox-release steps: - name: Azure login (OIDC) - uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2 + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} diff --git a/.github/workflows/test-signing-self-signed.yml b/.github/workflows/test-signing-self-signed.yml index eb2cf899..2db0e60e 100644 --- a/.github/workflows/test-signing-self-signed.yml +++ b/.github/workflows/test-signing-self-signed.yml @@ -43,7 +43,7 @@ jobs: -HashAlgorithm SHA256 ` -NotAfter (Get-Date).AddDays(7) $pfxPath = Join-Path $env:RUNNER_TEMP "test-signing.pfx" - $pwd = ConvertTo-SecureString -String "testpassword" -Force -AsPlainText + $pwd = ConvertTo-SecureString -String "1d4cbcfd39a6560c5edabc499725ed1e7d4a7d6d72266803c91398821e06b744" -Force -AsPlainText Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($cert.Thumbprint)" -FilePath $pfxPath -Password $pwd | Out-Null "PFX_PATH=$pfxPath" >> $env:GITHUB_ENV @@ -64,7 +64,7 @@ jobs: Write-Host "Signing: $($_.FullName)" & $env:SIGNTOOL sign ` /f $env:PFX_PATH ` - /p "testpassword" ` + /p "1d4cbcfd39a6560c5edabc499725ed1e7d4a7d6d72266803c91398821e06b744" ` /fd SHA256 ` /tr http://timestamp.digicert.com ` /td SHA256 ` diff --git a/dist-workspace.toml b/dist-workspace.toml index 3b115aef..c99c51e7 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -28,4 +28,4 @@ windows-archive = ".zip" install-success-msg = "" [dist.github-custom-runners] -aarch64-pc-windows-msvc = "windows-2022" +aarch64-pc-windows-msvc = "windows-2022" # azure/artifact-signing-action doesn't run on ARM, so we need to cross-compile bt.exe for ARM64 and sign it on windows amd64 From 898c927453831f6cc8d5ff743a1686ce5eeb7c7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Fri, 22 May 2026 19:07:25 -0700 Subject: [PATCH 11/11] chore: sign all releases --- .../actions/sign-windows-artifacts/action.yml | 60 +++++++++++++++++++ .github/workflows/release-canary.yml | 17 ++++++ .github/workflows/release.yml | 37 ++---------- 3 files changed, 81 insertions(+), 33 deletions(-) create mode 100644 .github/actions/sign-windows-artifacts/action.yml diff --git a/.github/actions/sign-windows-artifacts/action.yml b/.github/actions/sign-windows-artifacts/action.yml new file mode 100644 index 00000000..cbe847cc --- /dev/null +++ b/.github/actions/sign-windows-artifacts/action.yml @@ -0,0 +1,60 @@ +name: Sign Windows artifacts +description: Azure OIDC login + Trusted Signing of all .exe under target/distrib, then refresh zips so archives contain the signed binaries. + +inputs: + client-id: + required: true + description: Azure AD App Registration client ID (OIDC) + tenant-id: + required: true + description: Azure tenant ID + subscription-id: + required: true + description: Azure subscription ID + endpoint: + required: true + description: Trusted Signing Account endpoint URL + account-name: + required: true + description: Trusted Signing Account name + cert-profile: + required: true + description: Certificate profile name + +runs: + using: composite + steps: + - uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + client-id: ${{ inputs.client-id }} + tenant-id: ${{ inputs.tenant-id }} + subscription-id: ${{ inputs.subscription-id }} + + - uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 + with: + endpoint: ${{ inputs.endpoint }} + trusted-signing-account-name: ${{ inputs.account-name }} + certificate-profile-name: ${{ inputs.cert-profile }} + files-folder: target/distrib + files-folder-filter: exe + files-folder-recurse: true + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Refresh zip archives with signed executables + shell: pwsh + run: | + Get-ChildItem -Path "target/distrib" -Filter "*.zip" | ForEach-Object { + $zipPath = $_.FullName + $stagingDir = Join-Path $_.DirectoryName $_.BaseName + if (Test-Path $stagingDir) { + Remove-Item $zipPath -Force + Compress-Archive -Path "$stagingDir\*" -DestinationPath $zipPath + $shaFile = "$zipPath.sha256" + if (Test-Path $shaFile) { + $hash = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLower() + "$hash $($_.Name)" | Set-Content $shaFile -NoNewline + } + } + } diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index 51e0d090..b085579c 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -13,6 +13,7 @@ concurrency: permissions: actions: read contents: write + id-token: write issues: write pull-requests: write @@ -110,9 +111,14 @@ jobs: matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} runs-on: ${{ matrix.runner }} container: ${{ matrix.container && matrix.container.image || null }} + # Only claim the release env on main so the OIDC token's subject matches + # the AAD federated credential. Other branches get no env, no env secrets, + # and skip signing. + environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + HAS_AZURE_SIGNING: ${{ secrets.AZURE_CLIENT_ID != '' && secrets.AZURE_TENANT_ID != '' && secrets.AZURE_SUBSCRIPTION_ID != '' }} steps: - name: Enable windows longpaths run: git config --global core.longpaths true @@ -179,6 +185,17 @@ jobs: dist build --tag="${{ needs.plan.outputs.dist-tag }}" --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json echo "dist ran successfully" + - name: Sign Windows artifacts + if: ${{ runner.os == 'Windows' && env.HAS_AZURE_SIGNING == 'true' }} + uses: ./.github/actions/sign-windows-artifacts + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }} + account-name: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }} + cert-profile: ${{ vars.AZURE_SIGNING_CERT_PROFILE }} + - id: dist-files name: Post-build shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d722d15e..f27a8d37 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -172,45 +172,16 @@ jobs: dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json echo "dist ran successfully" - - name: Azure login for code signing + - name: Sign Windows artifacts if: ${{ runner.os == 'Windows' && env.HAS_AZURE_SIGNING == 'true' }} - uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + uses: ./.github/actions/sign-windows-artifacts with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - - name: Sign Windows executables - if: ${{ runner.os == 'Windows' && env.HAS_AZURE_SIGNING == 'true' }} - uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 - with: endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }} - trusted-signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }} - certificate-profile-name: ${{ vars.AZURE_SIGNING_CERT_PROFILE }} - files-folder: target/distrib - files-folder-filter: exe - files-folder-recurse: true - file-digest: SHA256 - timestamp-rfc3161: http://timestamp.acs.microsoft.com - timestamp-digest: SHA256 - - - name: Refresh zip archives with signed executables - if: ${{ runner.os == 'Windows' && env.HAS_AZURE_SIGNING == 'true' }} - shell: pwsh - run: | - Get-ChildItem -Path "target/distrib" -Filter "*.zip" | ForEach-Object { - $zipPath = $_.FullName - $stagingDir = Join-Path $_.DirectoryName $_.BaseName - if (Test-Path $stagingDir) { - Remove-Item $zipPath -Force - Compress-Archive -Path "$stagingDir\*" -DestinationPath $zipPath - $shaFile = "$zipPath.sha256" - if (Test-Path $shaFile) { - $hash = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLower() - "$hash $($_.Name)" | Set-Content $shaFile -NoNewline - } - } - } + account-name: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }} + cert-profile: ${{ vars.AZURE_SIGNING_CERT_PROFILE }} - id: dist-files name: Post-build