Skip to content
Open
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: 10 additions & 3 deletions .buildkite/commands/build-for-windows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .buildkite/pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .buildkite/release-build-and-distribute.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand Down
2 changes: 1 addition & 1 deletion .buildkite/shared-pipeline-vars
Original file line number Diff line number Diff line change
Expand Up @@ -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='4411dd924c08dea251702db5760e741c9d81eff2'
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Waiting for confirmation on the tooling from a few more clients before shipping a new version of the plugin. A commit sha doesn't look as neat as a tag, but the pinning result is the same.

NVM_PLUGIN_VERSION='0.6.0'

export IMAGE_ID="$XCODE_VERSION"
Expand Down
15 changes: 12 additions & 3 deletions apps/studio/forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '../..' );

Expand All @@ -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.
Expand Down Expand Up @@ -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,
}
),
Comment on lines +100 to +109
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once we'll have established that the new code signing is good, we'll be able to merge the checks done inside windowsSign.ts in this file, before defining this ForgeConfig instance.

As it stands, this code looks a bit ugly, but it's a temporary compromise.

},
[ 'win32' ]
),
Expand Down
32 changes: 32 additions & 0 deletions apps/studio/windowsSign.ts
Original file line number Diff line number Diff line change
@@ -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. ' +
Comment on lines +19 to +22
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getWindowsSign() validates AZURE_CODE_SIGNING_DLIB and AZURE_METADATA_JSON but not SIGNTOOL_PATH, even though the Azure signing hook requires it. This can lead to a later failure during signing rather than failing fast when loading Forge config; consider validating SIGNTOOL_PATH here and including it in the error message to keep the required env var list consistent.

Suggested change
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. ' +
if ( ! process.env.AZURE_CODE_SIGNING_DLIB || ! process.env.AZURE_METADATA_JSON || ! process.env.SIGNTOOL_PATH ) {
throw new Error(
'USE_AZURE_TRUSTED_SIGNING is set but Azure signing env vars ' +
'(AZURE_CODE_SIGNING_DLIB, AZURE_METADATA_JSON, SIGNTOOL_PATH) are missing. ' +

Copilot uses AI. Check for mistakes.
'Did setup_azure_trusted_signing.ps1 run?'
);
}

return {
hookModulePath: path.resolve( __dirname, '..', '..', 'scripts', 'azure-sign-hook.js' ),
};
}

export const windowsSign = getWindowsSign();
28 changes: 28 additions & 0 deletions scripts/azure-sign-hook.js
Original file line number Diff line number Diff line change
@@ -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' } );
};
59 changes: 59 additions & 0 deletions scripts/azure-signing.cjs
Original file line number Diff line number Diff line change
@@ -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,
};
85 changes: 73 additions & 12 deletions scripts/package-appx.mjs
Original file line number Diff line number Diff line change
@@ -1,15 +1,32 @@
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 = [ '1', 'true' ].includes(
process.env.USE_AZURE_TRUSTED_SIGNING?.trim().toLowerCase()
);

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 ) );
Expand Down Expand Up @@ -167,14 +184,58 @@ await convertToWindowsStore( {
outputDirectory: appxOutputPathUnsigned,
} );

// Create signed AppX
// Create signed AppX (used for local testing via sideloading)
const appxOutputPathSigned = path.resolve( outPath, `${ appxName }-${ architecture }-signed` );
console.log( `~~~ Creating signed .appx for local testing at ${ appxOutputPathSigned }...` );

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,
} );
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 );
}

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.` );

// 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 }` );
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image

Given the signed AppX is not used by any of our distribution automation, no other update was needed.

}
} 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,
} );
}
Loading