Skip to content

Commit 1f91a64

Browse files
authored
ci: serve release downloads from verified dev-seeded caches (#1390)
ci: serve release downloads from verified dev-seeded caches github.com release downloads returned 502/504 intermittently for hours on 2026-11-08, failing "Install the Electron binary" in test and build jobs on every OS and Tauri bundling (AppImage runtime, Windows installer tooling). Every job re-downloaded these artifacts on each run. A trusted seeding workflow (push to dev on lockfile/cache changes, twice-weekly schedule, manual dispatch; dev only, contents: read) fills dev-scoped caches for the four runner images: npm, Electron zips and electron-builder's fpm/winCodeSign, Cargo's downloaded crates and the AppImage runtime. Tauri bundler tools are saved only by the scheduled nightly dev build. A restore-only composite action re-verifies what a tool would otherwise trust: Electron zips against electron's checksums.json before app-builder sees them, .crate files against Cargo.lock, and the AppImage runtime against GitHub's published SHA-256. Pull-request and test-signing workflows declare cache-mode: read, so they restore but never create caches; this also stops the per-PR npm cache copies that pushed the repository over its 10 GB cache limit. Cargo target/ is deliberately not cached. On a cache miss or helper failure every tool downloads exactly as before; no retry hides real failures, and no test, signing or verification step changes. Restore steps are skipped when a checked-out branch lacks the new action, so open PRs keep today's behaviour until they merge dev. New download-cache tests (guards, ordering, restore-only, save conditions, verification) and the existing workflow/policy tests pass (55/55); strict YAML and actionlint are clean apart from actionlint's missing cache-mode support. Independent review found no findings.
1 parent 4d72ad5 commit 1f91a64

9 files changed

Lines changed: 674 additions & 2 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
name: Restore download cache
2+
description: >-
3+
Restore one download cache written only by trusted dev runs, then check the
4+
files that Cargo and the Tauri bundler would use unverified. Never saves; a
5+
miss leaves every download to its tool.
6+
7+
inputs:
8+
kind:
9+
description: electron, cargo, tauri or appimage-runtime
10+
required: true
11+
exact:
12+
description: Restore only the exact key (seeding), without an older Cargo cache
13+
required: false
14+
default: "false"
15+
16+
outputs:
17+
key:
18+
description: Cache key; empty when this kind has no cache on this runner
19+
value: ${{ steps.spec.outputs.key }}
20+
path:
21+
description: Cached paths, one per line
22+
value: ${{ steps.spec.outputs.path }}
23+
cache-hit:
24+
description: Whether the exact key was restored
25+
value: ${{ steps.restore.outputs.cache-hit }}
26+
27+
runs:
28+
using: composite
29+
steps:
30+
# Without a key the cache is skipped and every tool downloads as usual.
31+
- name: Resolve ${{ inputs.kind }} cache
32+
id: spec
33+
continue-on-error: true
34+
shell: bash
35+
env:
36+
GH_TOKEN: ${{ github.token }}
37+
run: node "$GITHUB_ACTION_PATH/../../scripts/download-cache.cjs" spec "${{ inputs.kind }}" >> "$GITHUB_OUTPUT"
38+
39+
- name: Restore ${{ inputs.kind }} cache
40+
id: restore
41+
if: ${{ steps.spec.outputs.key != '' }}
42+
uses: actions/cache/restore@v6.1.0
43+
with:
44+
key: ${{ steps.spec.outputs.key }}
45+
path: ${{ steps.spec.outputs.path }}
46+
restore-keys: ${{ inputs.exact != 'true' && steps.spec.outputs.restore-keys || '' }}
47+
48+
- name: Check restored ${{ inputs.kind }} files
49+
if: ${{ steps.restore.outputs.cache-matched-key != '' && inputs.kind != 'electron' }}
50+
shell: bash
51+
env:
52+
CACHE_KEY: ${{ steps.spec.outputs.key }}
53+
CACHE_PATH: ${{ steps.spec.outputs.path }}
54+
run: node "$GITHUB_ACTION_PATH/../../scripts/download-cache.cjs" verify "${{ inputs.kind }}"

‎.github/scripts/download-cache.cjs‎

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// Keys, paths and integrity checks of the CI download caches
2+
// (.github/actions/download-cache, .github/workflows/download-cache.yml).
3+
"use strict"
4+
5+
const crypto = require("node:crypto")
6+
const fs = require("node:fs")
7+
const os = require("node:os")
8+
const path = require("node:path")
9+
const { pathToFileURL } = require("node:url")
10+
11+
const root = path.resolve(__dirname, "../..")
12+
// Assets of releases that change in place ("continuous"): keyed and checked by
13+
// the SHA-256 digest GitHub publishes for them.
14+
const RUNTIME = { repo: "AppImage/type2-runtime", asset: "runtime-x86_64" }
15+
const RUNTIME_URL = `https://github.com/${RUNTIME.repo}/releases/download/continuous/${RUNTIME.asset}`
16+
// tauri-bundler stores this plugin as linuxdeploy-plugin-appimage.AppImage.
17+
const APPIMAGE_PLUGIN = { repo: "linuxdeploy/linuxdeploy-plugin-appimage", asset: "linuxdeploy-plugin-appimage-x86_64.AppImage" }
18+
const APPIMAGE_PLUGIN_FILE = "linuxdeploy-plugin-appimage.AppImage"
19+
20+
const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex")
21+
const read = (file) => fs.readFileSync(path.join(root, file), "utf8")
22+
23+
function lockedVersion(lock, name) {
24+
const version = lock.packages?.[`node_modules/${name}`]?.version
25+
if (!version) throw new Error(`package-lock.json does not lock ${name} at the root`)
26+
return version
27+
}
28+
29+
// Defaults shared by @electron/get (env-paths), app-builder (GetCacheDirectory)
30+
// and tauri-bundler (dirs::cache_dir).
31+
function cacheHome({ env, platform, home }) {
32+
if (platform === "darwin") return path.join(home, "Library", "Caches")
33+
if (platform === "win32") return env.LOCALAPPDATA || path.join(home, "AppData", "Local")
34+
return env.XDG_CACHE_HOME || path.join(home, ".cache")
35+
}
36+
37+
const electronDir = (host) => path.join(cacheHome(host), "electron", host.platform === "win32" ? "Cache" : "")
38+
// build-and-upload packages both macOS architectures on the Intel runner.
39+
const electronTargets = ({ platform, arch }) =>
40+
platform === "darwin" && arch === "x64" ? ["darwin-x64", "darwin-arm64"] : [`${platform}-${arch}`]
41+
42+
function cargoLock(text) {
43+
const crates = new Map()
44+
for (const block of text.replace(/\r\n/g, "\n").split("[[package]]").slice(1)) {
45+
const field = (name) => block.match(new RegExp(`^${name} = "([^"]+)"$`, "m"))?.[1]
46+
if (field("source")?.startsWith("registry+") && field("checksum")) {
47+
crates.set(`${field("name")}-${field("version")}.crate`, field("checksum"))
48+
}
49+
}
50+
return crates
51+
}
52+
53+
function spec(kind, host, inputs) {
54+
const { env, platform, arch, home } = host
55+
const id = `${platform}-${arch}`
56+
if (kind === "electron") {
57+
// A relocated cache would not match the paths seeded here.
58+
if (env.electron_config_cache || env.ELECTRON_CACHE || env.ELECTRON_BUILDER_CACHE) return null
59+
const lock = JSON.parse(inputs.packageLock)
60+
const paths = [electronDir(host)]
61+
// electron-builder's own downloads: fpm (deb) and winCodeSign (rcedit).
62+
if (platform !== "darwin") paths.push(path.join(cacheHome(host), "electron-builder", platform === "win32" ? "Cache" : ""))
63+
const key = `electron-${id}-${lockedVersion(lock, "electron")}-app-builder-bin-${lockedVersion(lock, "app-builder-bin")}`
64+
return { key, paths }
65+
}
66+
if (kind === "cargo") {
67+
const cargoHome = path.join(home, ".cargo")
68+
if (env.CARGO_HOME && path.resolve(env.CARGO_HOME) !== cargoHome) return null
69+
const text = inputs.cargoLock.replace(/\r\n/g, "\n")
70+
// Download caches only: registry/src and target/ are rebuilt by every job.
71+
const paths = [path.join(cargoHome, "registry", "index"), path.join(cargoHome, "registry", "cache")]
72+
if (/^source = "git\+/m.test(text)) paths.push(path.join(cargoHome, "git", "db"))
73+
return { key: `cargo-${id}-${sha256(text)}`, paths, restoreKeys: `cargo-${id}-` }
74+
}
75+
if (kind === "tauri") {
76+
if (platform === "darwin") return null // the macOS app bundle downloads no tools
77+
const linux = platform === "linux"
78+
if (linux && !isDigest(inputs.pluginDigest)) return null
79+
const lock = JSON.parse(inputs.packageLock)
80+
const key = `tauri-${id}-${lockedVersion(lock, "@tauri-apps/cli")}${linux ? `-${inputs.pluginDigest}` : ""}`
81+
return { key, paths: [path.join(cacheHome(host), "tauri")] }
82+
}
83+
if (kind === "appimage-runtime") {
84+
if (platform !== "linux" || arch !== "x64" || !isDigest(inputs.runtimeDigest)) return null
85+
return {
86+
key: `appimage-runtime-x86_64-${inputs.runtimeDigest}`,
87+
paths: [path.join(cacheHome(host), "appimage-type2-runtime", RUNTIME.asset)],
88+
}
89+
}
90+
throw new Error(`Unknown download cache kind: ${kind}`)
91+
}
92+
93+
const isDigest = (value) => /^[0-9a-f]{64}$/.test(value ?? "")
94+
95+
async function releaseDigest({ repo, asset }, env) {
96+
try {
97+
const headers = { accept: "application/vnd.github+json" }
98+
if (env.GH_TOKEN) headers.authorization = `Bearer ${env.GH_TOKEN}`
99+
const response = await fetch(`https://api.github.com/repos/${repo}/releases/tags/continuous`, { headers })
100+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
101+
const digest = (await response.json()).assets?.find((entry) => entry.name === asset)?.digest
102+
return digest?.match(/^sha256:([0-9a-f]{64})$/)?.[1]
103+
} catch (error) {
104+
// stderr: `spec` writes its stdout to $GITHUB_OUTPUT.
105+
console.error(`::notice::No digest for ${repo} ${asset} (${error.message}); its tool downloads it`)
106+
}
107+
}
108+
109+
function outputs(result) {
110+
if (!result) return ""
111+
const lines = [`key=${result.key}`, "path<<__PATHS__", ...result.paths, "__PATHS__"]
112+
if (result.restoreKeys) lines.push(`restore-keys=${result.restoreKeys}`)
113+
return `${lines.join("\n")}\n`
114+
}
115+
116+
// Cargo does not re-check a .crate it finds in registry/cache against Cargo.lock;
117+
// a crate that does not match is removed so Cargo downloads and verifies it.
118+
function verifyCrates(cacheDir, crates) {
119+
let removed = 0
120+
const registries = fs.existsSync(cacheDir) ? fs.readdirSync(cacheDir, { withFileTypes: true }) : []
121+
for (const registry of registries.filter((entry) => entry.isDirectory())) {
122+
const dir = path.join(cacheDir, registry.name)
123+
for (const name of fs.readdirSync(dir)) {
124+
const file = path.join(dir, name)
125+
if (crates.get(name) === sha256(fs.readFileSync(file))) continue
126+
fs.rmSync(file, { force: true })
127+
removed++
128+
}
129+
}
130+
return removed
131+
}
132+
133+
function verifiedFile(file, expected) {
134+
if (!fs.existsSync(file)) return false
135+
if (sha256(fs.readFileSync(file)) === expected) return true
136+
fs.rmSync(file, { force: true })
137+
console.log(`::warning::Removed ${file}: its SHA-256 does not match`)
138+
return false
139+
}
140+
141+
// app-builder neither looks in @electron/get's cache nor checks Electron zips:
142+
// give it each zip @electron/get stores, after checking it against electron/checksums.json.
143+
function linkElectronZips(host, version, checksums) {
144+
const dir = electronDir(host)
145+
for (const target of electronTargets(host)) {
146+
const name = `electron-v${version}-${target}.zip`
147+
const entries = fs.existsSync(dir) ? fs.readdirSync(dir, { withFileTypes: true }) : []
148+
const source = entries.filter((entry) => entry.isDirectory())
149+
.map((entry) => path.join(dir, entry.name, name))
150+
.find((file) => verifiedFile(file, checksums[name]))
151+
if (!source) continue
152+
const destination = path.join(dir, name)
153+
fs.rmSync(destination, { force: true })
154+
try { fs.linkSync(source, destination) } catch { fs.copyFileSync(source, destination) }
155+
console.log(`Verified ${name} for electron-builder`)
156+
}
157+
}
158+
159+
async function main([command, kind]) {
160+
const host = { env: process.env, platform: process.platform, arch: process.arch, home: os.homedir() }
161+
const inputs = () => ({
162+
packageLock: read("package-lock.json"),
163+
cargoLock: read("packages/tauri-app/Cargo.lock"),
164+
})
165+
if (command === "spec") {
166+
const linuxX64 = host.platform === "linux" && host.arch === "x64"
167+
const extra = {}
168+
if (linuxX64 && kind === "appimage-runtime") extra.runtimeDigest = await releaseDigest(RUNTIME, process.env)
169+
if (linuxX64 && kind === "tauri") extra.pluginDigest = await releaseDigest(APPIMAGE_PLUGIN, process.env)
170+
process.stdout.write(outputs(spec(kind, host, { ...inputs(), ...extra })))
171+
} else if (command === "verify" && kind === "cargo") {
172+
const removed = verifyCrates(path.join(host.home, ".cargo", "registry", "cache"), cargoLock(inputs().cargoLock))
173+
if (removed) console.log(`::warning::Removed ${removed} cached crates that Cargo.lock does not verify`)
174+
} else if (command === "verify" && kind === "tauri") {
175+
// tauri-bundler never refreshes a stored plugin: a stale or altered one is removed.
176+
const digest = process.env.CACHE_KEY.split("-").pop()
177+
if (host.platform === "linux") verifiedFile(path.join(process.env.CACHE_PATH, APPIMAGE_PLUGIN_FILE), digest)
178+
} else if (command === "verify" && kind === "appimage-runtime") {
179+
const file = process.env.CACHE_PATH
180+
if (verifiedFile(file, process.env.CACHE_KEY.split("-").pop())) {
181+
fs.appendFileSync(process.env.GITHUB_ENV, `LDAI_RUNTIME_FILE=${file}\n`)
182+
}
183+
} else if (command === "link" && kind === "electron") {
184+
const electron = path.dirname(require.resolve("electron/package.json", { paths: [root] }))
185+
linkElectronZips(host, require(path.join(electron, "package.json")).version, require(path.join(electron, "checksums.json")))
186+
} else if (command === "seed" && kind === "electron") {
187+
const electron = path.dirname(require.resolve("electron/package.json", { paths: [root] }))
188+
const { downloadArtifact } = await import(pathToFileURL(require.resolve("@electron/get", { paths: [root] })))
189+
const { version } = require(path.join(electron, "package.json"))
190+
for (const target of electronTargets(host)) {
191+
const [platform, arch] = target.split("-")
192+
// As electron's install script: checked against the checksums the package ships.
193+
await downloadArtifact({ version, artifactName: "electron", platform, arch, checksums: require(path.join(electron, "checksums.json")) })
194+
}
195+
// The archives electron-builder fetches itself, each checked against app-builder's SHA-512.
196+
const { getBin } = require(require.resolve("app-builder-lib/out/binDownload", { paths: [root] }))
197+
if (host.platform === "linux") await getBin("fpm")
198+
if (host.platform === "win32") await getBin("winCodeSign")
199+
} else if (command === "seed" && kind === "appimage-runtime") {
200+
const response = await fetch(RUNTIME_URL)
201+
const data = Buffer.from(await response.arrayBuffer())
202+
if (!response.ok || sha256(data) !== process.env.CACHE_KEY.split("-").pop()) {
203+
console.log(`::warning::AppImage runtime not seeded (HTTP ${response.status} or digest mismatch)`)
204+
return
205+
}
206+
fs.mkdirSync(path.dirname(process.env.CACHE_PATH), { recursive: true })
207+
fs.writeFileSync(process.env.CACHE_PATH, data, { mode: 0o755 })
208+
fs.appendFileSync(process.env.GITHUB_OUTPUT, "ready=true\n")
209+
} else {
210+
throw new Error(`Usage: download-cache.cjs spec|verify|link|seed <kind>`)
211+
}
212+
}
213+
214+
if (require.main === module) {
215+
main(process.argv.slice(2)).catch((error) => {
216+
console.error(error)
217+
process.exit(1)
218+
})
219+
}
220+
221+
module.exports = { cargoLock, electronTargets, linkElectronZips, outputs, spec, verifyCrates }

0 commit comments

Comments
 (0)