235 lines
10 KiB
JavaScript
235 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
// Asserts that the telemetry compile-time constants — `ORCA_BUILD_IDENTITY`
|
|
// and `ORCA_POSTHOG_WRITE_KEY` — were substituted into the shipped binary.
|
|
// Closes the gap PR #1385 flagged: a release built without those env vars
|
|
// produces an `IS_OFFICIAL_BUILD === false` binary that ships silently and
|
|
// transmits nothing. This script runs after `electron-builder` has packed
|
|
// the app and BEFORE the GitHub release is flipped from draft → published.
|
|
// A failure here fails the matrix job; the draft release stays in place
|
|
// until a human resolves it.
|
|
//
|
|
// We grep the packed `app.asar`'s `out/main/*.js` files (the same files
|
|
// that the runtime loads) rather than the unpacked `out/`. asar is a
|
|
// tar-like archive that doesn't transform contents, so the two are
|
|
// byte-equivalent today — but verifying the asar protects against any
|
|
// future config change that excludes the main bundle from the package.
|
|
//
|
|
// Forward-compat: while `TELEMETRY_ENABLED` is `false` in the source, the
|
|
// bundler dead-code-eliminates the entire transport block, so the
|
|
// `BUILD_IDENTITY = "..."` and `WRITE_KEY = "phc_..."` constants do not
|
|
// appear in the binary even when env vars are correctly injected. This
|
|
// script reads the source flag at build time and skips the assertion in
|
|
// that case — the moment the flag flips to `true` (PR #1385), every
|
|
// subsequent release is enforced.
|
|
//
|
|
// Cross-platform note: written in Node so it runs identically on the Mac,
|
|
// Linux, and Windows release runners. Locating `app.asar` via `fs.readdir`
|
|
// (instead of POSIX `find`) avoids depending on Git Bash on Windows.
|
|
// Reading the asar via the programmatic `@electron/asar` API (instead of
|
|
// shelling out to `npx asar`) avoids both a network fetch on every run
|
|
// and the Windows `.cmd`-shim/`shell: true` workaround.
|
|
|
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
import { join, resolve } from 'node:path'
|
|
// Why @electron/asar: canonical replacement for the deprecated `asar` package.
|
|
// It's transitively available via electron-builder (and pnpm's
|
|
// `shamefully-hoist=true` in `.npmrc` flattens it into the root
|
|
// `node_modules`). If electron-builder ever drops it, promote this to a
|
|
// direct devDependency in package.json.
|
|
import { extractFile, listPackage } from '@electron/asar'
|
|
|
|
// Why resolve from import.meta.url instead of cwd: a release runner (or a
|
|
// developer debugging locally) may invoke this script from a non-root cwd.
|
|
// Resolving relative to the script's own location turns a misleading
|
|
// "could not parse TELEMETRY_ENABLED flag" parse error into a clear
|
|
// file-not-found error, and decouples the script from the caller's cwd.
|
|
const repoRoot = resolve(import.meta.dirname, '..', '..')
|
|
|
|
function findAsar(rootDir) {
|
|
// Why: electron-builder produces one `app.asar` per platform-arch combo.
|
|
// Linux/Windows targets ship one (`dist/linux-unpacked/resources/app.asar`,
|
|
// `dist/win-unpacked/resources/app.asar`); macOS dual-arch ships two
|
|
// (`dist/mac/Orca.app/Contents/Resources/app.asar` for x64,
|
|
// `dist/mac-arm64/Orca.app/Contents/Resources/app.asar` for arm64) because
|
|
// `electron-builder.config.cjs` declares `arch: ['x64', 'arm64']`. Both
|
|
// arches share the same JS bundle through electron-vite's single `main`
|
|
// build, so the constants are identical across them — but verifying every
|
|
// match catches the regression where one arch's pack drifts (e.g. a future
|
|
// arch-specific bundle split that forgets to thread the `define` block).
|
|
const matches = []
|
|
const stack = [rootDir]
|
|
while (stack.length > 0) {
|
|
const dir = stack.pop()
|
|
let entries
|
|
try {
|
|
entries = readdirSync(dir, { withFileTypes: true })
|
|
} catch {
|
|
continue
|
|
}
|
|
for (const entry of entries) {
|
|
const fullPath = join(dir, entry.name)
|
|
if (entry.isDirectory()) {
|
|
stack.push(fullPath)
|
|
} else if (entry.isFile() && entry.name === 'app.asar') {
|
|
matches.push(fullPath)
|
|
}
|
|
}
|
|
}
|
|
return matches
|
|
}
|
|
|
|
const distDir = process.argv[2] ?? 'dist'
|
|
if (!existsSync(distDir) || !statSync(distDir).isDirectory()) {
|
|
console.error(`::error::dist directory not found at ${distDir}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
// Why: with `TELEMETRY_ENABLED = false` in source, Rollup eliminates the
|
|
// transport block and the substituted constants vanish from the binary.
|
|
// Verifying in that state would always fail. Read the flag from source
|
|
// instead of inferring it from the build, so the gate cannot drift.
|
|
const clientSrcPath = join(repoRoot, 'src/main/telemetry/client.ts')
|
|
const clientSrc = readFileSync(clientSrcPath, 'utf8')
|
|
const enabledMatch = /^const\s+TELEMETRY_ENABLED\s*=\s*(true|false)/m.exec(clientSrc)
|
|
if (!enabledMatch) {
|
|
console.error(`::error::could not parse TELEMETRY_ENABLED flag from ${clientSrcPath}`)
|
|
process.exit(1)
|
|
}
|
|
if (enabledMatch[1] === 'false') {
|
|
console.log(
|
|
'TELEMETRY_ENABLED is false in source — transport is dead-code-eliminated, ' +
|
|
'so the BUILD_IDENTITY/WRITE_KEY constants are not expected in the binary. ' +
|
|
'Skipping asar grep. (Once the flag flips to true, this verify becomes enforcing.)'
|
|
)
|
|
process.exit(0)
|
|
}
|
|
|
|
const asarMatches = findAsar(distDir)
|
|
if (asarMatches.length === 0) {
|
|
console.error(`::error::could not locate app.asar under ${distDir}`)
|
|
process.exit(1)
|
|
}
|
|
console.log(`Found ${asarMatches.length} app.asar payload(s) under ${distDir}:`)
|
|
for (const m of asarMatches) {
|
|
console.log(` - ${m}`)
|
|
}
|
|
|
|
// Why these regexes: electron-vite's `define` block substitutes the bare
|
|
// identifiers `ORCA_BUILD_IDENTITY` and `ORCA_POSTHOG_WRITE_KEY` with their
|
|
// JSON-stringified values at build time. `src/main/telemetry/client.ts`
|
|
// then assigns those into module-local consts named `BUILD_IDENTITY` and
|
|
// `WRITE_KEY`. electron-vite's main config is not minified (Vite default for
|
|
// Electron main builds), so Rollup emits the substituted constants verbatim
|
|
// as `const BUILD_IDENTITY = "stable";`. Match that exact emitted shape so a
|
|
// regression — e.g. the env var unset and the substitution falling back to
|
|
// literal `null` — fails the grep instead of slipping through as a falsy-
|
|
// but-stringy value. NOTE: if `build.minify` is ever enabled on the main
|
|
// bundle, esbuild/terser will rename top-level consts and this regex must
|
|
// be revisited (or replaced with a value-based assertion).
|
|
//
|
|
// WRITE_KEY char class includes `_` and `-` because PostHog project API
|
|
// keys use URL-safe base64 alphabet beyond `phc_`.
|
|
const BUILD_IDENTITY_RE = /const\s+BUILD_IDENTITY\s*=\s*"(rc|stable)"/
|
|
const WRITE_KEY_RE = /const\s+WRITE_KEY\s*=\s*"(phc_[A-Za-z0-9_-]+)"/
|
|
|
|
function verifyAsar(asarPath) {
|
|
console.log(`Verifying ${asarPath}`)
|
|
|
|
// Why list-then-extract (not a hardcoded path): future electron-vite
|
|
// chunking (e.g. `manualChunks`) could move the constants out of
|
|
// `out/main/index.js` into `out/main/chunks/telemetry-XYZ.js`. Enumerate
|
|
// every `.js` under `out/main/` and concatenate before grepping so the
|
|
// verify is resilient to chunking. listPackage builds entries with the
|
|
// host `path` module, so on Windows runners separators are backslashes
|
|
// (`\out\main\index.js`); normalize to forward slashes for the filter
|
|
// and strip the leading separator (of either kind) before passing the
|
|
// entry to extractFile.
|
|
const allEntries = listPackage(asarPath)
|
|
const mainJsEntries = allEntries.filter((p) => {
|
|
const normalized = p.replace(/\\/g, '/').replace(/^\/+/, '')
|
|
return normalized.startsWith('out/main/') && normalized.endsWith('.js')
|
|
})
|
|
|
|
if (mainJsEntries.length === 0) {
|
|
console.error(`::error::no .js files found under out/main/ in ${asarPath}`)
|
|
return null
|
|
}
|
|
|
|
const indexJs = mainJsEntries
|
|
.map((entry) => {
|
|
// extractFile uses the host path module internally; pass the
|
|
// host-separator path with the leading separator stripped.
|
|
const internal = entry.replace(/^[\\/]+/, '')
|
|
return extractFile(asarPath, internal).toString('utf8')
|
|
})
|
|
.join('\n')
|
|
|
|
const buildIdentityMatch = BUILD_IDENTITY_RE.exec(indexJs)
|
|
const writeKeyMatch = WRITE_KEY_RE.exec(indexJs)
|
|
|
|
if (!buildIdentityMatch) {
|
|
console.error(`::error::BUILD_IDENTITY constant missing or unexpected value in ${asarPath}`)
|
|
const sample = indexJs.match(/.{0,80}BUILD_IDENTITY.{0,80}/g)?.slice(0, 5) ?? []
|
|
for (const line of sample) {
|
|
console.error(` ${line.slice(0, 200)}`)
|
|
}
|
|
return null
|
|
}
|
|
if (!writeKeyMatch) {
|
|
console.error(`::error::PostHog WRITE_KEY missing from ${asarPath}`)
|
|
const sample = indexJs.match(/.{0,80}WRITE_KEY.{0,80}/g)?.slice(0, 5) ?? []
|
|
for (const line of sample) {
|
|
console.error(` ${line.slice(0, 200)}`)
|
|
}
|
|
return null
|
|
}
|
|
|
|
return { asarPath, buildIdentity: buildIdentityMatch[1], writeKey: writeKeyMatch[1] }
|
|
}
|
|
|
|
// Why verify every match (not just the first): macOS dual-arch produces one
|
|
// asar per arch from the same `out/main` bundle, so identical constants are
|
|
// expected — but if a future change introduces an arch-specific bundle split
|
|
// (or a per-arch `electron-vite build` invocation) that forgets to thread
|
|
// the `define` values, only one of the asars would carry the constants and
|
|
// the broken arch would ship transmitting nothing. Looping is cheap insurance
|
|
// against that class of regression. We additionally require every asar to
|
|
// agree on the BUILD_IDENTITY and WRITE_KEY values — a mismatch means the
|
|
// matrix shipped inconsistent build-identity claims, or (worse) per-arch
|
|
// PostHog keys that would split events across projects, both of which are
|
|
// release bugs.
|
|
const results = []
|
|
for (const asarPath of asarMatches) {
|
|
const result = verifyAsar(asarPath)
|
|
if (!result) {
|
|
process.exit(1)
|
|
}
|
|
results.push(result)
|
|
}
|
|
|
|
const distinctIdentities = new Set(results.map((r) => r.buildIdentity))
|
|
if (distinctIdentities.size > 1) {
|
|
console.error(`::error::asars disagree on BUILD_IDENTITY: ${[...distinctIdentities].join(', ')}`)
|
|
for (const r of results) {
|
|
console.error(` - ${r.asarPath}: ${r.buildIdentity}`)
|
|
}
|
|
process.exit(1)
|
|
}
|
|
|
|
const distinctWriteKeys = new Set(results.map((r) => r.writeKey))
|
|
if (distinctWriteKeys.size > 1) {
|
|
console.error(`::error::asars disagree on WRITE_KEY across arches`)
|
|
for (const r of results) {
|
|
console.error(` - ${r.asarPath}: ${r.writeKey.slice(0, 8)}... (length=${r.writeKey.length})`)
|
|
}
|
|
process.exit(1)
|
|
}
|
|
|
|
const [first] = results
|
|
console.log(
|
|
`Telemetry constants verified across ${results.length} asar(s): ` +
|
|
`BUILD_IDENTITY="${first.buildIdentity}", ` +
|
|
`WRITE_KEY="${first.writeKey.slice(0, 8)}..." (length=${first.writeKey.length})`
|
|
)
|