Skip to content

Commit 9062f79

Browse files
committed
feat: implement macOS manifest merging script and update CI workflow for improved release process
1 parent b5fc914 commit 9062f79

3 files changed

Lines changed: 238 additions & 8 deletions

File tree

.github/workflows/release.yml

Lines changed: 106 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,36 @@ permissions:
1212
contents: write
1313

1414
jobs:
15-
release:
15+
# Build every distributable in parallel. Nothing publishes here — artifacts go
16+
# to the `publish` job below, which is the only writer of the GitHub release.
17+
#
18+
# macOS is split by architecture on purpose. Notarization is ~3.5 minutes of
19+
# waiting on Apple per arch and electron-builder runs them serially, so a
20+
# combined mac job spent ~7 minutes of its ~10 doing nothing but waiting.
21+
# Running the two arches as concurrent jobs overlaps that wait.
22+
build:
23+
name: build (${{ matrix.label }})
1624
runs-on: ${{ matrix.os }}
1725

1826
strategy:
19-
# One platform's failure shouldn't cancel the other two — a macOS OOM
20-
# took the Linux and Windows builds down with it on v0.11.0, so the run
21-
# reported nothing about whether they would have succeeded.
27+
# One target's failure shouldn't cancel the others — a macOS OOM took the
28+
# Linux and Windows builds down with it on v0.11.0, so the run reported
29+
# nothing about whether they would have succeeded.
2230
fail-fast: false
2331
matrix:
24-
os: [macos-latest, ubuntu-latest, windows-latest]
32+
include:
33+
- os: macos-latest
34+
label: mac-x64
35+
args: --mac --x64
36+
- os: macos-latest
37+
label: mac-arm64
38+
args: --mac --arm64
39+
- os: ubuntu-latest
40+
label: linux
41+
args: --linux
42+
- os: windows-latest
43+
label: win
44+
args: --win
2545

2646
steps:
2747
- name: Check out Git repository
@@ -51,7 +71,7 @@ jobs:
5171
- name: Bundle agent server
5272
run: npm run bundle:agent
5373

