Files
stablyai--orca/config/scripts/verify-telemetry-constants.mjs
2026-07-13 13:05:33 +08:00

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})`
)