From 03a4940ff6878998ec5a5a2d42764fe60b6b7f53 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 8 Sep 2026 14:03:33 -0600 Subject: [PATCH] ci(release): sign Windows binaries with Azure Artifact Signing Windows binaries have never been Authenticode-signed. On machines running WDAC, Smart App Control, or AppLocker in enforcing mode the OS refuses to execute the unsigned clerk.exe, which surfaces through the npm wrapper shim as "Failed to start Clerk CLI: spawnSync ... UNKNOWN" and logs Code Integrity event 3077. Add a sign-windows reusable workflow mirroring sign-macos, wired into the stable, canary, and snapshot pipelines so publishing is gated on it. Signing goes through Azure Artifact Signing, whose leaf certificates live roughly 72 hours, so the verify step fails on a missing RFC-3161 countersignature as well as an invalid status. Fixes #474 --- .github/workflows/release.yml | 88 ++++++++++++++++++++++++---- .github/workflows/sign-windows.yml | 94 ++++++++++++++++++++++++++++++ docs/releasing.md | 49 +++++++++++++--- 3 files changed, 214 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/sign-windows.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1177249ec..5180b6275 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,8 +82,25 @@ jobs: APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} + sign-windows: + needs: [versioning, build] + if: needs.versioning.outputs.release_created == 'true' + permissions: + contents: read + id-token: write + uses: ./.github/workflows/sign-windows.yml + with: + artifact-prefix: clerk + secrets: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_SIGNING_CERT_PROFILE: ${{ secrets.AZURE_SIGNING_CERT_PROFILE }} + smoke-test: - needs: [versioning, build, sign-macos] + needs: [versioning, build, sign-macos, sign-windows] uses: ./.github/workflows/smoke-test.yml with: version: ${{ needs.versioning.outputs.version }} @@ -91,7 +108,7 @@ jobs: preset: stable publish-npm: - needs: [versioning, build, sign-macos, smoke-test] + needs: [versioning, build, sign-macos, sign-windows, smoke-test] # Must run on GitHub-hosted runner for npm OIDC trusted publishing runs-on: ubuntu-latest timeout-minutes: 15 @@ -119,7 +136,7 @@ jobs: GH_TOKEN: ${{ github.token }} publish-github: - needs: [versioning, build, sign-macos, smoke-test, publish-npm] + needs: [versioning, build, sign-macos, sign-windows, smoke-test, publish-npm] runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -144,7 +161,7 @@ jobs: done homebrew: - needs: [versioning, build, sign-macos, smoke-test, publish-npm] + needs: [versioning, build, sign-macos, sign-windows, smoke-test, publish-npm] runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -175,7 +192,17 @@ jobs: always() && needs.versioning.outputs.release_created == 'true' && contains(needs.*.result, 'failure') - needs: [versioning, build, sign-macos, smoke-test, publish-npm, publish-github, homebrew] + needs: + [ + versioning, + build, + sign-macos, + sign-windows, + smoke-test, + publish-npm, + publish-github, + homebrew, + ] uses: ./.github/workflows/notify-failure.yml with: workflow-name: Stable release @@ -238,8 +265,24 @@ jobs: APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} + canary-sign-windows: + needs: [canary-version, canary-build] + permissions: + contents: read + id-token: write + uses: ./.github/workflows/sign-windows.yml + with: + artifact-prefix: clerk-canary + secrets: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_SIGNING_CERT_PROFILE: ${{ secrets.AZURE_SIGNING_CERT_PROFILE }} + canary-smoke-test: - needs: [canary-version, canary-build, canary-sign-macos] + needs: [canary-version, canary-build, canary-sign-macos, canary-sign-windows] uses: ./.github/workflows/smoke-test.yml with: version: ${{ needs.canary-version.outputs.version }} @@ -247,7 +290,7 @@ jobs: preset: canary canary-publish-github: - needs: [canary-version, canary-build, canary-sign-macos, canary-smoke-test] + needs: [canary-version, canary-build, canary-sign-macos, canary-sign-windows, canary-smoke-test] runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -335,6 +378,7 @@ jobs: canary-version, canary-build, canary-sign-macos, + canary-sign-windows, canary-smoke-test, canary-publish-github, canary-publish-npm, @@ -471,8 +515,24 @@ jobs: APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} + snapshot-sign-windows: + needs: [snapshot, snapshot-build] + permissions: + contents: read + id-token: write + uses: ./.github/workflows/sign-windows.yml + with: + artifact-prefix: clerk-snapshot + secrets: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_SIGNING_CERT_PROFILE: ${{ secrets.AZURE_SIGNING_CERT_PROFILE }} + snapshot-smoke-test: - needs: [snapshot, snapshot-build, snapshot-sign-macos] + needs: [snapshot, snapshot-build, snapshot-sign-macos, snapshot-sign-windows] uses: ./.github/workflows/smoke-test.yml with: version: ${{ needs.snapshot.outputs.version }} @@ -480,7 +540,7 @@ jobs: preset: snapshot snapshot-publish: - needs: [snapshot, snapshot-build, snapshot-sign-macos, snapshot-smoke-test] + needs: [snapshot, snapshot-build, snapshot-sign-macos, snapshot-sign-windows, snapshot-smoke-test] # Must run on GitHub-hosted runner for npm OIDC trusted publishing runs-on: ubuntu-latest timeout-minutes: 15 @@ -566,7 +626,15 @@ jobs: snapshot-notify-failure: needs: - [snapshot, snapshot-ci, snapshot-build, snapshot-sign-macos, snapshot-smoke-test, snapshot-publish] + [ + snapshot, + snapshot-ci, + snapshot-build, + snapshot-sign-macos, + snapshot-sign-windows, + snapshot-smoke-test, + snapshot-publish, + ] if: >- always() && contains(needs.*.result, 'failure') diff --git a/.github/workflows/sign-windows.yml b/.github/workflows/sign-windows.yml new file mode 100644 index 000000000..326df0ab1 --- /dev/null +++ b/.github/workflows/sign-windows.yml @@ -0,0 +1,94 @@ +name: Sign Windows Binaries + +on: + workflow_call: + inputs: + artifact-prefix: + required: true + type: string + description: "Prefix for artifact names (e.g. 'clerk', 'clerk-canary', 'clerk-snapshot')" + secrets: + AZURE_TENANT_ID: + required: true + AZURE_CLIENT_ID: + required: true + AZURE_SUBSCRIPTION_ID: + required: true + AZURE_SIGNING_ENDPOINT: + required: true + AZURE_SIGNING_ACCOUNT: + required: true + AZURE_SIGNING_CERT_PROFILE: + required: true + +permissions: + contents: read + # Azure Artifact Signing authenticates via OIDC federated credentials; the + # calling job must grant id-token: write or azure/login cannot mint a token. + id-token: write + +jobs: + sign: + strategy: + fail-fast: false + matrix: + include: + - target: win32-arm64 + - target: win32-x64 + # Azure Artifact Signing ships a Windows-only signing engine (it drives + # signtool under the hood), so this cannot share the Linux build runner. + runs-on: windows-latest + timeout-minutes: 15 + steps: + # No checkout: unlike sign-macos.ts there is no local script to run -- + # the action owns credential handling, batching, and signtool invocation. + - uses: actions/download-artifact@v8 + with: + name: ${{ inputs.artifact-prefix }}-${{ matrix.target }} + path: dist/artifacts/${{ matrix.target }} + + - name: Azure login + uses: azure/login@v3 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Sign + uses: azure/artifact-signing-action@v2 + with: + endpoint: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + certificate-profile-name: ${{ secrets.AZURE_SIGNING_CERT_PROFILE }} + files: ${{ github.workspace }}\dist\artifacts\${{ matrix.target }}\clerk.exe + file-digest: SHA256 + # Artifact Signing leaf certificates are valid for ~72 hours. The + # RFC-3161 countersignature is what keeps a released binary trusted + # after the leaf expires, so an untimestamped signature is a bug. + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Verify signature + shell: pwsh + run: | + $path = "dist\artifacts\${{ matrix.target }}\clerk.exe" + $sig = Get-AuthenticodeSignature -FilePath $path + + Write-Host "Status: $($sig.Status)" + Write-Host "Signer: $($sig.SignerCertificate.Subject)" + Write-Host "Timestamp: $($sig.TimeStamperCertificate.Subject)" + + if ($sig.Status -ne 'Valid') { + throw "Authenticode verification failed ($($sig.Status)): $($sig.StatusMessage)" + } + if ($null -eq $sig.TimeStamperCertificate) { + throw "Signature is not timestamped; it would stop validating once the leaf certificate expires." + } + + - name: Re-upload signed artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.artifact-prefix }}-${{ matrix.target }} + path: dist/artifacts/${{ matrix.target }}/clerk.exe + overwrite: true + retention-days: 1 diff --git a/docs/releasing.md b/docs/releasing.md index da72ed8dc..5aafdd6f8 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -10,18 +10,18 @@ push to main -> merge "Version Packages" PR -> check-release.ts detects unpublished version -> build job: cross-compile all 8 targets (~5.5s total) - -> sign-macos job: code sign + notarize darwin binaries + -> sign-macos + sign-windows jobs: code sign darwin and win32 binaries -> smoke-test job: verify binaries on native runners -> publish-npm: generate platform packages + publish wrapper -> upload-github-assets: attach binaries to the GitHub Release -> homebrew: create archives, upload, render formula, push to clerk/homebrew-stable -> (if no stable release needed) canary.ts versions packages - -> build -> sign-macos -> smoke-test subset -> upload GitHub pre-release -> publish @canary (npm) + -> build -> sign-macos + sign-windows -> smoke-test subset -> upload GitHub pre-release -> publish @canary (npm) PR comment "!snapshot [name]" -> snapshot.ts versions packages from PR branch -> build job: cross-compile binaries - -> sign-macos job: code sign + notarize darwin binaries + -> sign-macos + sign-windows jobs: code sign darwin and win32 binaries -> smoke-test job: verify linux-x64 binary -> publish-npm: publish @snapshot packages -> post installation comment on PR @@ -150,7 +150,40 @@ APPLE_API_KEY_BASE64="..." APPLE_API_KEY_ID="..." APPLE_API_ISSUER_ID="..." \ bun run scripts/sign-macos.ts --target darwin-arm64 --artifacts-dir dist/artifacts ``` -### 3. Smoke Test Job (matrix) +### 3. Sign Windows Job + +Defined in [`.github/workflows/sign-windows.yml`](../.github/workflows/sign-windows.yml) and called by the release, canary, and snapshot pipelines. Runs on a Windows runner (one job per win32 architecture). Unlike macOS there is no local script -- [`azure/artifact-signing-action`](https://github.com/Azure/artifact-signing-action) owns credential handling and the `signtool` invocation. For each target, the job: + +1. Authenticates to Azure via `azure/login` using OIDC federated credentials (no stored client secret) +2. Signs the binary through Azure Artifact Signing (formerly Trusted Signing) with a SHA256 file digest +3. Countersigns with an RFC-3161 timestamp from `http://timestamp.acs.microsoft.com` +4. Verifies the result with `Get-AuthenticodeSignature`, failing if the status is not `Valid` or the signature carries no timestamp +5. Re-uploads the signed binary as a GitHub Actions artifact (overwriting the unsigned one) + +Azure Artifact Signing issues **short-lived leaf certificates (~72 hours)**. The RFC-3161 countersignature is what keeps a published binary trusted after the leaf expires, which is why the verify step treats a missing timestamp as a hard failure rather than a warning. + +Signing runs on a Windows runner because the signing engine is Windows-only; it cannot be folded into the Linux build job. It also cannot reuse `sign-macos.yml`'s matrix -- the two use entirely different toolchains. + +Unsigned Windows binaries are blocked outright on machines running WDAC, Smart App Control, or AppLocker in enforcing mode. The block surfaces to users as `spawnSync ... UNKNOWN` from the npm wrapper shim ([`packages/cli/bin/clerk`](../packages/cli/bin/clerk)) and as Code Integrity event 3077 in the Windows event log. + +#### Required Secrets + +| Secret | Description | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `AZURE_TENANT_ID` | Entra tenant containing the signing account | +| `AZURE_CLIENT_ID` | App registration with a federated credential trusting this repository, and the `Trusted Signing Certificate Profile Signer` role on the account | +| `AZURE_SUBSCRIPTION_ID` | Subscription containing the Artifact Signing account | +| `AZURE_SIGNING_ENDPOINT` | Regional signing endpoint, e.g. `https://eus.codesigning.azure.net/` | +| `AZURE_SIGNING_ACCOUNT` | Artifact Signing account name | +| `AZURE_SIGNING_CERT_PROFILE` | Certificate profile name within that account | + +All six are declared `required: true`, so a release fails fast rather than silently shipping an unsigned `clerk.exe`. + +The calling job must grant `id-token: write`; the repository-level default is `contents: read`, so each of the three call sites in `release.yml` sets its own `permissions` block. + +**Local testing:** not practical -- signing requires a Windows host plus an Azure identity federated to this repository. Exercise it through a `!snapshot` run instead. + +### 4. Smoke Test Job (matrix) Downloads each compiled binary and runs `--version` to verify the binary actually executes. Smoke testing is handled by a reusable workflow (`.github/workflows/smoke-test.yml`) shared across stable, canary, and snapshot pipelines. Each caller passes a preset name (`stable`, `canary`, or `snapshot`); the reusable workflow resolves the preset to a target matrix internally. glibc targets run natively on a platform-matched GitHub-hosted runner; musl targets run inside an Alpine Docker container on a Linux runner. @@ -158,7 +191,7 @@ Not all targets have a native runner available. `win32-arm64` is published as be Publishing and GitHub Release upload are gated on all smoke tests passing. -### 4. Publish npm Job +### 5. Publish npm Job Runs the releaser script (`scripts/releaser.ts`) via `bun run release` (stable), `bun run release:canary` (canary), or `bun run release:snapshot` (snapshot): @@ -191,11 +224,11 @@ Publishing uses [npm OIDC trusted publishing](https://docs.npmjs.com/trusted-pub > **First publish**: New packages cannot use trusted publishing until they exist on npm. The very first stable release requires a one-time `NODE_AUTH_TOKEN` with a granular access token. After that, configure trusted publishers for all packages and remove the token. -### 5. Upload GitHub Assets Job +### 6. Upload GitHub Assets Job Attaches the compiled binaries to the GitHub Release for direct download. Binaries are uploaded with display names following the `clerk-` convention (e.g., `clerk-darwin-arm64`, `clerk-win32-x64.exe`). -### 6. Homebrew Job +### 7. Homebrew Job Creates `.tar.gz` archives of the 4 Homebrew-relevant binaries (darwin-arm64, darwin-x64, linux-arm64, linux-x64) downloaded directly from Actions artifacts, uploads them to the GitHub Release, computes SHA256 checksums, renders `Formula/clerk.rb`, and pushes the result to `clerk/homebrew-stable`. The push uses the `HOMEBREW_TAP_TOKEN` secret (a fine-grained PAT or GitHub App token with `contents: write` on `clerk/homebrew-stable`). @@ -228,6 +261,7 @@ Install: `brew install clerk/stable/clerk` | `.changeset/config.json` | Changesets configuration | | `.github/workflows/build-binaries.yml` | Reusable workflow for cross-compiling binaries (called by release + snapshot) | | `.github/workflows/sign-macos.yml` | Reusable workflow for macOS code signing and notarization | +| `.github/workflows/sign-windows.yml` | Reusable workflow for Windows Authenticode signing via Azure Artifact Signing | | `.github/workflows/smoke-test.yml` | Reusable workflow for smoke-testing binaries (called by release + snapshot) | | `.github/workflows/release.yml` | GitHub Actions release, canary, and snapshot workflow | @@ -305,4 +339,5 @@ If your change is internal-only (CI, tests, docs, refactoring), you can skip the - **Org membership check**: Snapshot releases require the commenter to be a `MEMBER` or `OWNER` of the repository's organization, verified via `author_association`. - **OIDC trusted publishing**: Publish jobs authenticate via GitHub's OIDC provider instead of stored npm tokens. This eliminates secret rotation, prevents token exfiltration, and scopes publish permissions to specific workflow files. - **macOS code signing and notarization**: macOS binaries are signed with a Developer ID Application certificate and notarized by Apple before publishing. Gatekeeper checks the notarization ticket online on first execution. +- **Windows Authenticode signing**: Windows binaries are Authenticode-signed and RFC-3161 timestamped via Azure Artifact Signing before publishing, so machines running WDAC, Smart App Control, or AppLocker can execute or allowlist them by publisher. - **CI build check**: Every PR to `main` runs a JS bundle build to catch bundler-specific failures before merge.