Files
orca/config/scripts/verify-packaged-mobile-web-bundle.cjs
T
Jinwoo Hong ad4f26cdd4 feat(build): build, verify and package the mobile web bundle with every desktop release (OTA phase A, 2/5) (#21326)
* feat(mobile-web): add the Phase A bootstrap web source

A peer of src/ so the root workspace owns it and mobile's separate lockfile
stays out of packaging. Four assets across four content types, enough to
exercise multi-asset manifest handling rather than assume it.

The page reads buildId from manifest.json at runtime: buildId hashes the asset
list that index.html belongs to, so injecting it into a hashed asset would make
that asset's hash depend on itself.

Registered as a fourth typecheck project; without it the entry would be the
only TypeScript in a release path that tsc never sees.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(build): build and verify the mobile web bundle from the root workspace

Root esbuild over mobile-web/ into out/mobile-web/, content-addressed as
assets/<sha256>.<ext> with index.html the only stable name. buildId is the
sha256 of the canonical serialization of the sorted asset list, so it is a pure
function of content and usable as a cache key with no further reasoning.

The verifier builds twice into scratch dirs and compares: a timestamp, an
absolute path, or an unstable ordering fails the build when someone introduces
it, not the first time a phone gets a spurious cache miss. It also enforces the
Phase A budget of 16 assets and 256 KiB, separate from the permanent contract
ceiling.

build:release does not call build:desktop, so build:mobile-web is wired into
build:desktop, build:release, and build:release:parallel.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(packaging): fail the release when the mobile web bundle is missing or stale

electron-builder only warns about a missing input, so without a beforePack
guard a release ships an app that advertises the bundle capability and then
errors on every request. The hash check, not the existence check, is what
catches a half-written or stale out/.

The source tree is excluded from app.asar; out/mobile-web ships inside it under
the existing out rules, exactly as out/web does.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile-web): narrow the manifest with `in` instead of a cast

The changed-code casting gate rejects assertions, and `in` narrows the same
untrusted JSON without one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile-web): move the bundle source under src/ so the root guard passes

.github/scripts/check-root-directory-entries.mjs blocks any new top-level entry
by name, so mobile-web/ could not live at the root.

