Skip to content

Commit 8d7a65c

Browse files
committed
build: enable Azure Artifact Signing for Windows installers
Sign EXE and MSI via electron-builder's win.azureSignOptions, authorized by OIDC federated credentials between GitHub Actions and Azure. The federated credential is scoped to the release-candidate workflow's environment, so the human-gated environment approval and the Azure auth are layered. AppX is excluded via signExts: the Microsoft Store re-signs during certification with a Store-issued publisher cert, and matching its CN at build time isn't feasible. The wrapper appends --c.win.signExecutable=false when --mode=dev, so PR-time CI and local dev builds skip signing without needing Azure auth. Removes the legacy WIN_CSC_LINK / WIN_CSC_KEY_PASSWORD flow.
1 parent b68335e commit 8d7a65c

3 files changed

Lines changed: 68 additions & 25 deletions

File tree

.github/workflows/release-candidate.yml

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ jobs:
2525
environment: release-candidate
2626
permissions:
2727
contents: read
28+
# Required for the azure/login OIDC token exchange that authorizes
29+
# Azure Artifact Signing on the Windows runners.
30+
id-token: write
2831
strategy:
2932
# Don't cancel sibling matrix cells when one fails; let each one
3033
# finish so we get artifacts and full failure signal from every cell.
@@ -52,20 +55,19 @@ jobs:
5255
run:
5356
shell: bash
5457
env:
55-
# Temporary workaround: Windows signing is currently broken due to
56-
# a security-policy change. See
57-
# https://github.com/electron/windows-installer/issues/473
58-
# While this is true, the release-candidate job still runs Windows
59-
# through the build (so we get an installer artifact) but skips
60-
# the signing step.
61-
SCRATCH_SHOULD_SIGN: ${{ matrix.os != 'windows-latest' }}
6258
# Organization-specific identifiers used by electron-builder.yaml's ${env.X}
6359
# interpolation. Kept in repo Variables so a fork builds with its own values
6460
# (or fails loudly if unset) rather than colliding with our published artifacts.
6561
APPX_IDENTITY_NAME: ${{ vars.APPX_IDENTITY_NAME }}
6662
APPX_PUBLISHER: ${{ vars.APPX_PUBLISHER }}
6763
APPX_PUBLISHER_DISPLAY_NAME: ${{ vars.APPX_PUBLISHER_DISPLAY_NAME }}
6864
MSI_UPGRADE_CODE: ${{ vars.MSI_UPGRADE_CODE }}
65+
# Azure Artifact Signing parameters for electron-builder's win.azureSignOptions.
66+
# OIDC credentials for the signing call are set by the "Azure login" step below.
67+
AZURE_SIGNING_ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }}
68+
AZURE_SIGNING_ACCOUNT_NAME: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }}
69+
AZURE_SIGNING_PROFILE_NAME: ${{ vars.AZURE_SIGNING_PROFILE_NAME }}
70+
AZURE_SIGNING_PUBLISHER_NAME: ${{ vars.AZURE_SIGNING_PUBLISHER_NAME }}
6971
# App Store Connect API key — fastlane and electron-builder read
7072
# different env var names but use the same credentials. The .p8
7173
# file path (APPLE_API_KEY / APP_STORE_CONNECT_API_KEY_KEY_FILEPATH)
@@ -149,18 +151,23 @@ jobs:
149151
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
150152
if: matrix.os == 'macos-latest'
151153
run: bundle exec fastlane prepare_signing
154+
- name: Azure login
155+
# OIDC exchange that gives electron-builder's Azure Artifact Signing call the
156+
# credentials it needs. Federated credential is scoped to this workflow's
157+
# 'release-candidate' environment, so no client secret is involved.
158+
# Skip for the appx-only cell: it doesn't sign anything (Store re-signs during
159+
# certification), so it has no use for the Azure token.
160+
if: matrix.os == 'windows-latest' && matrix.target != 'appx'
161+
uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3
162+
with:
163+
client-id: ${{ secrets.AZURE_CLIENT_ID }}
164+
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
165+
allow-no-subscriptions: true
152166
- name: Build (signed)
153167
timeout-minutes: 30 # macOS notarization can take a while
154168
env:
155169
NODE_OPTIONS: --max-old-space-size=4096
156-
# Only expose Windows code-signing secrets to runs that will use them.
157-
# SCRATCH_SHOULD_SIGN is currently false on Windows (temporary workaround above);
158-
# both expressions evaluate to '' until Windows signing is re-enabled.
159-
WIN_CSC_LINK: ${{ matrix.os == 'windows-latest' && env.SCRATCH_SHOULD_SIGN == 'true' && secrets.WIN_CSC_LINK ||
160-
'' }}
161-
WIN_CSC_KEY_PASSWORD: ${{ matrix.os == 'windows-latest' && env.SCRATCH_SHOULD_SIGN == 'true' && secrets.WIN_CSC_KEY_PASSWORD
162-
|| '' }}
163-
run: npm run ${{ env.SCRATCH_SHOULD_SIGN == 'true' && 'dist' || 'distDev' }} -- --target=${{ matrix.target }}
170+
run: npm run dist -- --target=${{ matrix.target }}
164171
- name: Zip MAS-Dev build
165172
if: matrix.target == 'mas-dev'
166173
run: |

