trilium/scripts/update-flake-electron.ts
Elian Doran ae34d8ad0d
build(nix): pin Electron 44, which drops armv7l and bundled ANGLE
The Update Nix flake workflow has been failing since the bump to Electron
44 landed on main, for two independent reasons.

parseShasums treated every entry of ELECTRON_PLATFORM_TAGS as mandatory,
and Electron 44 ships no linux-armv7l assets at all — 43.4.1 lists seven
armv7l entries in SHASUMS256.txt, 44.0.0 lists none:

  Error: SHASUMS256.txt for Electron 44.0.0 is missing:
  electron-v44.0.0-linux-armv7l.zip

Drop armv7l-linux from the table, and x86_64-darwin with it: the nixpkgs
rev in flake.lock (6d12004) lists only x86_64-linux, armv7l-linux,
aarch64-linux and aarch64-darwin in generic.nix's `tags`, so that hash
could never have been read. Keep the hard throw on a missing asset —
shrinking the platform set is a decision for a human, not something to
paper over by silently pinning fewer systems.

Refreshing the hashes then got the job as far as the verification step,
which failed on its own:

  patchelf: missing filename

The nixpkgs Linux builder rewrites the rpath of Electron's ANGLE
libraries with an unguarded `patchelf ... lib*GL*`. Electron 43.4.1
shipped libEGL.so and libGLESv2.so; 44.0.0 links ANGLE into the main
binary and ships neither, leaving only libffmpeg.so, libvk_swiftshader.so
and libvulkan.so.1, so the glob expands to nothing. There is nothing
upstream to pull — nixpkgs master packages Electron 41-43 only and still
has the unguarded glob — so override that one command to tolerate an
empty match, guarded by a throwIf that fails loudly if the upstream text
changes rather than silently no-opping. Linux only, because the Darwin
branch of the builder defines no postFixup.

`nix build --no-link '.#electron' '.#electron.headers'` now succeeds and
the result reports v44.0.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 00:05:17 +03:00

199 lines
7.7 KiB
TypeScript