The source is excluded from app.asar by the existing '!src{,/**/*}' rule; the
explicit '!src/mobile-web{,/**/*}' entry stays as a marker. out/mobile-web is
unaffected and still ships under the out rules like out/web. No tsconfig
includes src/**, so node, web, cli, and relay do not pick the tree up; it is
registered as a knip entry so audit:dead-code does not call it unused.

buildId is unchanged at 9d78435e: the builder hashes content, not paths.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(build): resolve the entry-script guard through pathToFileURL

`file://${process.argv[1]}` never equals import.meta.url on Windows, where that
url is file:///C:/... So the builder exited 0 having written nothing and the
Windows packaging job failed later, at the guard, with no clue why. Every other
script in config/scripts already uses pathToFileURL; this one now does too, via
an exported predicate a posix runner can exercise with a win32 path.

The verify script had no entry guard at all, so importing its budget constants
ran the whole verification — including its process.exit — inside the test
worker. It is now a function behind the same guard.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(ci): build the mobile web bundle in the PR package job

That job assembles packaging inputs step by step instead of calling
build:release, so the new beforePack guard hard-failed it.

The census test added here is the oracle: it walks every workflow job that
invokes electron-builder without --prepackaged (which short-circuits doPack
before beforePack) and requires a bundle-producing script in the same job. It
goes red on exactly pr.yml's package job when this step is removed. Ten jobs
covered; the other nine already ran build:release, build:release:parallel, or
build:desktop.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile-web): pin source line endings, because CRLF changes the buildId

Every text byte under src/mobile-web is hashed into an asset digest and from
there into buildId, so a CRLF checkout produces a different bundle id for the
same commit: 91af2897 instead of 9d78435e. That would make a Windows-built
desktop disagree with a mac-built one about which bundle a phone has cached.

.gitattributes pins eol=lf for the text sources and -text for the PNG, matching
the four trees already pinned for byte-hashing. The verify script asserts no
source file carries a CR, so the build fails if the pin ever stops applying
rather than silently shipping a second bundle identity.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(build): read the test's own path from import.meta.filename

oxlint unicorn/prefer-import-meta-properties.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(test): census packaging jobs over raw workflow text, not re-serialized YAML

yaml.stringify folds long lines, and in dev-channel-win-build.yml's build-win the
fold landed between `electron-builder` and `--config`, so a real packaging job was
invisible to the census: 11 jobs exist, the test saw 10. Slice each job's raw source
by its parsed boundaries instead, and pin the inventory so a new packaging workflow
has to be added here on purpose.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(build): assert the script chain the packaging census trusts

The census only checks that a packaging job invokes one of ten build scripts; that
those scripts still reach build:mobile-web was asserted nowhere, so a dropped link
would leave every job looking covered while packaging failed at beforePack. Resolve
each script for real, and pin pr.yml's hand-rolled step, since that job never calls
build:release.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(build): realpath the entry path before the direct-invocation compare

Node resolves symlinks in import.meta.url but not in argv[1], so `node /tmp/...`
against a /private/tmp realpath compared two different strings: the builder and the
verifier exited 0 having written and checked nothing. Same silent-success shape as
the Windows file:// bug, so the fix sits next to it, with both seams injectable.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(mobile-web): format bootstrap.css with oxfmt

It was the only tracked CSS failing oxfmt --check. The buildId is unchanged at
9d78435e8bb73c3341f833c20aaefbd7bfdfc414b68dadf87c1689d86728fe33, because esbuild's
CSS minifier normalises the whitespace this touches before the asset is hashed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(packaging): reject bundle files the manifest does not list

The guard only walked the manifest, so a dropped assets/stale.js passed: assets are
content-addressed, nothing ever overwrites a stale copy, and it would ship inside
asar unreachable and unverified. Require every file under out/mobile-web to be the
manifest or a listed asset.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(packaging): give beforePack an explicit mobile web bundle root

The bundle guard read the repo's out/mobile-web unconditionally, so the two
arch-aware packaging tests that call the real beforePack went red in the unit-test
job, which never runs build:mobile-web. beforePack now takes the bundle root as a
second parameter defaulting to out/mobile-web, which is what electron-builder gets,
and those tests build a real bundle into a temp dir instead. The guard is neither
skipped nor made tolerant of a missing bundle.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(packaging): census sees script-wrapped packers; dev verify reuses the guard

The workflow census only matched a literal `electron-builder --config` line, so
daemon-relocation-spike's `pnpm run build:unpack` (which packs and runs beforePack) was
invisible to it. Jobs now count when any `pnpm run <script>` they invoke chains to
electron-builder without --prepackaged; the spike joins the pinned list (12 jobs).

verify-mobile-web-bundle.mjs re-implemented a weaker subset of the packaging guard
(no safe-path check, no buildId recompute). It now calls assertMobileWebBundleBuilt, so a
manifest edited after the build fails at `pnpm build:mobile-web` exactly as at beforePack.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 22:41:13 -04:00

192 lines
7.2 KiB
JavaScript

const { createHash } = require('node:crypto')
const { readFileSync, readdirSync, statSync } = require('node:fs')
const { join, resolve } = require('node:path')
const projectDir = resolve(__dirname, '..', '..')
const MOBILE_WEB_BUNDLE_DIR = join(projectDir, 'out', 'mobile-web')
const REMEDY = 'Run pnpm build:mobile-web (build:desktop and build:release already do).'
const ENTRYPOINT = 'index.html'
const SHA256_PATTERN = /^[0-9a-f]{64}$/
function failure(message) {
return new Error(`[verify-packaged-mobile-web-bundle] ${message}`)
}
function assertSafeRelativePath(path) {
if (typeof path !== 'string' || path.length === 0) {
throw failure('manifest asset has a missing or empty path')
}
const segments = path.split('/')
if (
path.includes('\\') ||
path.startsWith('/') ||
/^[a-zA-Z]:/.test(path) ||
segments.some((segment) => segment === '' || segment === '.' || segment === '..')
) {
throw failure(`manifest asset path is not a safe relative path: ${path}`)
}
}
function assertInteger(value, field) {
if (!Number.isSafeInteger(value) || value < 0) {
throw failure(`manifest field ${field} is not a non-negative integer: ${String(value)}`)
}
}
/**
* Canonical serialization of the asset list. Must stay byte-identical to
* serializeMobileWebBundleAssets in config/scripts/build-mobile-web-bundle.mjs; a divergence here
* would reject every honest bundle, so the two move together.
*/
function serializeAssets(assets) {
return JSON.stringify(
[...assets]
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
.map(({ path, sha256, byteLength, contentType }) => ({
path,
sha256,
byteLength,
contentType
}))
)
}
function parseManifest(bundleDir) {
const manifestPath = join(bundleDir, 'manifest.json')
let raw
try {
raw = readFileSync(manifestPath, 'utf8')
} catch (error) {
throw failure(
`no bundle manifest at ${manifestPath} (${error.code ?? error.message}). ${REMEDY}`
)
}
let manifest
try {
manifest = JSON.parse(raw)
} catch (error) {
throw failure(`${manifestPath} is not valid JSON: ${error.message}. ${REMEDY}`)
}
if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest)) {
throw failure(`${manifestPath} is not a JSON object. ${REMEDY}`)
}
if (manifest.schemaVersion !== 1) {
throw failure(`unsupported manifest schemaVersion: ${String(manifest.schemaVersion)}`)
}
if (typeof manifest.buildId !== 'string' || !SHA256_PATTERN.test(manifest.buildId)) {
throw failure(`manifest buildId is not a sha256 digest: ${String(manifest.buildId)}`)
}
if (typeof manifest.desktopVersion !== 'string' || manifest.desktopVersion.length === 0) {
throw failure('manifest desktopVersion is missing')
}
assertInteger(manifest.minCompatibleRuntimeProtocolVersion, 'minCompatibleRuntimeProtocolVersion')
assertInteger(manifest.runtimeProtocolVersion, 'runtimeProtocolVersion')
assertInteger(manifest.totalBytes, 'totalBytes')
if (manifest.entrypoint !== ENTRYPOINT) {
throw failure(`manifest entrypoint must be ${ENTRYPOINT}, got ${String(manifest.entrypoint)}`)
}
if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) {
throw failure('manifest lists no assets')
}
for (const asset of manifest.assets) {
if (typeof asset !== 'object' || asset === null) {
throw failure('manifest asset entry is not an object')
}
assertSafeRelativePath(asset.path)
if (typeof asset.sha256 !== 'string' || !SHA256_PATTERN.test(asset.sha256)) {
throw failure(`manifest asset ${asset.path} has no sha256 digest`)
}
assertInteger(asset.byteLength, `assets[${asset.path}].byteLength`)
if (typeof asset.contentType !== 'string' || asset.contentType.length === 0) {
throw failure(`manifest asset ${asset.path} has no contentType`)
}
}
if (!manifest.assets.some((asset) => asset.path === manifest.entrypoint)) {
throw failure(`manifest entrypoint ${manifest.entrypoint} is not one of its assets`)
}
const declaredTotal = manifest.assets.reduce((total, asset) => total + asset.byteLength, 0)
if (declaredTotal !== manifest.totalBytes) {
throw failure(
`manifest totalBytes is ${String(manifest.totalBytes)}, its assets sum to ${String(declaredTotal)}`
)
}
const recomputed = createHash('sha256')
.update(serializeAssets(manifest.assets), 'utf8')
.digest('hex')
if (recomputed !== manifest.buildId) {
throw failure(
`manifest buildId ${manifest.buildId} does not match its asset list (expected ${recomputed}). ${REMEDY}`
)
}
return manifest
}
/** Every file under the bundle directory, as a manifest-shaped relative path. */
function listBundleFiles(directory, prefix = '') {
const found = []
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const relativePath = prefix === '' ? entry.name : `${prefix}/${entry.name}`
if (entry.isDirectory()) {
found.push(...listBundleFiles(join(directory, entry.name), relativePath))
} else {
found.push(relativePath)
}
}
return found
}
/**
* Nothing in the bundle directory may be unaccounted for. An asset dropped from the manifest but
* left on disk by an interrupted build ships inside asar, unreachable and unverified, and grows
* the installer; content-addressed names mean stale copies never get overwritten.
*/
function assertNoUnlistedFiles(bundleDir, manifest) {
const listed = new Set(['manifest.json', ...manifest.assets.map((asset) => asset.path)])
const strays = listBundleFiles(bundleDir).filter((path) => !listed.has(path))
if (strays.length > 0) {
throw failure(
`${bundleDir} holds ${String(strays.length)} file(s) the manifest does not list: ` +
`${strays.sort().join(', ')}. ${REMEDY}`
)
}
}
/**
* Packaging guard: electron-builder only warns about a missing input, so without this a release
* would ship an app that advertises the bundle capability and then errors on every request. The
* hash check, not the existence check, is what catches a half-written or stale out/.
*/
function assertMobileWebBundleBuilt(bundleDir = MOBILE_WEB_BUNDLE_DIR) {
const manifest = parseManifest(bundleDir)
assertNoUnlistedFiles(bundleDir, manifest)
for (const asset of manifest.assets) {
const assetPath = join(bundleDir, asset.path)
let size
try {
size = statSync(assetPath).size
} catch (error) {
throw failure(
`manifest lists ${asset.path}, which is missing from ${bundleDir} (${error.code ?? error.message}). ${REMEDY}`
)
}
if (size !== asset.byteLength) {
throw failure(
`${asset.path} is ${String(size)} bytes on disk, manifest says ${String(asset.byteLength)}. ${REMEDY}`
)
}
const sha256 = createHash('sha256').update(readFileSync(assetPath)).digest('hex')
if (sha256 !== asset.sha256) {
throw failure(
`${asset.path} hashes to ${sha256} on disk, manifest says ${asset.sha256}. ${REMEDY}`
)
}
}
console.log(
`[verify-packaged-mobile-web-bundle] OK — buildId ${manifest.buildId}, ` +
`${String(manifest.assets.length)} asset(s), ${String(manifest.totalBytes)} bytes`
)
return manifest
}
module.exports = { MOBILE_WEB_BUNDLE_DIR, assertMobileWebBundleBuilt }