electron-builder.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ win:
4646
target:
4747
- appx
4848
- nsis
49+
# AppX is excluded from build-time signing: the Microsoft Store re-signs during
50+
# certification with a Store-issued publisher cert, and our build-time cert's
51+
# subject would not match the manifest's Publisher attribute.
52+
signExts: ["exe", "msi", "!appx", "!appxbundle", "!appxupload"]
53+
# azureSignOptions is supplied by scripts/electron-builder-wrapper.js only when
54+
# signing is enabled (--mode=dist). Declaring it here would also activate it for
55+
# PR CI and local --mode=dev builds, where Azure auth isn't available — and some
56+
# signing paths (notably the NSIS uninstaller) ignore signAndEditExecutable, so
57+
# gating per-target isn't reliable. Keeping the config off entirely is cleaner.
4958
appx:
5059
# Organization-specific identifiers come from environment variables so a fork can build
5160
# without colliding with our published artifacts. See README for required env vars.

scripts/electron-builder-wrapper.js

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
* Running this script with no command line parameters should build all targets for the current platform.
44
* Pass `--target=<short-name>` to build exactly one target instead of the platform default set; the matrix in
55
* `release-candidate.yml` uses this to fan a multi-target serial pass out into one runner per target. Valid short
6-
* names: `mas`, `mas-dev`, `dmg`, `appx`, `nsis`.
7-
* On Windows, make sure to set CSC_* or WIN_CSC_* environment variables or the NSIS build will fail.
6+
* names: `mas`, `mas-dev`, `dmg`, `appx`, `nsis`, `msi`, `installers`, `nsis-x64`.
7+
* Windows signing uses Azure Artifact Signing, configured in `electron-builder.yaml`'s `win.azureSignOptions`.
8+
* Auth comes from OIDC federated credentials in `release-candidate.yml`; signing is disabled when --mode=dev.
89
* On Mac, the CSC_* variables are optional but will be respected if present.
910
* See also: https://www.electron.build/code-signing
1011
*/
@@ -46,18 +47,12 @@ const getPlatformFlag = function () {
4647
* Run `electron-builder` once to build one or more target(s).
4748
* @param {object} wrapperConfig - overall configuration object for the wrapper script.
4849
* @param {object} target - the target to build in this call.
49-
* If the `target.name` is `'nsis'` then the environment must contain code-signing config (CSC_* or WIN_CSC_*).
50-
* If the `target.name` is `'appx'` then code-signing config will be stripped from the environment if present.
5150
*/
5251
const runBuilder = function (wrapperConfig, target) {
53-
// the AppX build fails if CSC_* or WIN_CSC_* variables are set
52+
// Strip CSC_* env vars for AppX (would conflict with the Store-managed cert) and for unsigned builds.
53+
// Windows signing itself now goes through Azure (not CSC), so these vars are typically absent anyway.
5454
const shouldStripCSC = (target.name.indexOf('appx') === 0) || (!wrapperConfig.doSign);
5555
const childEnvironment = shouldStripCSC ? stripCSC(process.env) : process.env;
56-
if (wrapperConfig.doSign &&
57-
(target.name.indexOf('nsis') === 0) &&
58-
!(childEnvironment.CSC_LINK || childEnvironment.WIN_CSC_LINK)) {
59-
throw new Error(`Signing NSIS build requires CSC_LINK or WIN_CSC_LINK`);
60-
}
6156
const platformFlag = getPlatformFlag();
6257
let allArgs = [platformFlag, target.name];
6358
if (target.platform === 'darwin') {
@@ -75,6 +70,38 @@ const runBuilder = function (wrapperConfig, target) {
7570
// APPLE_API_KEY / APPLE_API_KEY_ID / APPLE_API_ISSUER are present in
7671
// the environment.
7772
}
73+
// Appx-only means every target name in the pass starts with 'appx'. A mixed pass that bundles
74+
// appx with nsis or msi keeps signing on for the non-appx parts; only the pure-appx case opts
75+
// out so the Store can re-sign its container with unsigned inner binaries (today's behavior).
76+
const targetNames = target.name.trim().split(/\s+/);
77+
const isAppxOnly = targetNames.every(name => name.startsWith('appx'));
78+
if (target.platform === 'win32' && wrapperConfig.doSign && !isAppxOnly) {
79+
// Supply Azure Artifact Signing config via CLI rather than electron-builder.yaml. Putting it
80+
// in YAML would activate signing for every build, including PR CI and local dev where Azure
81+
// auth isn't available — and some signing paths (notably the NSIS uninstaller) ignore the
82+
// win.signAndEditExecutable flag, so per-target gating isn't reliable. AppX-only passes skip
83+
// signing entirely: the Microsoft Store re-signs the outer .appx during certification, and
84+
// we want the inner binaries unsigned (electron-builder would otherwise sign them via signExts).
85+
const required = [
86+
'AZURE_SIGNING_ENDPOINT',
87+
'AZURE_SIGNING_ACCOUNT_NAME',
88+
'AZURE_SIGNING_PROFILE_NAME',
89+
'AZURE_SIGNING_PUBLISHER_NAME'
90+
];
91+
const missing = required.filter(name => !childEnvironment[name]);
92+
if (missing.length > 0) {
93+
throw new Error(`Azure signing requires env vars: ${missing.join(', ')}`);
94+
}
95+
// Wrap values in double quotes so spaces (e.g. a multi-word publisherName) survive shell
96+
// re-tokenization. spawnSync(shell: true) joins argv with spaces and runs through cmd.exe /
97+
// /bin/sh, so any internal whitespace would otherwise become an arg separator.
98+
allArgs.push(
99+
`--c.win.azureSignOptions.endpoint="${childEnvironment.AZURE_SIGNING_ENDPOINT}"`,
100+
`--c.win.azureSignOptions.codeSigningAccountName="${childEnvironment.AZURE_SIGNING_ACCOUNT_NAME}"`,
101+
`--c.win.azureSignOptions.certificateProfileName="${childEnvironment.AZURE_SIGNING_PROFILE_NAME}"`,
102+
`--c.win.azureSignOptions.publisherName="${childEnvironment.AZURE_SIGNING_PUBLISHER_NAME}"`
103+
);
104+
}
78105
if (!wrapperConfig.doPackage) {
79106
allArgs.push('--dir', '--c.compression=store');
80107
}

0 commit comments

Comments
 (0)