/**
* Keeps the Electron binary pinned in `flake.nix` in sync with the version
* `apps/desktop/package.json` depends on.
*
* nixpkgs lags behind the Electron releases Trilium tracks, so the flake builds
* the exact pinned version from Electron's official binary release rather than
* taking `pkgs.electron_<major>` (see the comment above `pinnedElectronVersion`).
* Every Electron bump therefore has to be mirrored into the flake, or it refuses
* to evaluate:
*
* error: flake.nix pins Electron 43.2.0, but apps/desktop/package.json wants 43.3.0
*
* This script is that mirroring, automated: it reads the wanted version, takes
* the per-platform zip checksums from the release's own `SHASUMS256.txt`,
* computes the headers hash with `nix-prefetch-url`, and rewrites both
* `pinnedElectron*` bindings. It is a no-op when the pin already matches, so it
* is safe to run on a schedule.
*
* Run on demand or from the `update-nix-flake.yml` workflow, which opens a PR when
* the flake changes. Usage:
*
* pnpm chore:update-flake-electron
*
* Requires `nix-prefetch-url` on PATH — the headers hash is a NAR hash of the
* unpacked tarball, which only Nix can compute.
*/
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const SCRIPT_PATH = fileURLToPath(import.meta.url);
const ROOT = resolve(dirname(SCRIPT_PATH), "..");
const FLAKE_PATH = join(ROOT, "flake.nix");
const DESKTOP_PACKAGE_JSON_PATH = join(ROOT, "apps", "desktop", "package.json");
export async function main() {
const flake = readFileSync(FLAKE_PATH, "utf-8");
const wanted = readWantedElectronVersion(readFileSync(DESKTOP_PACKAGE_JSON_PATH, "utf-8"));
const pinned = readPinnedElectronVersion(flake);
if (wanted === pinned) {
console.log(`flake.nix already pins Electron ${pinned}; nothing to do.`);
return;
}
console.log(`Electron ${pinned} -> ${wanted}; refreshing the pin.`);
const hashes = {
...parseShasums(await fetchShasums(wanted), wanted),
headers: prefetchHeadersHash(wanted)
};
writeFileSync(FLAKE_PATH, rewriteFlake(flake, wanted, hashes));
console.log(`Wrote ${FLAKE_PATH}`);
for (const [ key, hash ] of Object.entries(hashes)) {
console.log(` ${key} = ${hash}`);
}
}
/**
* Nix system → the tag Electron uses in its release asset names, in the order the
* generated `pinnedElectronHashes` block lists them. A system belongs here when the
* Electron release ships that zip and `tags` in nixpkgs'
* `pkgs/development/tools/electron/binary/generic.nix` — the builder the flake
* reuses — covers it.
*/
export const ELECTRON_PLATFORM_TAGS: Record<string, string> = {
"x86_64-linux": "linux-x64",
"aarch64-linux": "linux-arm64",
"aarch64-darwin": "darwin-arm64"
};
/** Per-platform zip checksums plus the `headers` NAR hash, keyed as the flake expects. */
export type ElectronHashes = Record<string, string> & { headers: string };
/**
* The flake pins one exact build, so a range (`^43.3.0`) has no single answer —
* refuse rather than guess at what the lockfile happens to resolve to today.
*/
export function readWantedElectronVersion(packageJson: string): string {
const version = JSON.parse(packageJson)?.devDependencies?.electron;
if (typeof version !== "string") {
throw new Error("apps/desktop/package.json has no devDependencies.electron entry.");
}
if (!/^\d+\.\d+\.\d+$/.test(version)) {
throw new Error(
`apps/desktop/package.json must pin an exact Electron version, found "${version}".`
);
}
return version;
}
export function readPinnedElectronVersion(flake: string): string {
const match = flake.match(/^\s*pinnedElectronVersion = "([^"]+)";$/m);
if (!match) {
throw new Error("Could not find the pinnedElectronVersion binding in flake.nix.");
}
return match[1];
}
async function fetchShasums(version: string): Promise<string> {
const url = `https://github.com/electron/electron/releases/download/v${version}/SHASUMS256.txt`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch ${url}: HTTP ${response.status}. Is Electron ${version} released?`
);
}
return response.text();
}
/**
* Pull the per-platform zip checksums out of a release's `SHASUMS256.txt`, whose
* lines are `<sha256> *<asset name>`. The file also lists chromedriver, symbol and
* mas builds, so match asset names exactly rather than by substring.
*
* Throw on a missing asset rather than emitting a partial set: Electron dropping a
* platform has to shrink `ELECTRON_PLATFORM_TAGS` and the flake together, which is a
* decision for a human, not something to paper over by silently pinning fewer systems.
*/
export function parseShasums(shasums: string, version: string): Record<string, string> {
const byAsset = new Map<string, string>();
for (const line of shasums.split("\n")) {
const match = line.match(/^([0-9a-f]{64}) \*(.+)$/);
if (match) {
byAsset.set(match[2].trim(), match[1]);
}
}
const hashes: Record<string, string> = {};
const missing: string[] = [];
for (const [ system, tag ] of Object.entries(ELECTRON_PLATFORM_TAGS)) {
const asset = `electron-v${version}-${tag}.zip`;
const hash = byAsset.get(asset);
if (hash) {
hashes[system] = hash;
} else {
missing.push(asset);
}
}
if (missing.length > 0) {
throw new Error(`SHASUMS256.txt for Electron ${version} is missing: ${missing.join(", ")}`);
}
return hashes;
}
/**
* The headers are consumed with `fetchzip`, so the flake needs the NAR hash of the
* *unpacked* tarball rather than its sha256 — hence shelling out to Nix.
*/
function prefetchHeadersHash(version: string): string {
const tarball = `node-v${version}-headers.tar.gz`;
const url = `https://artifacts.electronjs.org/headers/dist/v${version}/${tarball}`;
try {
return execFileSync("nix-prefetch-url", [ "--unpack", url ], { encoding: "utf-8" }).trim();
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error(
"nix-prefetch-url is not on PATH; install Nix to refresh the headers hash."
);
}
throw err;
}
}
export function rewriteFlake(flake: string, version: string, hashes: ElectronHashes): string {
const versionPattern = /^([ \t]*)pinnedElectronVersion = "[^"]*";$/m;
const hashesPattern = /^([ \t]*)pinnedElectronHashes = \{[\s\S]*?^\1\};$/m;
if (!versionPattern.test(flake) || !hashesPattern.test(flake)) {
throw new Error(
"Could not find the pinnedElectronVersion/pinnedElectronHashes bindings in flake.nix."
);
}
return flake
.replace(
versionPattern,
(_match, indent: string) => `${indent}pinnedElectronVersion = "${version}";`
)
.replace(hashesPattern, (_match, indent: string) => renderHashesBlock(indent, hashes));
}
function renderHashesBlock(indent: string, hashes: ElectronHashes): string {
const inner = `${indent} `;
const lines = Object.keys(ELECTRON_PLATFORM_TAGS)
.map((system) => `${inner}${system} = "${hashes[system]}";`);
lines.push(`${inner}headers = "${hashes.headers}";`);
return `${indent}pinnedElectronHashes = {\n${lines.join("\n")}\n${indent}};`;
}
// Only when run as a script — the pure helpers above are imported by the spec.
if (process.argv[1] === SCRIPT_PATH) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}