Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 4 additions & 9 deletions .github/workflows/actions/sign-files/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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."
Expand All @@ -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
Expand Down
49 changes: 36 additions & 13 deletions .github/workflows/create-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: |
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
42 changes: 23 additions & 19 deletions llm-docs/code-signing-installers.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -170,43 +170,47 @@ 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`

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-<v>-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-<v>-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 \
<extra-args> <path>
signtool.exe verify /v /pa <path>
smctl sign --keypair-alias $KEYPAIR_ALIAS --input <path> \
--digalg SHA256 --sigalg SHA256 --timestamp=true
smctl sign verify --input <path>
```
`/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.
Expand Down
Loading