Skip to content

Commit 530dc19

Browse files
committed
fix(desktop): stage the runtime on Windows and skip empty signing credentials
Node refuses to spawn a .cmd shim without a shell, so pnpm.cmd raised EINVAL and the Windows job never staged the Host closure. Spawn a bare pnpm through a shell on Windows and quote arguments cmd.exe would otherwise split. An unset GitHub secret interpolates to an empty string rather than to an absent variable, and electron-builder resolves an empty CSC_LINK as a certificate path -- path.resolve(appDir, '') is the app directory, so the mac job died on 'not a file'. Export only credentials that carry a value, and state the unsigned macOS path explicitly.
1 parent 70a22fc commit 530dc19

4 files changed

Lines changed: 114 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@pymodel/pythinker-desktop': patch
3+
---
4+
5+
Fix Windows runtime staging and skip empty signing credentials in the desktop release workflow

.github/workflows/desktop-release.yml

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,33 @@ jobs:
5151
# Without Developer ID signing secrets, electron-builder publishes an
5252
# ad-hoc/self-signed app. macOS auto-update will not accept unsigned updates,
5353
# but this still proves packaging and the feed shape.
54+
# An unset GitHub secret interpolates to an empty string, and
55+
# electron-builder resolves an empty CSC_LINK as a certificate path
56+
# (path.resolve(appDir, '') === appDir), failing with "not a file".
57+
# Export only the variables that carry a value.
58+
- name: Resolve macOS signing credentials
59+
shell: bash
60+
env:
61+
IN_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
62+
IN_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
63+
IN_APPLE_ID: ${{ secrets.APPLE_ID }}
64+
IN_APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
65+
IN_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
66+
run: |
67+
for name in CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
68+
input="IN_${name}"
69+
value="${!input:-}"
70+
if [ -n "$value" ]; then printf '%s<<__EOF__\n%s\n__EOF__\n' "$name" "$value" >> "$GITHUB_ENV"; fi
71+
done
72+
if [ -z "${IN_CSC_LINK:-}" ]; then
73+
echo 'CSC_IDENTITY_AUTO_DISCOVERY=false' >> "$GITHUB_ENV"
74+
echo 'No macOS signing certificate configured; building unsigned.'
75+
fi
5476
- name: Package and publish desktop release
5577
working-directory: apps/desktop
5678
run: pnpm exec electron-builder --mac dmg zip --publish always
5779
env:
5880
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
59-
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
60-
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
61-
APPLE_ID: ${{ secrets.APPLE_ID }}
62-
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
63-
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
6481

6582
- name: Upload macOS artifacts for manual runs
6683
if: github.event_name == 'workflow_dispatch'
@@ -107,15 +124,25 @@ jobs:
107124
working-directory: apps/desktop
108125
run: node --import tsx scripts/stage-runtime.ts
109126

110-
# Without WIN_CSC_* signing secrets, Windows artifacts are unsigned and
111-
# installers trigger a SmartScreen warning on first run.
127+
# Only non-empty WIN_CSC_* signing secrets are exported. Without them,
128+
# Windows artifacts are unsigned and installers trigger a SmartScreen
129+
# warning on first run.
130+
- name: Resolve Windows signing credentials
131+
shell: bash
132+
env:
133+
IN_WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
134+
IN_WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
135+
run: |
136+
for name in WIN_CSC_LINK WIN_CSC_KEY_PASSWORD; do
137+
input="IN_${name}"
138+
value="${!input:-}"
139+
if [ -n "$value" ]; then printf '%s<<__EOF__\n%s\n__EOF__\n' "$name" "$value" >> "$GITHUB_ENV"; fi
140+
done
112141
- name: Package and publish desktop release
113142
working-directory: apps/desktop
114143
run: pnpm exec electron-builder --win nsis --x64 --publish always
115144
env:
116145
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
117-
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
118-
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
119146

120147
- name: Upload Windows artifacts for manual runs
121148
if: github.event_name == 'workflow_dispatch'

