mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
feat(desktop): add Windows code signing
This commit is contained in:
65
.github/workflows/release.yml
vendored
65
.github/workflows/release.yml
vendored
@@ -113,6 +113,31 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: npm run build
|
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.
|
||||||
|
- name: Download Windows signing CLI
|
||||||
|
if: matrix.platform == 'win' && vars.SIGNTOOL_CLI_URL != ''
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
SIGNTOOL_CLI_URL: ${{ vars.SIGNTOOL_CLI_URL }}
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/signtool"
|
||||||
|
curl -fsSL "$SIGNTOOL_CLI_URL" -o "$RUNNER_TEMP/signtool/cli.zip"
|
||||||
|
# Unzip and locate the signtool executable regardless of nesting.
|
||||||
|
unzip -o "$RUNNER_TEMP/signtool/cli.zip" -d "$RUNNER_TEMP/signtool" >/dev/null
|
||||||
|
exe="$(find "$RUNNER_TEMP/signtool" -type f -iname 'signtool*.exe' | head -n1)"
|
||||||
|
if [ -z "$exe" ]; then
|
||||||
|
echo "signtool.exe not found in downloaded archive" >&2
|
||||||
|
find "$RUNNER_TEMP/signtool" -type f >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Normalize to a Windows-style path for execFileSync in Node.
|
||||||
|
echo "SIGNTOOL_PATH=$(cygpath -w "$exe")" >> "$GITHUB_ENV"
|
||||||
|
echo "resolved signtool: $exe"
|
||||||
|
|
||||||
- name: Build & publish (electron-builder)
|
- name: Build & publish (electron-builder)
|
||||||
working-directory: desktop
|
working-directory: desktop
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -124,8 +149,14 @@ jobs:
|
|||||||
# is the correct state for unsigned builds.
|
# is the correct state for unsigned builds.
|
||||||
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
||||||
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
||||||
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
|
# Windows remote code signing (Racent-style CLI). Credentials are
|
||||||
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
|
# 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.
|
||||||
|
SIGNTOOL_ACCESS_KEY: ${{ secrets.SIGNTOOL_ACCESS_KEY }}
|
||||||
|
SIGNTOOL_ACCESS_SECRET: ${{ secrets.SIGNTOOL_ACCESS_SECRET }}
|
||||||
|
SIGNTOOL_CERT_CODE: ${{ secrets.SIGNTOOL_CERT_CODE }}
|
||||||
|
COW_SIGN_DRY_RUN: ${{ vars.COW_SIGN_DRY_RUN }}
|
||||||
run: |
|
run: |
|
||||||
# Pick the signing cert for THIS platform only. The mac and win secrets
|
# Pick the signing cert for THIS platform only. The mac and win secrets
|
||||||
# are both present in the job env, but a mac cert must never leak into a
|
# are both present in the job env, but a mac cert must never leak into a
|
||||||
@@ -137,6 +168,10 @@ jobs:
|
|||||||
#
|
#
|
||||||
# NOTE: we only ever `export`, never `unset`, GitHub-injected env vars
|
# NOTE: we only ever `export`, never `unset`, GitHub-injected env vars
|
||||||
# (an `unset` can return non-zero and abort under errexit).
|
# (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.
|
||||||
case "${{ matrix.platform }}" in
|
case "${{ matrix.platform }}" in
|
||||||
mac)
|
mac)
|
||||||
if [ -n "$MAC_CSC_LINK" ]; then
|
if [ -n "$MAC_CSC_LINK" ]; then
|
||||||
@@ -144,12 +179,6 @@ jobs:
|
|||||||
export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
|
export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
win)
|
|
||||||
if [ -n "$WIN_CSC_LINK" ]; then
|
|
||||||
export CSC_LINK="$WIN_CSC_LINK"
|
|
||||||
export CSC_KEY_PASSWORD="$WIN_CSC_KEY_PASSWORD"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Never let electron-builder publish: our publish target is a generic
|
# Never let electron-builder publish: our publish target is a generic
|
||||||
@@ -157,19 +186,25 @@ jobs:
|
|||||||
# installers to R2 and register them in D1 ourselves (publish-r2 job).
|
# installers to R2 and register them in D1 ourselves (publish-r2 job).
|
||||||
# `--publish never` still emits the latest*.yml files.
|
# `--publish never` still emits the latest*.yml files.
|
||||||
#
|
#
|
||||||
# CONFIG PER PLATFORM: the dynamic electron-builder.js only exists to
|
# CONFIG PER PLATFORM: each platform loads its OWN dynamic config.
|
||||||
# inject mac.binaries (the backend Mach-O files to hardened-sign for
|
# mac -> electron-builder.js (injects mac.binaries for signing)
|
||||||
# notarization) — it's a pure no-op on Windows. Passing --config on
|
# win -> electron-builder.win.js (wires the remote sign hook +
|
||||||
# Windows was what silently broke the Windows build (it produced no
|
# afterPack to sign the backend exe)
|
||||||
# installer while the job still reported success; Windows worked fine
|
# HISTORY: passing --config on Windows previously broke the build (no
|
||||||
# before --config was introduced). So Windows uses the plain
|
# installer, job still green). That happened because the MAC config
|
||||||
# package.json build config and only mac uses the dynamic one.
|
# (electron-builder.js) was a no-op on Windows yet still disturbed the
|
||||||
|
# run. The fix is a DEDICATED win config that correctly extends
|
||||||
|
# config.win — not sharing the mac one. If a build ever runs WITHOUT
|
||||||
|
# signing configured, electron-builder.win.js still returns the base
|
||||||
|
# config unchanged (sign hook just skips), so the installer is still
|
||||||
|
# produced.
|
||||||
#
|
#
|
||||||
# Invoke via `node <cli.js>` rather than `npx`: on Windows `npx` is
|
# Invoke via `node <cli.js>` rather than `npx`: on Windows `npx` is
|
||||||
# npx.cmd (a batch wrapper) and running it from this Git Bash step can
|
# npx.cmd (a batch wrapper) and running it from this Git Bash step can
|
||||||
# make bash return before the wrapped process finishes. node skips it.
|
# make bash return before the wrapped process finishes. node skips it.
|
||||||
case "${{ matrix.platform }}" in
|
case "${{ matrix.platform }}" in
|
||||||
mac) config_arg="--config electron-builder.js" ;;
|
mac) config_arg="--config electron-builder.js" ;;
|
||||||
|
win) config_arg="--config electron-builder.win.js" ;;
|
||||||
*) config_arg="" ;;
|
*) config_arg="" ;;
|
||||||
esac
|
esac
|
||||||
node node_modules/electron-builder/cli.js ${{ matrix.eb_flags }} $config_arg --publish never
|
node node_modules/electron-builder/cli.js ${{ matrix.eb_flags }} $config_arg --publish never
|
||||||
|
|||||||
146
desktop/electron-builder.win.js
Normal file
146
desktop/electron-builder.win.js
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* --dry-run so the WHOLE pipeline can be validated in CI with a self-signed
|
||||||
|
* cert, WITHOUT a real certificate and WITHOUT consuming any signing quota.
|
||||||
|
*/
|
||||||
|
const { execFileSync } = require('child_process')
|
||||||
|
const fs = require('fs')
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
// 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'
|
||||||
|
|
||||||
|
// Signing is possible when we have the CLI plus either a real cert code or
|
||||||
|
// explicit dry-run mode (dry-run accepts placeholder credentials).
|
||||||
|
function canSign() {
|
||||||
|
if (!SIGNTOOL || !fs.existsSync(SIGNTOOL)) return false
|
||||||
|
if (DRY_RUN) return true
|
||||||
|
return !!(ACCESS_KEY && ACCESS_SECRET && CERT_CODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a single file in place using the Racent 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.
|
||||||
|
*/
|
||||||
|
function signFile(filePath) {
|
||||||
|
const tmpOut = `${filePath}.signed`
|
||||||
|
// Remove a stale temp from a previous failed run (CLI errors if --out exists).
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(tmpOut)) fs.rmSync(tmpOut)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'sign',
|
||||||
|
...(DRY_RUN ? ['--dry-run'] : []),
|
||||||
|
`--access-key=${ACCESS_KEY}`,
|
||||||
|
`--access-secret=${ACCESS_SECRET}`,
|
||||||
|
`--cert-code=${CERT_CODE}`,
|
||||||
|
`--file=${filePath}`,
|
||||||
|
`--out=${tmpOut}`,
|
||||||
|
'--sha1=false',
|
||||||
|
'--sha2=true',
|
||||||
|
'--timestamp-rfc3161',
|
||||||
|
TIMESTAMP,
|
||||||
|
]
|
||||||
|
|
||||||
|
// Never print credentials: log only the file being signed.
|
||||||
|
console.log(`[win-sign] signing ${path.basename(filePath)}${DRY_RUN ? ' (dry-run)' : ''}`)
|
||||||
|
execFileSync(SIGNTOOL, args, { stdio: ['ignore', 'inherit', 'inherit'] })
|
||||||
|
|
||||||
|
if (!fs.existsSync(tmpOut)) {
|
||||||
|
throw new Error(`[win-sign] signed output not produced for ${filePath}`)
|
||||||
|
}
|
||||||
|
// Replace the original with the signed copy.
|
||||||
|
fs.rmSync(filePath)
|
||||||
|
fs.renameSync(tmpOut, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// electron-builder calls this for each artifact it generates (app exe, NSIS
|
||||||
|
// installer, uninstaller). Signature: (configuration) => void, where
|
||||||
|
// configuration.path is the file to sign.
|
||||||
|
async function customSign(configuration) {
|
||||||
|
if (!canSign()) {
|
||||||
|
console.warn('[win-sign] signing skipped (no signtool/credentials)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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).
|
||||||
|
config.win = { ...config.win, signtoolOptions: { sign: customSign, signingHashAlgorithms: ['sha256'] } }
|
||||||
|
config.afterPack = afterPack
|
||||||
|
|
||||||
|
module.exports = config
|
||||||
Reference in New Issue
Block a user