Build and Authenticode-sign a Windows x64 desktop installer in desktop releases (#13607)

* feat(desktop): build and Authenticode-sign a Windows x64 NSIS installer in desktop releases

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* fix(desktop): pin OIDC-adjacent actions to commit SHAs in the Windows signing job

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* fix(desktop): pin checkout and upload-artifact to commit SHAs in the Windows signing job

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
This commit is contained in:
Saoud Rizwan
2026-08-26 23:07:50 -07:00
committed by GitHub
parent b4fd4ee0cd
commit 8981079a43
5 changed files with 444 additions and 17 deletions
+4 -4
View File
@@ -9,7 +9,7 @@ Use this skill when the user asks to release the desktop app, publish the Cline
> Working directory: run every command below from the repository root.
Desktop releases are macOS-only today (a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel) and are built entirely in GitHub Actions — there is no local publish path. Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user **on that channel**.
Desktop releases ship two platforms, built entirely in GitHub Actions — there is no local publish path. macOS: a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel. Windows: an Authenticode-signed NSIS installer (`<Product>_<version>_x64-setup.exe`), signed via Azure Trusted Signing in the `build-windows` job (jsign through Tauri's `signCommand`, see `apps/examples/desktop-app/scripts/tauri-sign-windows.ps1`; requires the repo-level `AZURE_*` secrets including `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP`, plus a `PublishDesktop`-environment federated credential on the `cline-cli-signing` Entra app). Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user **on that channel**.
## Release contract
@@ -21,7 +21,7 @@ Desktop releases are macOS-only today (a single signed + notarized universal DMG
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update — committed on `main` for stable, on `desktop-experimental` for beta.
- Publish path: `.github/workflows/desktop-publish.yml` (workflow_dispatch, requires the tag to exist, point at the checked-out commit, and be reachable from the channel's branch — `origin/main` for stable, `origin/desktop-experimental` for beta).
- **Both channels dispatch from `main`.** This is a security invariant, not a convenience: the run executes `main`'s workflow copy and only the checkout points at the tag, so the signing-secret gates (the `github.ref == main` check and the PublishDesktop environment's main-only deployment-branch policy) hold for beta too. Never add `desktop-experimental` to the PublishDesktop deployment-branch policy.
- The workflow creates the tag's GitHub release (universal DMG + updater artifact + `latest.json`; marked prerelease for beta) and refreshes the channel's rolling feed release, which is the static auto-update feed every installed app on that channel polls. Never delete the `desktop-latest` or `desktop-beta` release or tag.
- The workflow creates the tag's GitHub release (universal DMG + macOS updater artifact + Windows NSIS installer with its updater signature + `latest.json`; marked prerelease for beta) and refreshes the channel's rolling feed release, which is the static auto-update feed every installed app on that channel polls. Never delete the `desktop-latest` or `desktop-beta` release or tag.
- The changelog's `## <version>` section (exact-match, not "topmost") is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
- Always ask before pushing commits or tags.
@@ -120,7 +120,7 @@ gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments \
Nothing after `validate` runs — and no signing key is readable — until then.
The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the Bun sidecar is lipo'd by `build-sidecar-bin.ts`; beta adds the `tauri.beta.conf.json` overlay), verifies every Mach-O in the bundle carries both slices and that the compiled binary embeds exactly its own channel's feed URL, signs with the Developer ID certificate, notarizes with the App Store Connect API key, signs the updater artifact with the Tauri updater key, creates the GitHub release (prerelease for beta), refreshes the channel's feed (`desktop-latest/latest.json` or `desktop-beta/latest.json`), and posts to Slack. Notarization typically adds 210 minutes.
The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the Bun sidecar is lipo'd by `build-sidecar-bin.ts`; beta adds the `tauri.beta.conf.json` overlay), verifies every Mach-O in the bundle carries both slices and that the compiled binary embeds exactly its own channel's feed URL, signs with the Developer ID certificate, notarizes with the App Store Connect API key, and signs the updater artifact with the Tauri updater key. In parallel, `build-windows` builds the x64 NSIS installer on a Windows runner, Authenticode-signs every binary via Azure Trusted Signing (Tauri `signCommand` -> `scripts/tauri-sign-windows.ps1`), runs the same feed-endpoint and telemetry guardrails, and verifies the shipped installer with `Get-AuthenticodeSignature`. The release job then creates the GitHub release (prerelease for beta), refreshes the channel's feed (`desktop-latest/latest.json` or `desktop-beta/latest.json`), and posts to Slack. Notarization typically adds 210 minutes.
If the workflow fails on missing credentials, see "Publish secrets (one-time setup)" below.
@@ -131,7 +131,7 @@ curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.
curl -sL https://github.com/cline/cline/releases/download/desktop-beta/latest.json | head -30 # beta
```
The `version` field must be the new release and both `darwin-aarch64` and `darwin-x86_64` entries must point at the same new universal `.app.tar.gz` asset under the release tag (each slice of the fat binary requests its own arch key at runtime, so both keys serve the one artifact). Installed apps on that channel — including older per-arch installs — pick the update up on next launch or within 2 hours.
The `version` field must be the new release; both `darwin-aarch64` and `darwin-x86_64` entries must point at the same new universal `.app.tar.gz` asset under the release tag (each slice of the fat binary requests its own arch key at runtime, so both keys serve the one artifact), and the `windows-x86_64` entry must point at the new `*_x64-setup.exe` asset. Installed apps on that channel — including older per-arch installs — pick the update up on next launch or within 2 hours.
After a **beta** publish, also confirm the stable feed was not touched: `desktop-latest/latest.json` must still serve the previous stable version. (The workflow guards this fail-closed, but it is cheap to verify and catastrophic to miss — the updater comparator is a plain semver "newer than", so a beta manifest on `desktop-latest` would auto-update every stable install onto the beta.)
+274 -1
View File
@@ -462,9 +462,282 @@ jobs:
path: apps/examples/desktop-app/dist/publish/*
if-no-files-found: error
build-windows:
name: Build Windows (x64)
needs: validate
# Same gate rationale as the macOS build job above. This job additionally
# needs id-token: write for Azure OIDC: Windows binaries are
# Authenticode-signed with Azure Trusted Signing, authenticated through the
# PublishDesktop-environment federated credential on the cline-cli-signing
# Entra app (subject repo:cline/cline:environment:PublishDesktop).
if: github.ref == 'refs/heads/main'
environment: PublishDesktop
runs-on: windows-latest
timeout-minutes: 90
permissions:
contents: read
id-token: write
steps:
# All-or-nothing: an unsigned Windows desktop build is never acceptable
# (Smart App Control / WDAC block unsigned exes and SmartScreen flags
# unsigned installers), and Tauri would skip updater-artifact signing
# silently if the updater key were missing. Unlike the CLI pipeline
# there is no unsigned fallback here.
- name: Verify signing secrets are present
shell: bash
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
missing=()
for name in AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID \
AZURE_TRUSTED_SIGNING_ENDPOINT AZURE_TRUSTED_SIGNING_ACCOUNT_NAME \
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP \
TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
[ -n "${!name}" ] || missing+=("$name")
done
if [ ${#missing[@]} -gt 0 ]; then
echo "Missing signing secrets for the Windows desktop build:"
printf ' - %s\n' "${missing[@]}"
echo
echo "The AZURE_* names are repository secrets; the TAURI_* names"
echo "live in the PublishDesktop environment. Refusing to build an"
echo "unsigned Windows desktop release."
exit 1
fi
echo "All Windows signing secrets are present."
# Every action in this job is SHA-pinned (unlike elsewhere in this
# file): they run with id-token: write and the updater signing key in
# scope, so a hijacked upstream tag must not be able to reach the
# signing identity or tamper with what gets signed and uploaded.
- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ needs.validate.outputs.tag }}
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.3.13"
- name: Setup Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable branch
with:
# With a SHA-pinned action the toolchain no longer comes from the
# ref name, so it must be set explicitly.
toolchain: stable
# No Rust build cache, mirroring the macOS job: this job holds the
# updater signing key and an Azure signing session, and a restored cache
# archive is attacker-controlled if the Actions cache is poisoned.
- name: Install dependencies
run: bun install
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build SDK packages
run: bun run build:sdk
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
- name: Azure login (OIDC)
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# Tauri invokes signCommand once per staged binary (main exe, sidecar,
# NSIS uninstaller, and the installer itself). The overlay is generated
# here rather than committed because signCommand needs an absolute path
# to the signing script on this runner.
- name: Write signing config overlay
shell: bash
run: |
SCRIPT_PATH="${GITHUB_WORKSPACE//\\//}/apps/examples/desktop-app/scripts/tauri-sign-windows.ps1"
SIGN_CONF="${RUNNER_TEMP//\\//}/tauri-windows-sign.conf.json"
cat > "$SIGN_CONF" <<EOF
{
"\$schema": "https://schema.tauri.app/config/2",
"bundle": {
"windows": {
"signCommand": "pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ${SCRIPT_PATH} %1"
}
}
}
EOF
cat "$SIGN_CONF"
echo "SIGN_CONF=${SIGN_CONF}" >> "$GITHUB_ENV"
- name: Build and sign desktop bundle
shell: bash
working-directory: apps/examples/desktop-app
# NSIS only: the MSI (WiX) target adds nothing for direct-download
# distribution and the updater uses the NSIS artifact. $CONFIG_ARGS is
# deliberately unquoted: it must word-split into separate flags.
run: bunx tauri build --bundles nsis $CONFIG_ARGS --config "$SIGN_CONF"
env:
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
# Telemetry inlined into the sidecar at compile time, same as macOS.
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
# Authenticode signing via scripts/tauri-sign-windows.ps1 (jsign +
# Azure Trusted Signing; the token comes from the azure/login session)
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP }}
# Updater artifact signing (minisign keypair, same key as macOS)
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# Same guardrail as the macOS job: assert the compiled binary embeds
# this channel's updater feed URL and not the other channel's. Checked
# on the unbundled main exe because NSIS compresses the installer
# contents, which defeats a string search on the installer itself.
- name: Verify updater feed endpoint
shell: bash
working-directory: apps/examples/desktop-app
env:
CHANNEL: ${{ needs.validate.outputs.channel }}
run: |
case "$CHANNEL" in
stable)
WANT="releases/download/desktop-latest/latest.json"
FORBID="releases/download/desktop-beta/latest.json"
;;
beta)
WANT="releases/download/desktop-beta/latest.json"
FORBID="releases/download/desktop-latest/latest.json"
;;
*)
echo "unknown channel: ${CHANNEL}"
exit 1
;;
esac
found=0
for bin in src-tauri/target/release/*.exe; do
if grep -a "$FORBID" "$bin" >/dev/null; then
echo "$bin embeds the other channel's feed URL (${FORBID})"
exit 1
fi
if grep -a "$WANT" "$bin" >/dev/null; then
found=1
fi
done
if [ "$found" -ne 1 ]; then
echo "No exe in src-tauri/target/release embeds ${WANT}."
echo "The updater endpoint overlay did not apply; check the"
echo "--config flags on the build step and tauri.beta.conf.json."
exit 1
fi
echo "Updater endpoint verified: ${WANT}"
# Same guardrail as the macOS job, run natively on the Windows sidecar.
- name: Verify sidecar telemetry config was inlined
shell: bash
working-directory: apps/examples/desktop-app
run: |
SELFCHECK=$(./src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe --telemetry-selfcheck)
echo "$SELFCHECK"
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
echo "Packaged sidecar reports telemetry disabled."
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
echo "'Build and sign desktop bundle' step and the --define"
echo "inlining in scripts/build-sidecar-bin.ts."
exit 1
fi
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
echo "Packaged sidecar reports telemetry enabled but its OTLP"
echo "endpoint is missing, unparseable, or not an http(s) URL."
echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret."
exit 1
fi
- name: Collect artifacts
shell: bash
working-directory: apps/examples/desktop-app
env:
VERSION: ${{ needs.validate.outputs.version }}
PRODUCT: ${{ needs.validate.outputs.product }}
run: |
BUNDLE_DIR="src-tauri/target/release/bundle"
OUT="dist/publish"
mkdir -p "$OUT"
# "Cline" -> Cline, "Cline Beta" -> Cline-Beta
PREFIX="${PRODUCT// /-}"
SETUP=$(find "$BUNDLE_DIR/nsis" -name '*-setup.exe' -print -quit)
if [ -z "$SETUP" ]; then
echo "no NSIS installer produced under $BUNDLE_DIR/nsis"
exit 1
fi
# The .sig is the updater (minisign) signature; without it the
# manifest generator cannot publish a windows-x86_64 entry.
if [ ! -f "${SETUP}.sig" ]; then
echo "updater signature missing next to $SETUP"
exit 1
fi
cp "$SETUP" "$OUT/${PREFIX}_${VERSION}_x64-setup.exe"
cp "${SETUP}.sig" "$OUT/${PREFIX}_${VERSION}_x64-setup.exe.sig"
ls -lh "$OUT"
# Independent Authenticode gate on the exact artifact users download.
# The signing script already verifies each file it signs, but this step
# would still catch an installer that skipped signCommand entirely.
- name: Verify Authenticode signatures
shell: pwsh
working-directory: apps/examples/desktop-app
run: |
# The Tauri bundler signs the sidecar in place, so check it here too;
# a WDAC-locked machine blocks the app at runtime if the sidecar it
# spawns is unsigned, even when the installer itself is fine.
$files = @(Get-ChildItem dist/publish/*.exe) + @(Get-Item src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe)
if ($files.Count -lt 2) { throw "expected at least the installer and the sidecar to verify" }
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature $file.FullName
if ($sig.Status -ne "Valid") {
throw "Invalid Authenticode signature for $($file.Name): $($sig.Status) - $($sig.StatusMessage)"
}
Write-Host "$($file.Name): Valid ($($sig.SignerCertificate.Subject))"
}
- name: Upload artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-windows-x64
path: apps/examples/desktop-app/dist/publish/*
if-no-files-found: error
release:
name: Create GitHub release
needs: [validate, build]
needs: [validate, build, build-windows]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -77,6 +77,66 @@ describe("buildUpdateManifest", () => {
expect(Object.keys(manifest.platforms)).toHaveLength(2);
});
test("maps a Windows NSIS setup artifact to windows-x86_64", () => {
const dir = makeUniversalArtifactDir();
writeFileSync(path.join(dir, "Cline-Code_0.1.0_x64-setup.exe"), "nsis");
writeFileSync(
path.join(dir, "Cline-Code_0.1.0_x64-setup.exe.sig"),
"sig-windows-x64\n",
);
const manifest = buildUpdateManifest({
version: "0.1.0",
tag: "desktop-v0.1.0",
dir,
repo: "cline/cline",
notes: "notes",
pubDate: "2026-07-21T00:00:00.000Z",
});
expect(manifest.platforms["windows-x86_64"]).toEqual({
signature: "sig-windows-x64",
url: "https://github.com/cline/cline/releases/download/desktop-v0.1.0/Cline-Code_0.1.0_x64-setup.exe",
});
// darwin entries from the universal artifact are unaffected.
expect(Object.keys(manifest.platforms).sort()).toEqual([
"darwin-aarch64",
"darwin-x86_64",
"windows-x86_64",
]);
});
test("ignores non-updater exe files without a setup arch suffix", () => {
const dir = makeUniversalArtifactDir();
writeFileSync(path.join(dir, "Cline-Code_0.1.0_x64.exe"), "exe");
const manifest = buildUpdateManifest({
version: "0.1.0",
tag: "desktop-v0.1.0",
dir,
repo: "cline/cline",
notes: "notes",
pubDate: "2026-07-21T00:00:00.000Z",
});
expect(Object.keys(manifest.platforms).sort()).toEqual([
"darwin-aarch64",
"darwin-x86_64",
]);
});
test("throws when a Windows setup artifact is missing its signature", () => {
const dir = makeUniversalArtifactDir();
writeFileSync(path.join(dir, "Cline-Code_0.1.0_x64-setup.exe"), "nsis");
expect(() =>
buildUpdateManifest({
version: "0.1.0",
tag: "desktop-v0.1.0",
dir,
repo: "cline/cline",
notes: "notes",
pubDate: "2026-07-21T00:00:00.000Z",
}),
).toThrow();
});
test("throws when universal and per-arch artifacts claim the same platform", () => {
const dir = makePerArchArtifactDir();
writeFileSync(
@@ -24,17 +24,25 @@ export type UpdateManifest = {
platforms: Record<string, UpdaterPlatformEntry>;
};
// Maps the arch token embedded in artifact file names (see the "Collect
// Maps the arch token embedded in macOS artifact file names (see the "Collect
// artifacts" workflow step) to the platform keys the Tauri updater requests.
// A universal (fat) bundle serves both macOS architectures: each slice of the
// installed app requests its own compile-time arch key at runtime, and both
// keys point at the same artifact and signature.
const PLATFORM_KEYS_BY_ARCH_SUFFIX: Record<string, string[]> = {
const MACOS_PLATFORM_KEYS_BY_ARCH_SUFFIX: Record<string, string[]> = {
aarch64: ["darwin-aarch64"],
x86_64: ["darwin-x86_64"],
universal: ["darwin-aarch64", "darwin-x86_64"],
};
// On Windows the updater artifact is the NSIS installer itself
// (createUpdaterArtifacts signs the setup exe with the updater key), named
// `<Product>_<version>_<arch>-setup.exe` by the Tauri bundler.
const WINDOWS_PLATFORM_KEYS_BY_ARCH_SUFFIX: Record<string, string[]> = {
x64: ["windows-x86_64"],
arm64: ["windows-aarch64"],
};
const getArgValue = (args: string[], name: string): string | undefined => {
const index = args.indexOf(name);
if (index >= 0 && args[index + 1] && !args[index + 1].startsWith("--")) {
@@ -45,13 +53,22 @@ const getArgValue = (args: string[], name: string): string | undefined => {
return inline?.slice(prefix.length);
};
const archOfUpdaterArtifact = (fileName: string): string | undefined => {
if (!fileName.endsWith(".app.tar.gz")) {
return undefined;
const platformKeysOfUpdaterArtifact = (
fileName: string,
): string[] | undefined => {
if (fileName.endsWith(".app.tar.gz")) {
const arch = Object.keys(MACOS_PLATFORM_KEYS_BY_ARCH_SUFFIX).find(
(candidate) => fileName.includes(`_${candidate}`),
);
return arch ? MACOS_PLATFORM_KEYS_BY_ARCH_SUFFIX[arch] : undefined;
}
return Object.keys(PLATFORM_KEYS_BY_ARCH_SUFFIX).find((arch) =>
fileName.includes(`_${arch}`),
);
if (fileName.endsWith("-setup.exe")) {
const arch = Object.keys(WINDOWS_PLATFORM_KEYS_BY_ARCH_SUFFIX).find(
(candidate) => fileName.endsWith(`_${candidate}-setup.exe`),
);
return arch ? WINDOWS_PLATFORM_KEYS_BY_ARCH_SUFFIX[arch] : undefined;
}
return undefined;
};
export const buildUpdateManifest = (options: {
@@ -65,8 +82,8 @@ export const buildUpdateManifest = (options: {
const platforms: Record<string, UpdaterPlatformEntry> = {};
for (const fileName of readdirSync(options.dir).sort()) {
const arch = archOfUpdaterArtifact(fileName);
if (!arch) {
const platformKeys = platformKeysOfUpdaterArtifact(fileName);
if (!platformKeys) {
continue;
}
const signaturePath = path.join(options.dir, `${fileName}.sig`);
@@ -74,7 +91,7 @@ export const buildUpdateManifest = (options: {
if (!signature) {
throw new Error(`empty updater signature at ${signaturePath}`);
}
for (const platformKey of PLATFORM_KEYS_BY_ARCH_SUFFIX[arch]) {
for (const platformKey of platformKeys) {
if (platforms[platformKey]) {
throw new Error(
`multiple updater artifacts claim platform ${platformKey}; found ${fileName} after ${platforms[platformKey].url}`,
@@ -89,7 +106,7 @@ export const buildUpdateManifest = (options: {
if (Object.keys(platforms).length === 0) {
throw new Error(
`no updater artifacts (*.app.tar.gz with a known arch suffix) found in ${options.dir}`,
`no updater artifacts (*.app.tar.gz or *-setup.exe with a known arch suffix) found in ${options.dir}`,
);
}
@@ -0,0 +1,77 @@
# Authenticode-signs one PE file with Azure Trusted Signing via jsign.
#
# Invoked by the Tauri bundler through `bundle > windows > signCommand` (the
# desktop-publish workflow generates a config overlay pointing here), once per
# binary it stages: the main app exe, the code-sidecar external binary, the
# NSIS uninstaller, and the NSIS installer itself.
#
# Requirements (all provided by the desktop-publish Windows job):
# - an azure/login OIDC session (jsign's token comes from `az account get-access-token`)
# - AZURE_TRUSTED_SIGNING_ENDPOINT / _ACCOUNT_NAME / _CERTIFICATE_PROFILE env vars
# - java on PATH (preinstalled on GitHub Windows runners)
#
# Mirrors the CLI pipeline (.github/actions/sign-windows-cli): same jsign
# version and flags, same Microsoft timestamp service. Kept as a standalone
# script so the signing behavior is reviewable in the repo rather than inlined
# in a generated config string.
param(
[Parameter(Mandatory = $true, Position = 0)]
[string] $Path
)
$ErrorActionPreference = "Stop"
$jsignVersion = "7.5"
$jsignSha256 = "602A51C3545A6DC4FB99BD2EA7152B26D1345916D0C93DDFBD5936CB735AF91C"
$endpoint = $env:AZURE_TRUSTED_SIGNING_ENDPOINT
$account = $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME
$certProfile = $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE
if (-not $endpoint -or -not $account -or -not $certProfile) {
throw "Azure Trusted Signing env vars are not set (AZURE_TRUSTED_SIGNING_ENDPOINT/_ACCOUNT_NAME/_CERTIFICATE_PROFILE)"
}
$resolved = (Resolve-Path $Path).Path
# jsign expects the endpoint host; tolerate the portal's trailing-slash form.
$keystore = $endpoint -replace '^https://', '' -replace '/$', ''
$jar = Join-Path $env:RUNNER_TEMP "jsign-$jsignVersion.jar"
if (-not (Test-Path $jar)) {
Invoke-WebRequest -Uri "https://github.com/ebourg/jsign/releases/download/$jsignVersion/jsign-$jsignVersion.jar" -OutFile $jar
}
$actualHash = (Get-FileHash -Algorithm SHA256 $jar).Hash
if ($actualHash -ne $jsignSha256) {
Remove-Item $jar -Force
throw "jsign jar checksum mismatch: expected $jsignSha256, got $actualHash"
}
# Short-lived bearer token from the azure/login OIDC session. Fetched per
# invocation (signCommand runs once per file) so a long Rust build beforehand
# can never leave us with an expired token. Passed to jsign via env, not argv.
$env:JSIGN_STOREPASS = (az account get-access-token --resource https://codesigning.azure.net --query accessToken --output tsv)
if (-not $env:JSIGN_STOREPASS) {
throw "failed to acquire an Azure access token; is azure/login configured on this job?"
}
Write-Host "Signing $resolved"
java -jar $jar `
--storetype TRUSTEDSIGNING `
--keystore $keystore `
--storepass env:JSIGN_STOREPASS `
--alias "$account/$certProfile" `
--alg SHA-256 `
--tsaurl http://timestamp.acs.microsoft.com `
--tsmode RFC3161 `
--replace `
$resolved
if ($LASTEXITCODE -ne 0) {
throw "jsign failed for $resolved (exit $LASTEXITCODE)"
}
$signature = Get-AuthenticodeSignature $resolved
if ($signature.Status -ne "Valid") {
throw "signature verification failed for ${resolved}: $($signature.Status) - $($signature.StatusMessage)"
}
Write-Host "Signed and verified: $resolved ($($signature.SignerCertificate.Subject))"