From c3de0916281eb994756c720c4b66b0516d97a9c5 Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Tue, 7 Apr 2026 15:06:26 +1000 Subject: [PATCH 01/11] Add Azure Trusted Signing utilities Shared config and signing hook for Azure Trusted Signing, ported from the `test-artifact-signing` branch. `azure-signing.cjs` builds signtool args and validates env vars. `azure-sign-hook.js` is the custom `@electron/windows-sign` hook that calls signtool with SHA256-only params (Azure doesn't support the default SHA1+SHA256 dual-sign). --- Generated with the help of Claude Code, https://claude.ai/code Co-Authored-By: Claude Code Opus 4.6 --- scripts/azure-sign-hook.js | 28 ++++++++++++++++++ scripts/azure-signing.cjs | 59 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 scripts/azure-sign-hook.js create mode 100644 scripts/azure-signing.cjs diff --git a/scripts/azure-sign-hook.js b/scripts/azure-sign-hook.js new file mode 100644 index 0000000000..2e1ce9620f --- /dev/null +++ b/scripts/azure-sign-hook.js @@ -0,0 +1,28 @@ +// Custom signing hook for @electron/windows-sign. +// Azure Trusted Signing only supports SHA256; the default dual-sign +// (sha1 + sha256) fails because there's no local cert for sha1. +// This hook calls signtool directly with SHA256-only parameters. + +/* eslint-disable @typescript-eslint/no-require-imports */ + +const { execFileSync } = require( 'child_process' ); +const path = require( 'path' ); +const { + assertAzureSigningEnv, + getAzureSignArgs, + getAzureSigningConfig, +} = require( './azure-signing.cjs' ); + +// @electron/windows-sign includes .ps1 shim scripts from node_modules/.bin +// in its file list. These are text files that don't need code signing. +const SKIP_EXTENSIONS = new Set( [ '.ps1', '.vbs', '.wsf' ] ); + +module.exports = function ( fileToSign ) { + if ( SKIP_EXTENSIONS.has( path.extname( fileToSign ).toLowerCase() ) ) { + return; + } + assertAzureSigningEnv(); + const { signtoolPath } = getAzureSigningConfig(); + + execFileSync( signtoolPath, getAzureSignArgs( fileToSign ), { stdio: 'inherit' } ); +}; diff --git a/scripts/azure-signing.cjs b/scripts/azure-signing.cjs new file mode 100644 index 0000000000..4476e79bba --- /dev/null +++ b/scripts/azure-signing.cjs @@ -0,0 +1,59 @@ +const REQUIRED_ENV_VARS = [ + 'AZURE_CODE_SIGNING_DLIB', + 'AZURE_METADATA_JSON', + 'SIGNTOOL_PATH' +]; + +const DEFAULTS = { + fileDigestAlgorithm: 'SHA256', + timestampDigestAlgorithm: 'SHA256', + timestampServer: 'http://timestamp.acs.microsoft.com', +}; + +function getAzureSigningConfig() { + return { + signtoolPath: process.env.SIGNTOOL_PATH, + dlib: process.env.AZURE_CODE_SIGNING_DLIB, + metadata: process.env.AZURE_METADATA_JSON, + fileDigestAlgorithm: process.env.AZURE_FILE_DIGEST || DEFAULTS.fileDigestAlgorithm, + timestampDigestAlgorithm: + process.env.AZURE_TIMESTAMP_DIGEST || DEFAULTS.timestampDigestAlgorithm, + timestampServer: process.env.AZURE_TIMESTAMP_SERVER || DEFAULTS.timestampServer, + }; +} + +function assertAzureSigningEnv() { + for ( const envVar of REQUIRED_ENV_VARS ) { + if ( ! process.env[ envVar ] ) { + throw new Error( `Required env var ${ envVar } is not set!` ); + } + } +} + +function getAzureSignArgs( fileToSign, extraArgs = [] ) { + const { dlib, metadata, fileDigestAlgorithm, timestampDigestAlgorithm, timestampServer } = + getAzureSigningConfig(); + + return [ + 'sign', + '/v', + ...extraArgs, + '/fd', + fileDigestAlgorithm, + '/tr', + timestampServer, + '/td', + timestampDigestAlgorithm, + '/dlib', + dlib, + '/dmdf', + metadata, + fileToSign, + ]; +} + +module.exports = { + assertAzureSigningEnv, + getAzureSignArgs, + getAzureSigningConfig, +}; From 5be42af8b050ae076fa99c6f8ee95ed927da9842 Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Tue, 7 Apr 2026 15:07:29 +1000 Subject: [PATCH 02/11] Add windowsSign toggle module Returns Azure signing hook config when `USE_AZURE_TRUSTED_SIGNING` is set, `undefined` otherwise so Forge falls back to PFX certs. Throws if the toggle is on but Azure env vars are missing. --- Generated with the help of Claude Code, https://claude.ai/code Co-Authored-By: Claude Code Opus 4.6 --- apps/studio/windowsSign.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 apps/studio/windowsSign.ts diff --git a/apps/studio/windowsSign.ts b/apps/studio/windowsSign.ts new file mode 100644 index 0000000000..7620c7944b --- /dev/null +++ b/apps/studio/windowsSign.ts @@ -0,0 +1,32 @@ +import path from 'path'; +import type { WindowsSignOptions } from '@electron/packager'; + +// Azure Trusted Signing configuration for Windows code signing. +// +// Uses a custom hook module because the default @electron/windows-sign +// dual-signs (SHA1 + SHA256), but Azure only supports SHA256. +// The hook calls signtool directly with SHA256-only parameters. +// +// Controlled by the USE_AZURE_TRUSTED_SIGNING env var: +// - Unset/falsy: returns undefined, letting Forge use PFX certificate signing. +// - Set: returns the Azure signing hook config, or throws if the +// required Azure env vars are missing. +function getWindowsSign(): WindowsSignOptions | undefined { + if ( ! process.env.USE_AZURE_TRUSTED_SIGNING ) { + return undefined; + } + + if ( ! process.env.AZURE_CODE_SIGNING_DLIB || ! process.env.AZURE_METADATA_JSON ) { + throw new Error( + 'USE_AZURE_TRUSTED_SIGNING is set but Azure signing env vars ' + + '(AZURE_CODE_SIGNING_DLIB, AZURE_METADATA_JSON) are missing. ' + + 'Did setup_azure_trusted_signing.ps1 run?' + ); + } + + return { + hookModulePath: path.resolve( __dirname, '..', '..', 'scripts', 'azure-sign-hook.js' ), + }; +} + +export const windowsSign = getWindowsSign(); From bfd68f1744107fda71f36d707d5467bf495cb0cc Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Tue, 7 Apr 2026 15:07:51 +1000 Subject: [PATCH 03/11] Wire windowsSign into Forge config Adds `windowsSign` to `packagerConfig` and `MakerSquirrel`. When defined (Azure mode), uses the signing hook. When undefined (PFX mode), uses `certificateFile`/`certificatePassword`. --- Generated with the help of Claude Code, https://claude.ai/code Co-Authored-By: Claude Code Opus 4.6 --- apps/studio/forge.config.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/studio/forge.config.ts b/apps/studio/forge.config.ts index 8905b00211..3e5319275f 100644 --- a/apps/studio/forge.config.ts +++ b/apps/studio/forge.config.ts @@ -9,6 +9,7 @@ import { isErrnoException } from '@studio/common/lib/is-errno-exception'; import { exec } from 'child_process'; import { exec as pkgExec } from '@yao-pkg/pkg'; import type { ForgeConfig } from '@electron-forge/shared-types'; +import { windowsSign } from './windowsSign'; const repoRoot = path.resolve( __dirname, '../..' ); @@ -22,6 +23,7 @@ const config: ForgeConfig = { ], executableName: process.platform === 'linux' ? 'studio' : undefined, icon: path.join( __dirname, 'assets', 'studio-app-icon' ), + windowsSign, osxSign: { optionsForFile: ( filePath ) => { // The bundled Node binary requires specific entitlements for V8 JIT compilation. @@ -95,9 +97,16 @@ const config: ForgeConfig = { setupExe: 'studio-setup.exe', - // CI code-signing setup writes certificate.pfx at the repository root. - certificateFile: path.join( repoRoot, 'certificate.pfx' ), - certificatePassword: process.env.WINDOWS_CODE_SIGNING_CERT_PASSWORD, + // Azure mode: use the custom signing hook that calls signtool + // with Azure Trusted Signing parameters. + // PFX mode: use the local certificate file and password. + ...( windowsSign + ? { windowsSign } + : { + certificateFile: path.join( repoRoot, 'certificate.pfx' ), + certificatePassword: process.env.WINDOWS_CODE_SIGNING_CERT_PASSWORD, + } + ), }, [ 'win32' ] ), From a46540868c2ba7d8f6a7e3eaaf3ccabf97acbcf8 Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Tue, 7 Apr 2026 15:08:09 +1000 Subject: [PATCH 04/11] Toggle signing setup in CI build script Conditionally calls `setup_azure_trusted_signing.ps1` or `setup_windows_code_signing.ps1` based on `USE_AZURE_TRUSTED_SIGNING`. --- Generated with the help of Claude Code, https://claude.ai/code Co-Authored-By: Claude Code Opus 4.6 --- .buildkite/commands/build-for-windows.ps1 | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.buildkite/commands/build-for-windows.ps1 b/.buildkite/commands/build-for-windows.ps1 index 82eff5b681..a8580a3863 100644 --- a/.buildkite/commands/build-for-windows.ps1 +++ b/.buildkite/commands/build-for-windows.ps1 @@ -28,9 +28,16 @@ if ($Architecture -notin $VALID_ARCHITECTURES) { Exit 1 } -# setup_windows_code_signing.ps1 comes from CI Toolkit Plugin -& "setup_windows_code_signing.ps1" -If ($LastExitCode -ne 0) { Exit $LastExitCode } +If ($env:USE_AZURE_TRUSTED_SIGNING) { + Write-Host "--- :lock: Setting up Azure Trusted Signing" + $setupScript = (Get-Command setup_azure_trusted_signing.ps1 -ErrorAction Stop).Source + & $setupScript + If ($LastExitCode -ne 0) { Exit $LastExitCode } +} Else { + # setup_windows_code_signing.ps1 comes from CI Toolkit Plugin + & "setup_windows_code_signing.ps1" + If ($LastExitCode -ne 0) { Exit $LastExitCode } +} Write-Host "--- :npm: Installing Node dependencies" bash .buildkite/commands/install-node-dependencies.sh From 939c1477f395e094c59c0fd1285718e6bdc97779 Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Tue, 7 Apr 2026 15:09:35 +1000 Subject: [PATCH 05/11] Toggle signing in AppX packaging When `USE_AZURE_TRUSTED_SIGNING` is set, builds an unsigned sideload AppX then signs it with Azure signtool. Otherwise uses the existing PFX certificate path. --- Generated with the help of Claude Code, https://claude.ai/code Co-Authored-By: Claude Code Opus 4.6 --- scripts/package-appx.mjs | 77 +++++++++++++++++++++++++++++++++------- 1 file changed, 64 insertions(+), 13 deletions(-) diff --git a/scripts/package-appx.mjs b/scripts/package-appx.mjs index 0c6cd38ad1..113e497f73 100644 --- a/scripts/package-appx.mjs +++ b/scripts/package-appx.mjs @@ -1,15 +1,30 @@ +import { execFileSync } from 'child_process'; import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; import convertToWindowsStore from 'electron2appx'; import packageJson from '../apps/studio/package.json' with { type: 'json' }; +const useAzureSigning = Boolean( process.env.USE_AZURE_TRUSTED_SIGNING ); + console.log( '--- :electron: Packaging AppX' ); -console.log( '~~~ Verifying WINDOWS_CODE_SIGNING_CERT_PASSWORD env var...' ); -if ( ! process.env.WINDOWS_CODE_SIGNING_CERT_PASSWORD ) { - console.error( 'Required env var WINDOWS_CODE_SIGNING_CERT_PASSWORD is not set!' ); - process.exit( 1 ); +if ( useAzureSigning ) { + const azureSigning = await import( './azure-signing.cjs' ); + const { assertAzureSigningEnv } = azureSigning.default; + console.log( '~~~ Verifying Azure Trusted Signing env vars...' ); + try { + assertAzureSigningEnv(); + } catch ( error ) { + console.error( error instanceof Error ? error.message : error ); + process.exit( 1 ); + } +} else { + console.log( '~~~ Verifying WINDOWS_CODE_SIGNING_CERT_PASSWORD env var...' ); + if ( ! process.env.WINDOWS_CODE_SIGNING_CERT_PASSWORD ) { + console.error( 'Required env var WINDOWS_CODE_SIGNING_CERT_PASSWORD is not set!' ); + process.exit( 1 ); + } } const __dirname = path.dirname( fileURLToPath( import.meta.url ) ); @@ -167,14 +182,50 @@ await convertToWindowsStore( { outputDirectory: appxOutputPathUnsigned, } ); -// Create signed AppX +// Create sideload AppX — signing method depends on USE_AZURE_TRUSTED_SIGNING. const appxOutputPathSigned = path.resolve( outPath, `${ appxName }-${ architecture }-signed` ); -console.log( `~~~ Creating signed .appx for local testing at ${ appxOutputPathSigned }...` ); +console.log( `~~~ Creating .appx for sideloading at ${ appxOutputPathSigned }...` ); + +if ( useAzureSigning ) { + const sideloadPublisher = + 'CN=Automattic Inc., O=Automattic Inc., L=San Francisco, S=California, C=US'; + + // Build unsigned, then sign with Azure Trusted Signing via signtool. + await convertToWindowsStore( { + ...sharedOptions, + publisher: sideloadPublisher, + devCert: 'nil', + outputDirectory: appxOutputPathSigned, + } ); + + console.log( '~~~ Signing sideload .appx with Azure Trusted Signing...' ); + const azureSigning = await import( './azure-signing.cjs' ); + const { getAzureSignArgs, getAzureSigningConfig } = azureSigning.default; + const appxFiles = ( await fs.readdir( appxOutputPathSigned ) ).filter( ( f ) => + f.endsWith( '.appx' ) + ); + if ( appxFiles.length === 0 ) { + console.error( 'No .appx file found to sign!' ); + process.exit( 1 ); + } -await convertToWindowsStore( { - ...sharedOptions, - publisher: 'CN="Automattic, Inc.", O="Automattic, Inc.", S=California, C=US', - devCert: 'certificate.pfx', - certPass: process.env.WINDOWS_CODE_SIGNING_CERT_PASSWORD, - outputDirectory: appxOutputPathSigned, -} ); + for ( const appxFile of appxFiles ) { + const appxPath = path.join( appxOutputPathSigned, appxFile ); + console.log( `Signing ${ appxPath }...` ); + const { signtoolPath } = getAzureSigningConfig(); + execFileSync( signtoolPath, getAzureSignArgs( appxPath, [ '/debug' ] ), { + stdio: 'inherit', + } ); + console.log( `Signed ${ appxFile } successfully.` ); + } +} else { + // PFX certificate signing + await convertToWindowsStore( { + ...sharedOptions, + publisher: + 'CN="Automattic, Inc.", O="Automattic, Inc.", S=California, C=US', + devCert: 'certificate.pfx', + certPass: process.env.WINDOWS_CODE_SIGNING_CERT_PASSWORD, + outputDirectory: appxOutputPathSigned, + } ); +} From f0cf79a74fbc0e10c97c1e133c5ed3ec9ab9e950 Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Tue, 7 Apr 2026 15:10:19 +1000 Subject: [PATCH 06/11] Update CI toolkit for Azure signing Points to the `add-azure-trusted-signing` branch which includes `setup_azure_trusted_signing.ps1`. --- Generated with the help of Claude Code, https://claude.ai/code Co-Authored-By: Claude Code Opus 4.6 --- .buildkite/shared-pipeline-vars | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.buildkite/shared-pipeline-vars b/.buildkite/shared-pipeline-vars index a7a9319a1a..2b3ba201f3 100755 --- a/.buildkite/shared-pipeline-vars +++ b/.buildkite/shared-pipeline-vars @@ -5,7 +5,7 @@ # The ~> modifier is not currently used, but we check for it just in case XCODE_VERSION=$(sed -E -n 's/^(~> )?(.*)/xcode-\2/p' .xcode-version) -CI_TOOLKIT_VERSION='6.0.1' +CI_TOOLKIT_VERSION='add-azure-trusted-signing' NVM_PLUGIN_VERSION='0.6.0' export IMAGE_ID="$XCODE_VERSION" From 0700241824093612790790f363e21c3e3761ee83 Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Tue, 7 Apr 2026 15:36:43 +1000 Subject: [PATCH 07/11] Pin CI toolkit to commit SHA The `add-azure-trusted-signing` branch no longer exists. Use the commit SHA directly. --- Generated with the help of Claude Code, https://claude.ai/code Co-Authored-By: Claude Code Opus 4.6 --- .buildkite/shared-pipeline-vars | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.buildkite/shared-pipeline-vars b/.buildkite/shared-pipeline-vars index 2b3ba201f3..cb6d57c015 100755 --- a/.buildkite/shared-pipeline-vars +++ b/.buildkite/shared-pipeline-vars @@ -5,7 +5,7 @@ # The ~> modifier is not currently used, but we check for it just in case XCODE_VERSION=$(sed -E -n 's/^(~> )?(.*)/xcode-\2/p' .xcode-version) -CI_TOOLKIT_VERSION='add-azure-trusted-signing' +CI_TOOLKIT_VERSION='4411dd924c08dea251702db5760e741c9d81eff2' NVM_PLUGIN_VERSION='0.6.0' export IMAGE_ID="$XCODE_VERSION" From 08dc82c51fb00fccf9dfa1ffe89c93cb0d57d2cf Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Tue, 7 Apr 2026 16:53:40 +1000 Subject: [PATCH 08/11] Clarify the purpose of signing the local AppX Co-authored-by: Gio Lodi --- scripts/package-appx.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/package-appx.mjs b/scripts/package-appx.mjs index 113e497f73..2f67be2758 100644 --- a/scripts/package-appx.mjs +++ b/scripts/package-appx.mjs @@ -182,9 +182,9 @@ await convertToWindowsStore( { outputDirectory: appxOutputPathUnsigned, } ); -// Create sideload AppX — signing method depends on USE_AZURE_TRUSTED_SIGNING. +// Create signed AppX (used for local testing via sideloading) const appxOutputPathSigned = path.resolve( outPath, `${ appxName }-${ architecture }-signed` ); -console.log( `~~~ Creating .appx for sideloading at ${ appxOutputPathSigned }...` ); +console.log( `~~~ Creating signed .appx for local testing at ${ appxOutputPathSigned }...` ); if ( useAzureSigning ) { const sideloadPublisher = From ec671441730abe2b1138b460f5da690bc02cb234 Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Tue, 7 Apr 2026 16:54:04 +1000 Subject: [PATCH 09/11] Enable Azure Trusted Signing in CI Set `USE_AZURE_TRUSTED_SIGNING=1` in both dev and release Windows build jobs so Azure signing is active by default. The toggle in `build-for-windows.ps1` still allows falling back to PFX by unsetting the env var. --- Generated with the help of Claude Code, https://claude.ai/code Co-Authored-By: Claude Code Opus 4.6 --- .buildkite/pipeline.yml | 2 ++ .buildkite/release-build-and-distribute.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index a29981dc81..208e733425 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -138,6 +138,8 @@ steps: - label: 🔨 Windows Dev Build - {{matrix}} agents: queue: windows + env: + USE_AZURE_TRUSTED_SIGNING: 1 command: powershell -File .buildkite/commands/build-for-windows.ps1 -BuildType dev -Architecture {{matrix}} artifact_paths: - apps\studio\out\**\studio-setup.exe diff --git a/.buildkite/release-build-and-distribute.yml b/.buildkite/release-build-and-distribute.yml index f6fb6fde30..d31eecbacb 100644 --- a/.buildkite/release-build-and-distribute.yml +++ b/.buildkite/release-build-and-distribute.yml @@ -68,6 +68,8 @@ steps: - label: 🔨 Windows Release Build - {{matrix}} agents: queue: windows + env: + USE_AZURE_TRUSTED_SIGNING: 1 command: | bash .buildkite/commands/checkout-release-branch.sh "${RELEASE_VERSION}" powershell -File .buildkite/commands/build-for-windows.ps1 -BuildType release -Architecture {{matrix}} From 82cf1365b398e41a1cfe42385a09bb40726e7718 Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Tue, 7 Apr 2026 19:02:36 +1000 Subject: [PATCH 10/11] Rename signed AppX to drop "unsigned" `electron2appx` names the file "unsigned" when `devCert: 'nil'` is passed. The Azure signing path builds unsigned then signs externally, so the file kept the misleading name. --- Generated with the help of Claude Code, https://claude.ai/code Co-Authored-By: Claude Code Opus 4.6 --- scripts/package-appx.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/package-appx.mjs b/scripts/package-appx.mjs index 2f67be2758..14dd22f73d 100644 --- a/scripts/package-appx.mjs +++ b/scripts/package-appx.mjs @@ -217,6 +217,14 @@ if ( useAzureSigning ) { stdio: 'inherit', } ); console.log( `Signed ${ appxFile } successfully.` ); + + // Rename to remove misleading "unsigned" from the filename + const renamedFile = appxFile.replace( ' unsigned', '' ); + if ( renamedFile !== appxFile ) { + const renamedPath = path.join( appxOutputPathSigned, renamedFile ); + await fs.rename( appxPath, renamedPath ); + console.log( `Renamed to ${ renamedFile }` ); + } } } else { // PFX certificate signing From 6b940af02ebf314dfa125112fdc958184be99f1a Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Wed, 8 Apr 2026 13:35:31 +1000 Subject: [PATCH 11/11] Constrain `Boolean` parsing from env var Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- scripts/package-appx.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/package-appx.mjs b/scripts/package-appx.mjs index 14dd22f73d..9cff147372 100644 --- a/scripts/package-appx.mjs +++ b/scripts/package-appx.mjs @@ -5,7 +5,9 @@ import { fileURLToPath } from 'url'; import convertToWindowsStore from 'electron2appx'; import packageJson from '../apps/studio/package.json' with { type: 'json' }; -const useAzureSigning = Boolean( process.env.USE_AZURE_TRUSTED_SIGNING ); +const useAzureSigning = [ '1', 'true' ].includes( + process.env.USE_AZURE_TRUSTED_SIGNING?.trim().toLowerCase() +); console.log( '--- :electron: Packaging AppX' );