Skip to content

Commit e0aea67

Browse files
authored
fix: sign packaged Windows application (#632)
1 parent a223362 commit e0aea67

2 files changed

Lines changed: 262 additions & 103 deletions

File tree

.github/workflows/ci.yml

Lines changed: 242 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@ name: CI
22

33
on:
44
push:
5-
branches: [ main ]
5+
branches: [main]
66
pull_request:
7-
branches: [ main ]
8-
workflow_dispatch: # Manually run the signed-release flow from any branch (creates a draft release)
7+
branches: [main]
8+
workflow_dispatch:
9+
inputs:
10+
should_release:
11+
description: Build, sign, and publish a draft release
12+
required: true
13+
type: boolean
14+
default: false
915

1016
permissions:
1117
contents: write
@@ -15,103 +21,236 @@ jobs:
1521
runs-on: windows-latest
1622

1723
steps:
18-
- uses: actions/checkout@v6
19-
with:
20-
fetch-depth: 2 # Fetch the last 2 commits to compare changes
21-
22-
- name: Setup Node.js
23-
uses: actions/setup-node@v6
24-
with:
25-
node-version: '24'
26-
cache: 'npm'
27-
28-
- name: Cache node_modules and native builds
29-
uses: actions/cache@v5
30-
id: cache-deps
31-
with:
32-
path: |
33-
node_modules
34-
.vite
35-
**/node_modules
36-
**/*.node
37-
**/build/Release
38-
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/binding.gyp', '**/node_modules/**/*.node') }}
39-
restore-keys: |
40-
${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}-
41-
${{ runner.os }}-node-
42-
43-
- name: Install dependencies
44-
if: steps.cache-deps.outputs.cache-hit != 'true'
45-
run: npm ci
46-
47-
- name: Run linting
48-
run: npm run lint
49-
50-
- name: Build Storybook
51-
run: npm run build-storybook
52-
53-
- name: Run tests with coverage
54-
run: npm test
55-
56-
- name: Upload coverage report
57-
uses: actions/upload-artifact@v7
58-
with:
59-
name: coverage-report
60-
path: coverage
61-
62-
- name: Check if version changed
63-
id: check_version
64-
shell: pwsh
65-
run: |
66-
$currentVersion = (Get-Content package.json | ConvertFrom-Json).version
67-
$previousVersion = (git show HEAD^:package.json | ConvertFrom-Json).version
68-
if ($currentVersion -ne $previousVersion) {
69-
echo "version_changed=true" >> $env:GITHUB_OUTPUT
70-
echo "Version changed from $previousVersion to $currentVersion"
71-
}
72-
73-
# --- Signed release (only when the version bumps) ---------------------
74-
# Flow: build unsigned (dry-run) -> sign Setup.exe via SignPath -> swap the
75-
# signed installer back in place -> publish from the saved dry-run.
76-
# The dry-run manifest stores paths to out/make/... (no content hashing), so
77-
# replacing the Setup.exe file is picked up by --from-dry-run. RELEASES and
78-
# the .nupkg are never touched, keeping auto-updates valid.
79-
80-
- name: Build unsigned artifacts (publish dry-run)
81-
if: steps.check_version.outputs.version_changed == 'true' || github.event_name == 'workflow_dispatch'
82-
run: npx electron-forge publish --dry-run
83-
env:
84-
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
85-
86-
- name: Upload unsigned installer for signing
87-
id: upload-unsigned
88-
if: steps.check_version.outputs.version_changed == 'true' || github.event_name == 'workflow_dispatch'
89-
uses: actions/upload-artifact@v7
90-
with:
91-
name: unsigned-installer
92-
if-no-files-found: error
93-
path: out/make/squirrel.windows/x64/*Setup.exe
94-
95-
- name: Sign installer with SignPath
96-
if: steps.check_version.outputs.version_changed == 'true' || github.event_name == 'workflow_dispatch'
97-
uses: signpath/github-action-submit-signing-request@v2
98-
with:
99-
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
100-
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
101-
project-slug: irdashies
102-
signing-policy-slug: release-signing
103-
artifact-configuration-slug: initial
104-
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
105-
wait-for-completion: true
106-
output-artifact-directory: signed-installer
107-
108-
- name: Swap in the signed installer
109-
if: steps.check_version.outputs.version_changed == 'true' || github.event_name == 'workflow_dispatch'
110-
shell: pwsh
111-
run: Copy-Item signed-installer/*Setup.exe out/make/squirrel.windows/x64/ -Force
112-
113-
- name: Publish signed release
114-
if: steps.check_version.outputs.version_changed == 'true' || github.event_name == 'workflow_dispatch'
115-
run: npx electron-forge publish --from-dry-run
116-
env:
117-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
24+
- uses: actions/checkout@v6
25+
with:
26+
fetch-depth: 2 # Fetch the last 2 commits to compare changes
27+
persist-credentials: false
28+
29+
- name: Setup Node.js
30+
uses: actions/setup-node@v6
31+
with:
32+
node-version: '24'
33+
cache: 'npm'
34+
35+
- name: Cache node_modules and native builds
36+
uses: actions/cache@v5
37+
id: cache-deps
38+
with:
39+
path: |
40+
node_modules
41+
.vite
42+
**/node_modules
43+
**/*.node
44+
**/build/Release
45+
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/binding.gyp', '**/node_modules/**/*.node') }}
46+
restore-keys: |
47+
${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}-
48+
${{ runner.os }}-node-
49+
50+
- name: Install dependencies
51+
if: steps.cache-deps.outputs.cache-hit != 'true'
52+
run: npm ci
53+
54+
- name: Run linting
55+
run: npm run lint
56+
57+
- name: Build Storybook
58+
run: npm run build-storybook
59+
60+
- name: Run tests with coverage
61+
run: npm test
62+
63+
- name: Upload coverage report
64+
uses: actions/upload-artifact@v7
65+
with:
66+
name: coverage-report
67+
path: coverage
68+
69+
- name: Determine release eligibility
70+
id: check_version
71+
shell: pwsh
72+
env:
73+
DISPATCH_SHOULD_RELEASE: ${{ inputs.should_release }}
74+
run: |
75+
$currentVersion = (Get-Content package.json | ConvertFrom-Json).version
76+
$previousVersion = (git show HEAD^:package.json | ConvertFrom-Json).version
77+
$versionChanged = $currentVersion -ne $previousVersion
78+
$shouldRelease = (
79+
($env:GITHUB_EVENT_NAME -eq 'push' -and $versionChanged) -or
80+
($env:GITHUB_EVENT_NAME -eq 'workflow_dispatch' -and
81+
$env:DISPATCH_SHOULD_RELEASE -eq 'true')
82+
)
83+
84+
echo "version_changed=$($versionChanged.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT
85+
echo "should_release=$($shouldRelease.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT
86+
87+
if ($versionChanged) {
88+
echo "Version changed from $previousVersion to $currentVersion"
89+
}
90+
91+
# --- Signed release ----------------------------------------------------
92+
# Pushes release only when the version changes. Manual runs release only
93+
# when the should_release workflow-dispatch input is enabled. Pull
94+
# requests never enter this flow.
95+
# Flow: build unsigned (dry-run) -> sign the packaged application via
96+
# SignPath -> rebuild Squirrel artifacts from the signed package -> sign
97+
# Setup.exe via SignPath -> publish from the saved dry-run.
98+
# The dry-run manifest stores paths to out/make/... (no content hashing), so
99+
# rebuilding the artifacts and replacing Setup.exe are picked up by
100+
# --from-dry-run. Rebuilding all Squirrel artifacts together keeps RELEASES
101+
# and the .nupkg checksums consistent for auto-updates.
102+
103+
- name: Build unsigned artifacts (publish dry-run)
104+
if: steps.check_version.outputs.should_release == 'true'
105+
run: npx electron-forge publish --dry-run
106+
env:
107+
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
108+
109+
- name: Archive packaged Windows application
110+
if: steps.check_version.outputs.should_release == 'true'
111+
shell: pwsh
112+
run: |
113+
$packageDirectory = 'out/irdashies-win32-x64'
114+
if (-not (Test-Path -LiteralPath $packageDirectory -PathType Container)) {
115+
throw "Packaged application not found: $packageDirectory"
116+
}
117+
Compress-Archive `
118+
-Path "$packageDirectory/*" `
119+
-DestinationPath packaged-windows-app.zip `
120+
-CompressionLevel Optimal
121+
122+
- name: Upload packaged application for signing
123+
id: upload-packaged-app
124+
if: steps.check_version.outputs.should_release == 'true'
125+
uses: actions/upload-artifact@v7
126+
with:
127+
name: unsigned-packaged-windows-app
128+
if-no-files-found: error
129+
path: packaged-windows-app.zip
130+
131+
- name: Sign packaged application
132+
if: steps.check_version.outputs.should_release == 'true'
133+
uses: signpath/github-action-submit-signing-request@v2
134+
with:
135+
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
136+
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
137+
project-slug: irdashies
138+
signing-policy-slug: release-signing
139+
artifact-configuration-slug: packaged-app
140+
github-artifact-id: ${{ steps.upload-packaged-app.outputs.artifact-id }}
141+
wait-for-completion: true
142+
output-artifact-directory: signed-packaged-app
143+
144+
- name: Restore and verify signed packaged application
145+
if: steps.check_version.outputs.should_release == 'true'
146+
shell: pwsh
147+
run: |
148+
$signedArchive = Get-ChildItem `
149+
-LiteralPath signed-packaged-app `
150+
-Filter '*.zip' `
151+
-File `
152+
-Recurse |
153+
Select-Object -First 1
154+
if (-not $signedArchive) {
155+
throw 'SignPath did not return the signed packaged application ZIP.'
156+
}
157+
158+
Expand-Archive `
159+
-LiteralPath $signedArchive.FullName `
160+
-DestinationPath out/irdashies-win32-x64 `
161+
-Force
162+
163+
$appExecutable = 'out/irdashies-win32-x64/irdashies.exe'
164+
$signature = Get-AuthenticodeSignature -LiteralPath $appExecutable
165+
if ($signature.Status -ne 'Valid') {
166+
throw "Packaged application signature is $($signature.Status): $($signature.StatusMessage)"
167+
}
168+
Write-Output "Verified signed packaged application: $($signature.SignerCertificate.Subject)"
169+
170+
- name: Rebuild Squirrel artifacts from signed application
171+
if: steps.check_version.outputs.should_release == 'true'
172+
run: npx electron-forge make --skip-package
173+
174+
- name: Verify signed application in Squirrel package
175+
if: steps.check_version.outputs.should_release == 'true'
176+
shell: pwsh
177+
run: |
178+
$package = Get-ChildItem `
179+
-Path out/make/squirrel.windows/x64 `
180+
-Filter '*.nupkg' `
181+
-File |
182+
Where-Object { $_.Name -notlike '*-delta.nupkg' } |
183+
Select-Object -First 1
184+
if (-not $package) {
185+
throw 'Squirrel full package was not generated.'
186+
}
187+
188+
$extractDirectory = Join-Path $env:RUNNER_TEMP 'irdashies-nupkg'
189+
New-Item -ItemType Directory -Path $extractDirectory -Force | Out-Null
190+
$packageArchive = Join-Path $env:RUNNER_TEMP 'irdashies-nupkg.zip'
191+
Copy-Item -LiteralPath $package.FullName -Destination $packageArchive -Force
192+
Expand-Archive `
193+
-LiteralPath $packageArchive `
194+
-DestinationPath $extractDirectory `
195+
-Force
196+
197+
$packagedExecutable = Get-ChildItem `
198+
-LiteralPath $extractDirectory `
199+
-Recurse `
200+
-Filter 'irdashies.exe' `
201+
-File |
202+
Select-Object -First 1
203+
if (-not $packagedExecutable) {
204+
throw 'irdashies.exe was not found in the Squirrel package.'
205+
}
206+
207+
$signature = Get-AuthenticodeSignature `
208+
-LiteralPath $packagedExecutable.FullName
209+
if ($signature.Status -ne 'Valid') {
210+
throw "Squirrel package contains an application with signature status $($signature.Status)."
211+
}
212+
Write-Output "Verified signed application in $($package.Name)."
213+
214+
- name: Upload unsigned installer for signing
215+
id: upload-unsigned
216+
if: steps.check_version.outputs.should_release == 'true'
217+
uses: actions/upload-artifact@v7
218+
with:
219+
name: unsigned-installer
220+
if-no-files-found: error
221+
path: out/make/squirrel.windows/x64/*Setup.exe
222+
223+
- name: Sign installer with SignPath
224+
if: steps.check_version.outputs.should_release == 'true'
225+
uses: signpath/github-action-submit-signing-request@v2
226+
with:
227+
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
228+
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
229+
project-slug: irdashies
230+
signing-policy-slug: release-signing
231+
artifact-configuration-slug: initial
232+
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
233+
wait-for-completion: true
234+
output-artifact-directory: signed-installer
235+
236+
- name: Swap in the signed installer
237+
if: steps.check_version.outputs.should_release == 'true'
238+
shell: pwsh
239+
run: |
240+
Copy-Item signed-installer/*Setup.exe out/make/squirrel.windows/x64/ -Force
241+
$installer = Get-ChildItem `
242+
-Path out/make/squirrel.windows/x64 `
243+
-Filter '*Setup.exe' `
244+
-File |
245+
Select-Object -First 1
246+
$signature = Get-AuthenticodeSignature -LiteralPath $installer.FullName
247+
if ($signature.Status -ne 'Valid') {
248+
throw "Installer signature is $($signature.Status): $($signature.StatusMessage)"
249+
}
250+
Write-Output "Verified signed installer: $($signature.SignerCertificate.Subject)"
251+
252+
- name: Publish signed release
253+
if: steps.check_version.outputs.should_release == 'true'
254+
run: npx electron-forge publish --from-dry-run
255+
env:
256+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
SignPath artifact configuration for the packaged Windows application.
4+
5+
The CI workflow archives the contents of out/irdashies-win32-x64, so the
6+
application executable is at the root of the uploaded ZIP. This
7+
configuration signs that executable before the Squirrel artifacts are
8+
rebuilt.
9+
10+
This file is the repo copy for review/versioning. The authoritative copy must
11+
also be uploaded in the SignPath portal with the slug "packaged-app". Keep
12+
them in sync. Schema: https://docs.signpath.io/artifact-configuration/
13+
-->
14+
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
15+
<zip-file>
16+
<pe-file path="irdashies.exe" min-matches="1" max-matches="1">
17+
<authenticode-sign />
18+
</pe-file>
19+
</zip-file>
20+
</artifact-configuration>

0 commit comments

Comments
 (0)