Skip to content

Commit 3b8c8ed

Browse files
author
Yogesh Prajapati
committed
feat(release): add SignPath signing stage between build and publish
Adds a dedicated sign job that downloads unsigned platform artifacts, submits them to SignPath (windows-installer, macos-dmg, linux-packages artifact configs under the release-signing policy), and re-uploads the signed versions. The release job then regenerates latest*.yml manifests with correct sha512 hashes for the signed files before publishing.
1 parent 313499b commit 3b8c8ed

1 file changed

Lines changed: 240 additions & 69 deletions

File tree

.github/workflows/release.yml

Lines changed: 240 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -8,45 +8,27 @@ on:
88
permissions:
99
contents: write
1010
actions: read
11+
id-token: write # Required for SignPath Trusted Build System authentication
1112

1213
jobs:
13-
# ───────────────────────────────────────────────────────────────────────────
14-
# JOB 1 — BUILD (matrix across Windows + macOS + Linux)
15-
# Each runner compiles its own native artifacts and publishes them to the
16-
# GitHub release. electron-updater needs the per-platform manifest files
17-
# (latest.yml, latest-mac.yml, latest-linux.yml) AND the artifacts to sit
18-
# on the same release — `--publish always` handles both in one step.
19-
# ───────────────────────────────────────────────────────────────────────────
14+
# ─────────────────────────────────────────────────────────────────────────────
15+
# JOB 1 — BUILD (matrix: Windows, macOS, Linux)
16+
# Produces unsigned installers. Does NOT publish to GitHub — signing job
17+
# owns the final upload so the release only ever contains signed artifacts.
18+
# ─────────────────────────────────────────────────────────────────────────────
2019
build:
2120
name: Build (${{ matrix.os }})
2221
strategy:
23-
fail-fast: false # one platform's failure shouldn't block the others
22+
fail-fast: false
2423
matrix:
2524
include:
2625
- os: windows-latest
2726
target_flag: --win
28-
artifact_glob: |
29-
dist/*.exe
30-
dist/*.zip
31-
dist/latest.yml
32-
dist/*.blockmap
3327
- os: macos-latest
3428
target_flag: --mac
35-
artifact_glob: |
36-
dist/*.dmg
37-
dist/*.zip
38-
dist/latest-mac.yml
39-
dist/*.blockmap
4029
- os: ubuntu-latest
4130
target_flag: --linux
42-
artifact_glob: |
43-
dist/*.AppImage
44-
dist/*.deb
45-
dist/latest-linux.yml
46-
dist/*.blockmap
4731
runs-on: ${{ matrix.os }}
48-
outputs:
49-
version: ${{ steps.version.outputs.VERSION }}
5032

5133
steps:
5234
- name: Checkout code
@@ -58,17 +40,10 @@ jobs:
5840
node-version: '20'
5941
cache: 'npm'
6042

61-
# Linux-only: AppImage builds need libfuse2 on the GH runner image.
6243
- name: Install libfuse2 (Linux only)
6344
if: matrix.os == 'ubuntu-latest'
6445
run: sudo apt-get update && sudo apt-get install -y libfuse2
6546

66-
# Electron's postinstall downloads a ~100 MB binary from GitHub which
67-
# flakes on CI runners with "socket hang up" once in a while (saw it
68-
# on macos-latest during v1.6.1). Retry up to 3 times with backoff,
69-
# but stop retrying as soon as it succeeds. We deliberately run
70-
# postinstall scripts (no --ignore-scripts) because electron-builder
71-
# needs the Electron binary that install.js fetches.
7247
- name: Install dependencies (with retry on network flakes)
7348
shell: bash
7449
run: |
@@ -87,62 +62,258 @@ jobs:
8762
echo "npm ci failed after 3 attempts"
8863
exit 1
8964
90-
# Run build:wb explicitly so a postinstall-time failure here is
91-
# immediately distinguishable from a dependency-install failure.
92-
# (The postinstall hook in package.json runs this too — re-running
93-
# it is cheap and idempotent.)
9465
- name: Build whiteboard iframe bundle
9566
run: npm run build:wb
9667

97-
- name: Build & publish installer (electron-builder publishes to GitHub release)
98-
run: npx electron-builder ${{ matrix.target_flag }} --publish always
68+
- name: Build unsigned installers (no publish)
69+
run: npx electron-builder ${{ matrix.target_flag }} --publish never
9970
env:
100-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
101-
# Skip Mac code-signing in CI — we ship unsigned builds for now.
102-
# Users on macOS will need to right-click → Open the first time.
71+
# Skip local code-signing — SignPath handles signing in the next job.
10372
CSC_IDENTITY_AUTO_DISCOVERY: false
10473

105-
- name: Upload platform artifacts (for the release job)
74+
- name: Upload unsigned Windows artifacts
75+
if: matrix.os == 'windows-latest'
10676
uses: actions/upload-artifact@v4
10777
with:
108-
name: dist-${{ matrix.os }}
109-
path: ${{ matrix.artifact_glob }}
110-
if-no-files-found: warn
78+
name: unsigned-windows
79+
path: |
80+
dist/*.exe
81+
dist/*.zip
82+
if-no-files-found: error
11183

112-
- name: Get version from tag
113-
id: version
114-
shell: bash
115-
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
116-
117-
# ───────────────────────────────────────────────────────────────────────────
118-
# JOB 2 — FINALISE RELEASE
119-
# electron-builder --publish always has already uploaded every artifact
120-
# in the build job. This job's ONLY purpose is to finalise the GitHub
121-
# release: set a friendly title, generate the auto-changelog, and flip
122-
# the 'Latest' badge.
84+
- name: Upload unsigned macOS artifacts
85+
if: matrix.os == 'macos-latest'
86+
uses: actions/upload-artifact@v4
87+
with:
88+
name: unsigned-macos
89+
path: |
90+
dist/*.dmg
91+
dist/*.zip
92+
if-no-files-found: error
93+
94+
- name: Upload unsigned Linux artifacts
95+
if: matrix.os == 'ubuntu-latest'
96+
uses: actions/upload-artifact@v4
97+
with:
98+
name: unsigned-linux
99+
path: |
100+
dist/*.AppImage
101+
dist/*.deb
102+
if-no-files-found: error
103+
104+
# ─────────────────────────────────────────────────────────────────────────────
105+
# JOB 2 — SIGN (SignPath)
106+
# Downloads unsigned artifacts, submits each platform to SignPath, and
107+
# re-uploads the signed versions for the release job to consume.
123108
#
124-
# IMPORTANT: we do NOT pass `files:` to softprops. If we did, it would
125-
# re-upload everything in dist/ with its OWN filename sanitization
126-
# (which strips '+' chars from "Note++" and produces mangled
127-
# `Note.-1.7.0-x64.zip.blockmap` style names), bloating the release
128-
# to 27+ assets with confusing duplicates. Electron-builder is the
129-
# sole authoritative uploader.
130-
# ───────────────────────────────────────────────────────────────────────────
131-
release:
132-
name: Finalise GitHub Release
109+
# Prerequisites — configure once in GitHub repository settings:
110+
# Secrets → SIGNPATH_API_TOKEN (Settings → Secrets → Actions)
111+
# Variables → SIGNPATH_ORGANIZATION_ID (Settings → Variables → Actions)
112+
# SIGNPATH_PROJECT_SLUG
113+
#
114+
# In the SignPath dashboard you must also create:
115+
# • One Artifact Configuration per platform (windows-installer, macos-dmg,
116+
# linux-packages) that selects the files to sign and the certificate.
117+
# • One Signing Policy per platform that references those configurations.
118+
# • Optional: add this repo as a Trusted Build System (uses the
119+
# id-token: write permission above for keyless OIDC auth, eliminating
120+
# the need for SIGNPATH_API_TOKEN).
121+
#
122+
# macOS note: SignPath handles the Developer ID code-signing step. Apple
123+
# notarization (xcrun notarytool) is a separate process — add it here
124+
# after signing if you want full Gatekeeper trust without right-click → Open.
125+
# ─────────────────────────────────────────────────────────────────────────────
126+
sign:
127+
name: Sign (SignPath)
133128
needs: build
134129
runs-on: ubuntu-latest
135130

136131
steps:
137-
- name: Finalise release (title + changelog + 'Latest' badge)
132+
- name: Download unsigned Windows artifacts
133+
uses: actions/download-artifact@v4
134+
with:
135+
name: unsigned-windows
136+
path: ./unsigned/windows/
137+
138+
- name: Download unsigned macOS artifacts
139+
uses: actions/download-artifact@v4
140+
with:
141+
name: unsigned-macos
142+
path: ./unsigned/macos/
143+
144+
- name: Download unsigned Linux artifacts
145+
uses: actions/download-artifact@v4
146+
with:
147+
name: unsigned-linux
148+
path: ./unsigned/linux/
149+
150+
# ── Windows (Authenticode) ───────────────────────────────────────────────
151+
- name: Sign Windows installer
152+
uses: SignPath/github-action-submit-signing-request@v1
153+
with:
154+
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
155+
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
156+
project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }}
157+
signing-policy-slug: release-signing
158+
artifact-configuration-slug: windows-installer
159+
input-artifact-path: ./unsigned/windows/
160+
wait-for-completion: true
161+
output-artifact-directory: ./signed/windows/
162+
163+
# ── macOS (Apple Developer ID) ───────────────────────────────────────────
164+
# Requires an Apple Developer ID Application certificate loaded in SignPath.
165+
- name: Sign macOS disk image
166+
uses: SignPath/github-action-submit-signing-request@v1
167+
with:
168+
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
169+
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
170+
project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }}
171+
signing-policy-slug: release-signing
172+
artifact-configuration-slug: macos-dmg
173+
input-artifact-path: ./unsigned/macos/
174+
wait-for-completion: true
175+
output-artifact-directory: ./signed/macos/
176+
177+
# ── Linux (GPG) ──────────────────────────────────────────────────────────
178+
# Requires a GPG key configured in SignPath.
179+
# To sign .deb and .AppImage separately, duplicate this step with a
180+
# different artifact-configuration-slug for each format.
181+
- name: Sign Linux packages
182+
uses: SignPath/github-action-submit-signing-request@v1
183+
with:
184+
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
185+
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
186+
project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }}
187+
signing-policy-slug: release-signing
188+
artifact-configuration-slug: linux-packages
189+
input-artifact-path: ./unsigned/linux/
190+
wait-for-completion: true
191+
output-artifact-directory: ./signed/linux/
192+
193+
- name: Upload signed Windows artifacts
194+
uses: actions/upload-artifact@v4
195+
with:
196+
name: signed-windows
197+
path: ./signed/windows/
198+
if-no-files-found: error
199+
200+
- name: Upload signed macOS artifacts
201+
uses: actions/upload-artifact@v4
202+
with:
203+
name: signed-macos
204+
path: ./signed/macos/
205+
if-no-files-found: error
206+
207+
- name: Upload signed Linux artifacts
208+
uses: actions/upload-artifact@v4
209+
with:
210+
name: signed-linux
211+
path: ./signed/linux/
212+
if-no-files-found: error
213+
214+
# ─────────────────────────────────────────────────────────────────────────────
215+
# JOB 3 — PUBLISH & FINALISE RELEASE
216+
# Downloads signed artifacts, regenerates the electron-updater manifests
217+
# (latest*.yml) with correct sha512 hashes for the signed files, then
218+
# uploads everything to GitHub and marks the release as Latest.
219+
#
220+
# Why regenerate manifests? Signing changes file content, so the sha512
221+
# hashes electron-builder embedded in the original latest*.yml no longer
222+
# match. electron-updater would reject a signed update whose hash differs
223+
# from the manifest — regenerating here keeps auto-update working correctly.
224+
#
225+
# Blockmaps: signing invalidates existing .blockmap files. We omit blockmap
226+
# references from the regenerated manifests; electron-updater gracefully
227+
# falls back to a full download instead of a binary delta.
228+
# ─────────────────────────────────────────────────────────────────────────────
229+
release:
230+
name: Publish & Finalise Release
231+
needs: sign
232+
runs-on: ubuntu-latest
233+
234+
steps:
235+
- name: Download signed Windows artifacts
236+
uses: actions/download-artifact@v4
237+
with:
238+
name: signed-windows
239+
path: ./signed/windows/
240+
241+
- name: Download signed macOS artifacts
242+
uses: actions/download-artifact@v4
243+
with:
244+
name: signed-macos
245+
path: ./signed/macos/
246+
247+
- name: Download signed Linux artifacts
248+
uses: actions/download-artifact@v4
249+
with:
250+
name: signed-linux
251+
path: ./signed/linux/
252+
253+
- name: Set up Node.js (for manifest generation)
254+
uses: actions/setup-node@v4
255+
with:
256+
node-version: '20'
257+
258+
- name: Regenerate electron-updater manifests from signed files
259+
shell: node {0}
260+
run: |
261+
const crypto = require('crypto');
262+
const fs = require('fs');
263+
const path = require('path');
264+
265+
const version = process.env.GITHUB_REF_NAME;
266+
const releaseDate = new Date().toISOString();
267+
268+
function sha512b64(p) {
269+
return crypto.createHash('sha512').update(fs.readFileSync(p)).digest('base64');
270+
}
271+
function byExt(dir, ext) {
272+
if (!fs.existsSync(dir)) return [];
273+
return fs.readdirSync(dir).filter(f => f.endsWith(ext)).map(f => path.join(dir, f));
274+
}
275+
function entry(p) {
276+
return ` - url: ${path.basename(p)}\n sha512: ${sha512b64(p)}\n size: ${fs.statSync(p).size}\n`;
277+
}
278+
function write(file, primary, files) {
279+
const entries = files.map(entry).join('');
280+
fs.writeFileSync(file,
281+
`version: ${version}\nfiles:\n${entries}` +
282+
`path: ${path.basename(primary)}\nsha512: ${sha512b64(primary)}\nreleaseDate: '${releaseDate}'\n`
283+
);
284+
console.log(`Generated ${file}`);
285+
}
286+
287+
// latest.yml — Windows (.exe)
288+
const exes = byExt('./signed/windows', '.exe');
289+
if (exes.length) write('latest.yml', exes[0], exes);
290+
291+
// latest-mac.yml — macOS (.dmg + .zip)
292+
const dmgs = byExt('./signed/macos', '.dmg');
293+
if (dmgs.length) write('latest-mac.yml', dmgs[0], [...dmgs, ...byExt('./signed/macos', '.zip')]);
294+
295+
// latest-linux.yml — Linux (.AppImage; .deb listed as supplementary)
296+
const imgs = byExt('./signed/linux', '.AppImage');
297+
if (imgs.length) write('latest-linux.yml', imgs[0], [...imgs, ...byExt('./signed/linux', '.deb')]);
298+
299+
- name: Publish signed artifacts + manifests to GitHub release
138300
uses: softprops/action-gh-release@v2
139301
with:
140-
name: Note++ ${{ needs.build.outputs.VERSION }}
141-
tag_name: ${{ needs.build.outputs.VERSION }}
302+
name: Note++ ${{ github.ref_name }}
303+
tag_name: ${{ github.ref_name }}
142304
draft: false
143305
prerelease: false
144306
make_latest: 'true'
145307
generate_release_notes: true
146-
# no `files:` — electron-builder is the sole uploader
308+
files: |
309+
signed/windows/*.exe
310+
signed/windows/*.zip
311+
signed/macos/*.dmg
312+
signed/macos/*.zip
313+
signed/linux/*.AppImage
314+
signed/linux/*.deb
315+
latest.yml
316+
latest-mac.yml
317+
latest-linux.yml
147318
env:
148319
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

0 commit comments

Comments
 (0)