From 94d0f56689757788145c8c28b5a45c2f45ef4653 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Mon, 13 Jul 2026 17:49:50 +0800 Subject: [PATCH] fix(desktop): make Windows dry-run signing work --- .github/workflows/release.yml | 20 +++---- desktop/electron-builder.win.js | 92 ++++++++++++--------------------- 2 files changed, 44 insertions(+), 68 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 610a7c70..bf0842ef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -113,11 +113,11 @@ jobs: shell: bash run: npm run build - # Download the Windows remote-signing CLI (vendor-neutral: the URL comes - # from a repo variable, so a public workflow never names the signing - # vendor). Only runs on the Windows leg and only when a URL is set; - # otherwise the build stays unsigned. SIGNTOOL_PATH is exported for the - # next step's electron-builder.win.js to invoke. + # Download the Windows signing CLI. The URL comes from a repo variable, so + # nothing about the signing setup is hardcoded in a public workflow. Only + # runs on the Windows leg and only when a URL is set; otherwise the build + # stays unsigned. SIGNTOOL_PATH is exported for the next step's + # electron-builder.win.js to invoke. - name: Download Windows signing CLI if: matrix.platform == 'win' && vars.SIGNTOOL_CLI_URL != '' shell: bash @@ -149,7 +149,7 @@ jobs: # is the correct state for unsigned builds. MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }} MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} - # Windows remote code signing (Racent-style CLI). Credentials are + # Windows code signing via the signing CLI. Credentials are # secrets; SIGNTOOL_PATH was exported by the download step above. # COW_SIGN_DRY_RUN (repo variable) lets us validate the whole pipeline # with a self-signed cert before buying a real one — no quota used. @@ -170,8 +170,8 @@ jobs: # (an `unset` can return non-zero and abort under errexit). # macOS keeps the classic CSC_LINK (.p12) flow. Windows no longer uses # a local .pfx (EV private keys can't be exported since 2023); it signs - # via the remote CLI wired into electron-builder.win.js instead, using - # the SIGNTOOL_* env already set above — nothing to export here. + # via the CLI wired into electron-builder.win.js instead, using the + # SIGNTOOL_* env already set above — nothing to export here. case "${{ matrix.platform }}" in mac) if [ -n "$MAC_CSC_LINK" ]; then @@ -188,8 +188,8 @@ jobs: # # CONFIG PER PLATFORM: each platform loads its OWN dynamic config. # mac -> electron-builder.js (injects mac.binaries for signing) - # win -> electron-builder.win.js (wires the remote sign hook + - # afterPack to sign the backend exe) + # win -> electron-builder.win.js (wires the sign hook; electron-builder + # signs the app, backend and installer) # HISTORY: passing --config on Windows previously broke the build (no # installer, job still green). That happened because the MAC config # (electron-builder.js) was a no-op on Windows yet still disturbed the diff --git a/desktop/electron-builder.win.js b/desktop/electron-builder.win.js index ec46ae02..abad5f3b 100644 --- a/desktop/electron-builder.win.js +++ b/desktop/electron-builder.win.js @@ -2,22 +2,19 @@ * Dynamic electron-builder config for WINDOWS code signing. * * Mirrors electron-builder.js (which handles mac.binaries) but for Windows. - * It wires the Racent remote code-signing CLI into electron-builder so that - * every produced .exe (the app launcher + the NSIS installer) is signed in the - * cloud — the EV private key never leaves the CA's HSM, satisfying the post-2023 - * "private key must live in hardware" rule. + * It wires a signing CLI into electron-builder so that every .exe is signed, + * with the private key kept in hardware per the post-2023 code-signing rules. * - * Two signing passes are needed: - * 1. win.signtoolOptions.sign (customSign) — electron-builder calls this for - * each artifact it produces (app .exe, uninstaller, NSIS installer). - * 2. afterPack — signs the PyInstaller backend (cowagent-backend.exe) that is - * copied in via extraResources. electron-builder's sign hook only touches - * its OWN outputs, so the nested backend exe must be signed here, BEFORE - * the installer is assembled, so the signed backend ends up inside it. + * A SINGLE sign hook (win.signtoolOptions.sign) covers everything: electron- + * builder calls it for EVERY .exe it processes, which includes the app + * launcher, the packaged PyInstaller backend (extraResources/backend/ + * cowagent-backend.exe) and the NSIS installer. We deliberately do NOT add an + * afterPack pass — that would sign the backend a second time and waste a paid + * signing call on every release. * - * VENDOR PRIVACY: the CLI path and all credentials come from env vars only. - * Nothing in this file (or the public workflow) names the reseller, so a public - * repo never leaks which signing vendor we use. + * PRIVACY: the CLI path and all credentials come from env vars only. Nothing in + * this file (or the public workflow) is hardcoded, so a public repo never leaks + * any signing configuration. * * DRY-RUN / SKIP: when SIGNTOOL_CERT_CODE is absent we skip signing entirely * (unsigned dev/dry builds keep working). When COW_SIGN_DRY_RUN=1 we pass @@ -30,16 +27,22 @@ const path = require('path') const config = require('./package.json').build -// Absolute path to the Racent signtool CLI on the runner. Injected by CI so -// this file never hardcodes a vendor download URL. e.g. C:\signtool\signtool.exe +// Absolute path to the signing CLI on the runner. Injected by CI so this file +// never hardcodes a download URL. e.g. C:\signtool\signtool.exe const SIGNTOOL = process.env.SIGNTOOL_PATH || '' -const ACCESS_KEY = process.env.SIGNTOOL_ACCESS_KEY || '' -const ACCESS_SECRET = process.env.SIGNTOOL_ACCESS_SECRET || '' -const CERT_CODE = process.env.SIGNTOOL_CERT_CODE || '' // Dry-run validates the pipeline with a self-signed cert (no quota, no real // cert needed). Any truthy value enables it. const DRY_RUN = !!process.env.COW_SIGN_DRY_RUN +// In dry-run the CLI still requires these flags to be NON-EMPTY (it validates +// presence, not the value, and signs with a self-signed cert). So when no real +// credentials are provided during a dry-run, fall back to harmless placeholders +// to satisfy the CLI's arg check. Real runs pass the actual secrets through. +const PLACEHOLDER = DRY_RUN ? 'dry-run' : '' +const ACCESS_KEY = process.env.SIGNTOOL_ACCESS_KEY || PLACEHOLDER +const ACCESS_SECRET = process.env.SIGNTOOL_ACCESS_SECRET || PLACEHOLDER +const CERT_CODE = process.env.SIGNTOOL_CERT_CODE || PLACEHOLDER + // RFC3161 timestamp server for SHA256. Microsoft's is reliable from CI runners // worldwide; overridable via env if needed. const TIMESTAMP = process.env.SIGNTOOL_TIMESTAMP || 'http://timestamp.acs.microsoft.com' @@ -53,7 +56,7 @@ function canSign() { } /** - * Sign a single file in place using the Racent CLI. The CLI writes to a + * Sign a single file in place using the signing CLI. The CLI writes to a * separate --out path (it refuses to overwrite an existing file), so we sign to * a temp file and atomically move it back over the original. */ @@ -103,44 +106,17 @@ async function customSign(configuration) { signFile(configuration.path) } -// Recursively collect .exe/.dll under the packed backend dir so nested native -// binaries get signed too (Defender flags PyInstaller bundles most often). -function collectSignables(dir) { - const out = [] - const walk = (d) => { - for (const name of fs.readdirSync(d)) { - const full = path.join(d, name) - const st = fs.lstatSync(full) - if (st.isSymbolicLink()) continue - if (st.isDirectory()) { - walk(full) - continue - } - if (/\.(exe|dll)$/i.test(name)) out.push(full) - } - } - walk(dir) - return out -} - -// Sign the PyInstaller backend BEFORE the installer is built, so the signed -// backend is what gets packaged. context.appOutDir is the unpacked app dir. -async function afterPack(context) { - if (process.platform !== 'win32') return - if (!canSign()) return - const backendDir = path.join(context.appOutDir, 'resources', 'backend', 'cowagent-backend') - if (!fs.existsSync(backendDir)) { - console.warn(`[win-sign] backend dir not found, skipping backend signing: ${backendDir}`) - return - } - const files = collectSignables(backendDir) - console.log(`[win-sign] signing ${files.length} backend binaries`) - for (const f of files) signFile(f) -} - -// Extend the base config: attach the sign hook + afterPack. Only meaningful on -// Windows builds (this config is only passed via --config on the win matrix leg). +// Extend the base config: attach the sign hook. Only meaningful on Windows +// builds (this config is only passed via --config on the win matrix leg). +// +// electron-builder invokes customSign for EVERY .exe it touches — that already +// includes the packaged backend (extraResources/backend/cowagent-backend.exe) +// and the NSIS installer, not just the app launcher. So there's no separate +// afterPack pass: adding one would sign the backend twice (wasting a paid +// signing call per release). Nested PyInstaller .dll/.pyd files are left +// unsigned, which Windows Authenticode tolerates (unlike macOS, it doesn't +// require deep-signing every nested lib — a signed top-level exe is enough for +// SmartScreen/Defender to attribute the publisher). config.win = { ...config.win, signtoolOptions: { sign: customSign, signingHashAlgorithms: ['sha256'] } } -config.afterPack = afterPack module.exports = config