54-
- name: Build and Release
74+
- name: Build
5575
uses: samuelmeuli/action-electron-builder@v1
5676
env:
5777
# Vite's sourcemap generation is the memory ceiling on this build: the
@@ -77,6 +97,84 @@ jobs:
7797
VITE_GITHUB_APP_CLIENT_ID: ${{ secrets.VITE_GITHUB_APP_CLIENT_ID }}
7898
VITE_GITHUB_APP_SLUG: ${{ secrets.VITE_GITHUB_APP_SLUG }}
7999
with:
80-
# GitHub token, automatically provided to the action
81100
github_token: ${{ secrets.GITHUB_TOKEN }}
82-
release: true
101+
args: ${{ matrix.args }}
102+
# Publishing happens once, in the `publish` job. Two mac jobs writing
103+
# to the same release would race on latest-mac.yml.
104+
release: false
105+
106+
# Only the distributables. dist-electron also holds the unpacked .app and
107+
# win-unpacked trees, which are hundreds of MB and are already contained
108+
# in the artifacts below.
109+
- name: Collect distributables
110+
uses: actions/upload-artifact@v4
111+
with:
112+
name: dist-${{ matrix.label }}
113+
if-no-files-found: error
114+
retention-days: 7
115+
path: |
116+
dist-electron/*.dmg
117+
dist-electron/*.zip
118+
dist-electron/*.exe
119+
dist-electron/*.AppImage
120+
dist-electron/*.blockmap
121+
dist-electron/*.yml
122+
123+
publish:
124+
name: publish release
125+
needs: build
126+
runs-on: ubuntu-latest
127+
128+
steps:
129+
- name: Check out Git repository
130+
uses: actions/checkout@v4
131+
132+
- name: Install Node.js
133+
uses: actions/setup-node@v4
134+
with:
135+
node-version: 20
136+
137+
# Each artifact lands in its own subdirectory, which is what keeps the two
138+
# macOS `latest-mac.yml` files from overwriting each other before the merge
139+
# below can see them both.
140+
- name: Download build artifacts
141+
uses: actions/download-artifact@v4
142+
with:
143+
path: artifacts
144+
145+
- name: Merge macOS update manifests
146+
run: |
147+
npm install js-yaml@4 --no-save --no-audit --no-fund
148+
node scripts/merge-mac-update-manifest.js artifacts release
149+
150+
- name: Assemble release files
151+
shell: bash
152+
run: |
153+
set -euo pipefail
154+
mkdir -p release
155+
# Everything except latest-mac.yml, which the merge step already wrote.
156+
# -n so nothing can clobber the merged manifest.
157+
find artifacts -type f ! -name 'latest-mac.yml' -exec cp -n {} release/ \;
158+
echo "Release payload:"
159+
ls -lh release/
160+
161+
- name: Create draft release
162+
env:
163+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
164+
shell: bash
165+
run: |
166+
set -euo pipefail
167+
TAG="${GITHUB_REF_NAME}"
168+
TITLE="${TAG#v}"
169+
# Created as a draft, matching how electron-builder's GitHub publisher
170+
# behaved: the release is reviewed and published by hand.
171+
if gh release view "$TAG" >/dev/null 2>&1; then
172+
echo "Release $TAG exists — uploading assets over it."
173+
gh release upload "$TAG" release/* --clobber
174+
else
175+
gh release create "$TAG" release/* \
176+
--draft \
177+
--title "$TITLE" \
178+
--generate-notes
179+
fi
180+
echo "::notice::Draft release $TAG is ready. Review and publish it manually."

eslint.config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import reactRefresh from 'eslint-plugin-react-refresh'
66

77
export default [
88
{ ignores: ['dist'] },
9+
// Build and release tooling runs in Node, not the browser. Without this these
10+
// files report `process`/`__dirname` as undefined globals.
11+
{
12+
files: ['scripts/**/*.js'],
13+
languageOptions: { globals: globals.node },
14+
},
915
{
1016
files: ['**/*.{js,jsx}'],
1117
languageOptions: {
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Merge the per-architecture `latest-mac.yml` files produced by parallel macOS
4+
* release builds into the single manifest electron-updater expects.
5+
*
6+
* Why this exists
7+
* ---------------
8+
* macOS notarization is ~3.5 minutes per architecture and electron-builder runs
9+
* them serially, so a combined x64+arm64 build spends ~7 minutes waiting on
10+
* Apple. Splitting the two architectures into concurrent CI jobs halves that —
11+
* but each job then emits its OWN `latest-mac.yml` describing only the artifacts
12+
* it built. Uploading both to one release is last-writer-wins, which would leave
13+
* the auto-update feed advertising a single architecture and silently strand
14+
* every user on the other one.
15+
*
16+
* So the arch jobs publish nothing, and this merges their manifests.
17+
*
18+
* How electron-updater reads the result
19+
* -------------------------------------
20+
* MacUpdater selects its download by filtering `files[]` on whether the URL
21+
* contains "arm64" — keeping those entries on Apple Silicon and rejecting them
22+
* on Intel. That works only because `artifactName` in electron-builder.json puts
23+
* the arch in the filename (`Redstring-mac-arm64.zip`). The merge below is
24+
* therefore just a union of the `files` arrays; no rewriting is needed.
25+
*
26+
* The top-level `path`/`sha512`/`size` fields are the pre-`files[]` format that
27+
* old updaters still read, and they can only name one artifact. They're pointed
28+
* at the x64 build deliberately: a client old enough to depend on them predates
29+
* Apple Silicon support, so Intel is the safer thing for it to be handed.
30+
*
31+
* Usage: node scripts/merge-mac-update-manifest.js <input-dir> <output-dir>
32+
* input-dir - searched recursively for latest-mac.yml (one per arch job)
33+
* output-dir - receives the single merged latest-mac.yml
34+
*/
35+
36+
import fs from 'fs';
37+
import path from 'path';
38+
import yaml from 'js-yaml';
39+
40+
const [, , inputDir, outputDir] = process.argv;
41+
42+
if (!inputDir || !outputDir) {
43+
console.error('Usage: merge-mac-update-manifest.js <input-dir> <output-dir>');
44+
process.exit(1);
45+
}
46+
47+
/** Every latest-mac.yml under `dir`, at any depth. */
48+
const findManifests = (dir) => {
49+
const found = [];
50+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
51+
const full = path.join(dir, entry.name);
52+
if (entry.isDirectory()) found.push(...findManifests(full));
53+
else if (entry.name === 'latest-mac.yml') found.push(full);
54+
}
55+
return found;
56+
};
57+
58+
const manifestPaths = findManifests(inputDir);
59+
60+
if (manifestPaths.length === 0) {
61+
console.log('[merge-mac] No latest-mac.yml found — nothing to merge.');
62+
process.exit(0);
63+
}
64+
65+
const manifests = manifestPaths.map((file) => ({
66+
file,
67+
doc: yaml.load(fs.readFileSync(file, 'utf8'))
68+
}));
69+
70+
for (const { file, doc } of manifests) {
71+
const urls = (doc?.files ?? []).map((f) => f.url).join(', ');
72+
console.log(`[merge-mac] ${path.relative(inputDir, file)} → [${urls}]`);
73+
}
74+
75+
// A version disagreement means artifacts from two different builds landed in the
76+
// same release. Merging them would produce a manifest that points at a mixture,
77+
// so fail rather than publish something incoherent.
78+
const versions = [...new Set(manifests.map((m) => m.doc?.version).filter(Boolean))];
79+
if (versions.length > 1) {
80+
console.error(`[merge-mac] Refusing to merge: conflicting versions ${versions.join(' vs ')}`);
81+
process.exit(1);
82+
}
83+
84+
// Union the file entries, keyed by url. Duplicates would make an updater
85+
// download the same artifact twice.
86+
const filesByUrl = new Map();
87+
for (const { doc } of manifests) {
88+
for (const entry of doc?.files ?? []) {
89+
if (entry?.url) filesByUrl.set(entry.url, entry);
90+
}
91+
}
92+
const files = [...filesByUrl.values()];
93+
94+
if (files.length === 0) {
95+
console.error('[merge-mac] Refusing to merge: no file entries in any manifest.');
96+
process.exit(1);
97+
}
98+
99+
// Legacy single-artifact fields — Intel by preference, see the header note.
100+
// Fall back to the first entry if this build produced no x64 slice at all.
101+
const legacy = files.find((f) => !f.url.includes('arm64')) ?? files[0];
102+
103+
// Newest wins: the merged manifest describes whichever build finished last.
104+
const releaseDate = manifests
105+
.map((m) => m.doc?.releaseDate)
106+
.filter(Boolean)
107+
.sort()
108+
.pop();
109+
110+
const merged = {
111+
version: versions[0],
112+
files,
113+
path: legacy.url,
114+
sha512: legacy.sha512,
115+
releaseDate
116+
};
117+
if (legacy.size != null) merged.size = legacy.size;
118+
119+
fs.mkdirSync(outputDir, { recursive: true });
120+
const outFile = path.join(outputDir, 'latest-mac.yml');
121+
fs.writeFileSync(outFile, yaml.dump(merged, { lineWidth: -1 }));
122+
123+
console.log(`[merge-mac] Merged ${manifests.length} manifest(s) → ${files.length} artifact(s):`);
124+
for (const f of files) console.log(`[merge-mac] ${f.url}`);
125+
console.log(`[merge-mac] Legacy path field → ${merged.path}`);
126+
console.log(`[merge-mac] Wrote ${outFile}`);

0 commit comments

Comments
 (0)