apps/desktop/scripts/stage-runtime.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { existsSync } from 'node:fs'
55
import { cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'
66
import { tmpdir } from 'node:os'
77
import { join, resolve, sep } from 'node:path'
8+
import { fileURLToPath } from 'node:url'
89

910
const desktopRoot = resolve(import.meta.dirname, '..')
1011
const repositoryRoot = resolve(desktopRoot, '../..')
@@ -14,9 +15,42 @@ const entry = join(staging, 'node_modules/@pymodel/pythinker-code/dist/launcher.
1415
const frontend = join(staging, 'node_modules/@pymodel/pythinker-code/dist-web/index.html')
1516
const workspaceState = join(repositoryRoot, 'node_modules/.pnpm-workspace-state-v1.json')
1617

18+
/** Windows characters that make an argument unsafe to hand to `cmd.exe` unquoted. */
19+
const WINDOWS_UNSAFE_ARGUMENT = /[\s"&()<>^|]/u
20+
21+
/**
22+
* Decide how to invoke a package manager on one platform.
23+
*
24+
* Node refuses to spawn a `.cmd` or `.bat` shim without a shell, so Windows
25+
* needs `shell: true`. With a shell, Node does not quote arguments, so any
26+
* argument carrying whitespace or a `cmd.exe` metacharacter is quoted here.
27+
* @param platform - The value of `process.platform`.
28+
* @param command - The package-manager binary name.
29+
* @param args - Arguments in their unquoted form.
30+
* @returns The command, arguments and shell flag to pass to `spawn`.
31+
*/
32+
export function packageManagerInvocation(platform: string, command: string, args: readonly string[]): {
33+
readonly command: string
34+
readonly args: readonly string[]
35+
readonly shell: boolean
36+
} {
37+
if (platform !== 'win32') return { command, args, shell: false }
38+
return {
39+
command,
40+
args: args.map(argument => (WINDOWS_UNSAFE_ARGUMENT.test(argument) ? `"${argument}"` : argument)),
41+
shell: true,
42+
}
43+
}
44+
1745
async function run(command: string, args: readonly string[]): Promise<void> {
46+
const invocation = packageManagerInvocation(process.platform, command, args)
1847
await new Promise<void>((accept, reject) => {
19-
const child = spawn(command, args, { cwd: repositoryRoot, env: { ...process.env, CI: 'true' }, stdio: 'inherit' })
48+
const child = spawn(invocation.command, [...invocation.args], {
49+
cwd: repositoryRoot,
50+
env: { ...process.env, CI: 'true' },
51+
stdio: 'inherit',
52+
shell: invocation.shell,
53+
})
2054
child.once('error', reject)
2155
child.once('exit', (code, signal) => {
2256
if (code === 0) accept()
@@ -60,7 +94,7 @@ async function materializeLinks(): Promise<void> {
6094
async function deploy(target: string): Promise<void> {
6195
const savedWorkspaceState = existsSync(workspaceState) ? await readFile(workspaceState) : undefined
6296
try {
63-
await run(process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm', [
97+
await run('pnpm', [
6498
'--config.verify-deps-before-run=false', '--filter', deployPackage, 'deploy', '--legacy', '--prod',
6599
'--config.node-linker=hoisted', '--config.auto-install-peers=false', '--config.link-workspace-packages=true', target,
66100
])
@@ -96,4 +130,7 @@ async function main(): Promise<void> {
96130
console.log(`desktop runtime staged at ${staging}`)
97131
}
98132

99-
await main()
133+
const invokedPath = process.argv[1]
134+
if (invokedPath !== undefined && resolve(invokedPath) === fileURLToPath(import.meta.url)) {
135+
await main()
136+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { packageManagerInvocation } from '../scripts/stage-runtime'
3+
4+
describe('package manager invocation', () => {
5+
it('leaves non-Windows invocations untouched', () => {
6+
const args = ['--filter', 'x', '/tmp/a b']
7+
8+
expect(packageManagerInvocation('darwin', 'pnpm', args)).toEqual({
9+
command: 'pnpm',
10+
args,
11+
shell: false,
12+
})
13+
})
14+
15+
it('uses a shell on Windows', () => {
16+
expect(packageManagerInvocation('win32', 'pnpm', ['deploy']).shell).toBe(true)
17+
})
18+
19+
it('quotes a Windows path containing a space', () => {
20+
expect(packageManagerInvocation('win32', 'pnpm', ['deploy', 'C:\\Users\\John Doe\\tmp']).args)
21+
.toEqual(['deploy', '"C:\\Users\\John Doe\\tmp"'])
22+
})
23+
24+
it('leaves a safe Windows argument alone', () => {
25+
expect(packageManagerInvocation('win32', 'pnpm', ['--config.node-linker=hoisted']).args)
26+
.toEqual(['--config.node-linker=hoisted'])
27+
})
28+
29+
it('quotes a Windows cmd metacharacter', () => {
30+
expect(packageManagerInvocation('win32', 'pnpm', ['deploy&verify']).args)
31+
.toEqual(['"deploy&verify"'])
32+
})
33+
})

0 commit comments

Comments
 (0)