diff --git a/.github/workflows/actions/sign-files/action.yml b/.github/workflows/actions/sign-files/action.yml index cba83aa696b..635459574f9 100644 --- a/.github/workflows/actions/sign-files/action.yml +++ b/.github/workflows/actions/sign-files/action.yml @@ -4,8 +4,6 @@ inputs: paths: description: "Paths to sign" required: true - signtools-extra-args: - description: "Additionnal arguments to pass to signtool" outputs: cert_path: description: "certificate path" @@ -95,13 +93,13 @@ runs: } Write-Output "::endgroup::" - - name: Sign files with signtool + - name: Sign files with smctl shell: pwsh env: SM_CLIENT_CERT_FILE: ${{ steps.setup-cert.outputs.SM_CLIENT_CERT_FILE }} run: | Write-Output "::group::Check for required environment variables" - $requiredEnvVars = @('SM_HOST', 'SM_API_KEY', 'SM_CLIENT_CERT_FILE', 'SM_CLIENT_CERT_PASSWORD', 'CERT_FINGERPRINT') + $requiredEnvVars = @('SM_HOST', 'SM_API_KEY', 'SM_CLIENT_CERT_FILE', 'SM_CLIENT_CERT_PASSWORD', 'KEYPAIR_ALIAS') foreach ($envVar in $requiredEnvVars) { if (-not $(Get-Item -Path "Env:$envVar" -ErrorAction SilentlyContinue)) { Write-Output "::error title=Missing environment variable::Environment variable $envVar is not set." @@ -110,19 +108,16 @@ runs: Write-Output "All env var correctly set." } Write-Output "::endgroup::" - Write-Output "::group::Sync certificates" - smctl windows certsync - Write-Output "::endgroup::" # Sign each file that will be bundled in the installer $paths = "${{ inputs.paths }}" -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" } foreach ($path in $paths) { Write-Output "::group::Signing ${path}" - signtool.exe sign /sha1 $env:CERT_FINGERPRINT /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 ${{ inputs.signtools-extra-args }} $path + smctl sign --keypair-alias $env:KEYPAIR_ALIAS --input $path --digalg SHA256 --sigalg SHA256 --timestamp=true if ($LASTEXITCODE -ne 0) { Write-Output "::error title=Signing error::Error while signing ${path}" exit 1 } - signtool.exe verify /v /pa $path + smctl sign verify --input $path if ($LASTEXITCODE -ne 0) { Write-Output "::error title=Verify signature error::Error while verifying ${path}" exit 1 diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 04ebc42003e..f1e94a183b2 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -14,6 +14,16 @@ on: required: false type: boolean default: true + os: + description: "Which OS builds to run" + required: false + type: choice + options: + - all + - linux + - windows + - macos + default: all env: NFPM_VERSION: "2.43.1" @@ -43,6 +53,12 @@ jobs: if: ${{ inputs.publish-release }} uses: ./.github/workflows/actions/prevent-rerun + - name: Guard against partial-platform release publish + if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish-release && inputs.os != 'all' }} + run: | + echo "::error::Cannot publish a release when os is not 'all' (got '${{ inputs.os }}'). Set publish-release=false for a single-platform test run, or os=all to publish a full release." + exit 1 + # we _also_ need npm, specifically for webui/preview - uses: actions/setup-node@v6 with: @@ -124,6 +140,7 @@ jobs: make-tarball: runs-on: ubuntu-latest needs: [configure] + if: ${{ github.event_name != 'workflow_dispatch' || inputs.os == 'all' || inputs.os == 'linux' }} steps: - uses: actions/checkout@v6 with: @@ -161,6 +178,7 @@ jobs: make-arm64-tarball: runs-on: ubuntu-latest needs: [configure] + if: ${{ github.event_name != 'workflow_dispatch' || inputs.os == 'all' || inputs.os == 'linux' }} steps: - uses: actions/checkout@v6 with: @@ -248,6 +266,7 @@ jobs: make-installer-linux: runs-on: ubuntu-latest needs: [configure] + if: ${{ github.event_name != 'workflow_dispatch' || inputs.os == 'all' || inputs.os == 'linux' }} strategy: matrix: arch: [x86_64, aarch64] @@ -310,6 +329,7 @@ jobs: test-tarball-linux: runs-on: ubuntu-latest needs: [configure, make-tarball] + if: ${{ github.event_name != 'workflow_dispatch' || inputs.os == 'all' || inputs.os == 'linux' }} steps: - uses: actions/checkout@v6 with: @@ -350,6 +370,7 @@ jobs: make-installer-win: runs-on: windows-latest needs: [configure] + if: ${{ github.event_name != 'workflow_dispatch' || inputs.os == 'all' || inputs.os == 'windows' }} steps: - uses: actions/checkout@v6 @@ -389,12 +410,12 @@ jobs: ./package/pkg-working/bin/tools/pandoc.exe ./package/pkg-working/bin/quarto.js env: - # those environment variables are required to sign with signtool - SM_HOST: ${{ secrets.SM_HOST }} - SM_API_KEY: ${{ secrets.SM_API_KEY }} - SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }} - SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} - CERT_FINGERPRINT: ${{ secrets.SM_CLIENT_CERT_FINGERPRINT }} + # DigiCert STM 2026 credential set (signed via smctl sign --keypair-alias) + SM_HOST: ${{ secrets.SM_HOST_2026 }} + SM_API_KEY: ${{ secrets.SM_API_KEY_2026 }} + SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64_2026 }} + SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD_2026 }} + KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS_2026 }} - name: Make Installer run: | @@ -407,14 +428,13 @@ jobs: uses: ./.github/workflows/actions/sign-files with: paths: ./package/out/quarto-${{needs.configure.outputs.version}}-win.msi - signtools-extra-args: /d "Quarto CLI" env: - # those environment variables are required to sign with signtool - SM_HOST: ${{ secrets.SM_HOST }} - SM_API_KEY: ${{ secrets.SM_API_KEY }} - SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }} - SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} - CERT_FINGERPRINT: ${{ secrets.SM_CLIENT_CERT_FINGERPRINT }} + # DigiCert STM 2026 credential set (signed via smctl sign --keypair-alias) + SM_HOST: ${{ secrets.SM_HOST_2026 }} + SM_API_KEY: ${{ secrets.SM_API_KEY_2026 }} + SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64_2026 }} + SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD_2026 }} + KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS_2026 }} - name: Upload Artifact uses: actions/upload-artifact@v7 @@ -431,6 +451,7 @@ jobs: test-zip-win: runs-on: windows-latest needs: [configure, make-installer-win] + if: ${{ github.event_name != 'workflow_dispatch' || inputs.os == 'all' || inputs.os == 'windows' }} steps: - uses: actions/checkout@v6 with: @@ -479,6 +500,7 @@ jobs: make-installer-mac: runs-on: macos-latest needs: [configure] + if: ${{ github.event_name != 'workflow_dispatch' || inputs.os == 'all' || inputs.os == 'macos' }} steps: - uses: actions/checkout@v6 with: @@ -548,6 +570,7 @@ jobs: test-zip-mac: runs-on: macos-latest needs: [configure, make-installer-mac] + if: ${{ github.event_name != 'workflow_dispatch' || inputs.os == 'all' || inputs.os == 'macos' }} steps: - uses: actions/checkout@v6 with: diff --git a/llm-docs/code-signing-installers.md b/llm-docs/code-signing-installers.md index 642fe81b487..f0adaa82c38 100644 --- a/llm-docs/code-signing-installers.md +++ b/llm-docs/code-signing-installers.md @@ -1,6 +1,6 @@ --- -main_commit: a8d0dcfee -analyzed_date: 2026-05-19 +main_commit: c3e0f5cae +analyzed_date: 2026-07-24 key_files: - package/src/macos/installer.ts - package/src/windows/installer.ts @@ -170,17 +170,19 @@ Prevention: dispatch `create-release.yml` (publish-release=false) on the branch ### Cert provider -Posit signs via **DigiCert ONE / KeyLocker** (cloud-held EV cert). Local CLI is `smctl` (Software Trust Manager CLI) plus the standard `signtool.exe` from the Windows 10 SDK / App Certification Kit. The private key never leaves DigiCert's HSM — `smctl windows certsync` syncs the public cert into the local Windows cert store so `signtool` can reference it by thumbprint. +Posit signs via **DigiCert ONE / Software Trust Manager (STM)** (cloud-held EV cert). Local CLI is `smctl` (Software Trust Manager CLI). The private key never leaves DigiCert's HSM — `smctl sign --keypair-alias` talks to DigiCert directly to sign, with no local Windows cert-store sync needed. + +> **2026 credential rotation:** the cert active through mid-2026 used a SHA-1 fingerprint (`SM_CLIENT_CERT_FINGERPRINT` / `CERT_FINGERPRINT`) with `smctl windows certsync` + `signtool.exe`. The cert renewed for 2026 uses a keypair-alias model instead (`SM_KEYPAIR_ALIAS_2026` / `KEYPAIR_ALIAS`), signing directly via `smctl sign` with no `signtool.exe` invocation and no cert-store sync step. Secrets for the new cert carry a `_2026` suffix; the job's `env:` block maps them to the unsuffixed names below. Expect a similar suffix bump on each future annual renewal. ### Secrets → env vars → purpose -| Secret | Env var on sign step | Used for | -| ---------------------------- | ------------------------- | --------------------------------------------------------------------------------- | -| `SM_HOST` | `SM_HOST` | DigiCert tenant host URL | -| `SM_API_KEY` | `SM_API_KEY` | DigiCert API auth | -| `SM_CLIENT_CERT_FILE_B64` | `SM_CLIENT_CERT_FILE_B64` | Base64 of client auth PFX (decoded to `.build\certificates\codesign.pfx`) | -| `SM_CLIENT_CERT_PASSWORD` | `SM_CLIENT_CERT_PASSWORD` | PFX password | -| `SM_CLIENT_CERT_FINGERPRINT` | `CERT_FINGERPRINT` | SHA-1 thumbprint passed to `signtool /sha1` to select the cert in the local store | +| Secret (env var maps `_2026`-suffixed secret → unsuffixed name below) | Env var on sign step | Used for | +| --- | --- | --- | +| `SM_HOST_2026` | `SM_HOST` | DigiCert tenant host URL | +| `SM_API_KEY_2026` | `SM_API_KEY` | DigiCert API auth | +| `SM_CLIENT_CERT_FILE_B64_2026` | `SM_CLIENT_CERT_FILE_B64` | Base64 of client auth PFX (decoded to `.build\certificates\codesign.pfx`) | +| `SM_CLIENT_CERT_PASSWORD_2026` | `SM_CLIENT_CERT_PASSWORD` | PFX password | +| `SM_KEYPAIR_ALIAS_2026` | `KEYPAIR_ALIAS` | Keypair alias passed to `smctl sign --keypair-alias`, replaces the old cert-fingerprint selection | ### Signing flow — `.github/workflows/actions/sign-files` @@ -188,25 +190,27 @@ Composite action invoked **twice** per release in `make-installer-win`: 1. **Before MSI build** — sign every binary that will be bundled by WiX: `quarto.exe` (launcher), `deno.exe`, `esbuild.exe`, `dart.exe`, `deno_dom/plugin.dll`, `typst-gather.exe`, `pandoc.exe`, plus the `quarto.js` bundle. -2. **After MSI build** — sign the resulting `quarto--win.msi`, passing `signtools-extra-args: /d "Quarto CLI"` so the SmartScreen / UAC prompt shows a friendly description. +2. **After MSI build** — sign the resulting `quarto--win.msi`. `smctl sign` has no equivalent to signtool's `/d` description flag, so the MSI's SmartScreen / UAC description is whatever the MSI's own metadata provides, not an explicit signing-time string. Each invocation runs these steps (`.github/workflows/actions/sign-files/action.yml`): 1. **Setup client cert** — fail-fast if `SM_CLIENT_CERT_FILE_B64` missing; base64-decode into `.build\certificates\codesign.pfx` (cached across the second invocation). -2. **Install smctl** if absent — downloads `smtools-windows-x64.msi` from `rstudio-buildtools.s3.amazonaws.com` with retry-on-failure (transient S3 failures previously caused silent install failures); runs `msiexec /qn /log`; verifies `smctl.exe` is on disk; adds DigiCert install dir + Windows Kits App Certification Kit (signtool) to `PATH`. +2. **Install smctl** if absent — downloads `smtools-windows-x64.msi` from `rstudio-buildtools.s3.amazonaws.com` with retry-on-failure (transient S3 failures previously caused silent install failures); runs `msiexec /qn /log`; verifies `smctl.exe` is on disk. Also adds the Windows Kits App Certification Kit (`signtool.exe`) to `PATH` if missing — `smctl sign` delegates the actual Windows signing operation to `signtool.exe` under the hood, so this stays required even though nothing in the workflow invokes `signtool.exe` directly. 3. **Verify all required env vars** are set, else fail with `::error`. -4. **`smctl windows certsync`** — pull the EV cert from DigiCert into the local Windows cert store. -5. **For each path** in the multi-line `paths:` input: +4. **For each path** in the multi-line `paths:` input: ``` - signtool.exe sign /sha1 $CERT_FINGERPRINT \ - /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 \ - - signtool.exe verify /v /pa + smctl sign --keypair-alias $KEYPAIR_ALIAS --input \ + --digalg SHA256 --sigalg SHA256 --timestamp=true + smctl sign verify --input ``` - `/tr` adds an RFC 3161 timestamp (signature stays valid after cert expiry); `/fd SHA256` uses SHA-256 file digest; `/pa` verifies against the default authentication policy (the one SmartScreen uses). + `--digalg`/`--sigalg` are pinned explicitly because DigiCert's docs say they otherwise default to "whatever the underlying signing tool supports" rather than a fixed value. `--timestamp=true` keeps the signature valid after cert expiry (RFC 3161-style); DigiCert's own timestamp authority is used, without an explicit `--tsa-url` pin — that flag only records the TSA URL in signature metadata, it doesn't select which server is used. + + `smctl sign verify` shells out to `signtool verify /pa` internally (confirmed from live CI job logs, not just docs) — a verify failure surfaces as a `signtool` error, same as before the migration. Any single signing or verify failure fails the whole step. +**On the dropped `/d` description flag:** checked the pre-migration MSI (built with `signtool /d "Quarto CLI"`) via `certutil -dump`, `signtool verify /v /pa`, and Explorer's Digital Signatures > Details > Additional Information dialog — none of them surfaced the description text even though the flag was set. Removing it during the migration has no observed user-facing effect, since the field wasn't visibly rendering when present either. + ### Windows launcher `package/launcher/Cargo.toml` builds `quarto.exe` (Rust launcher) just before signing in `make-installer-win`. The Rust launcher is what the user actually runs; the underlying `quarto.cmd` / `deno` executes from inside the install dir. All of `quarto.exe`, `deno.exe`, and the bundled tools get signed individually so SmartScreen accepts the process tree, not just the MSI.