Files
orca/config/scripts/mobile-web-bundle-packaging-workflow-contract.test.mjs
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

160 lines
5.9 KiB
JavaScript

import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { parseDocument } from 'yaml'
const workflowsDir = fileURLToPath(new URL('../../.github/workflows', import.meta.url))
// Every script whose chain reaches build:mobile-web. build:unpack -> build -> build:desktop, and
// build:mac/linux/win each call build:desktop, so all of them produce out/mobile-web. The chain
// itself is not an assumption here: 'the build scripts' below resolves each one for real.
const BUNDLE_PRODUCING_SCRIPTS = [
'build',
'build:desktop',
'build:release',
'build:release:parallel',
'build:unpack',
'build:mobile-web',
'build:mac',
'build:mac:release',
'build:linux',
'build:win'
]
const BUNDLE_PRODUCER = new RegExp(
`pnpm (?:run )?(?:${BUNDLE_PRODUCING_SCRIPTS.join('|')})(?=$|[\\s'"&|;])`,
'm'
)
const packageScripts = JSON.parse(
readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8')
).scripts
const SCRIPT_INVOCATION = /pnpm (?:run )?([\w:-]+)(?=$|[\s'"&|;])/g
/** Whether `pnpm run <name>` eventually runs build:mobile-web. */
function reachesBundleBuild(name, seen = new Set()) {
if (name === 'build:mobile-web') {
return true
}
if (seen.has(name)) {
return false
}
seen.add(name)
const body = packageScripts[name]
if (typeof body !== 'string') {
return false
}
return [...body.matchAll(SCRIPT_INVOCATION)].some((match) => reachesBundleBuild(match[1], seen))
}
/**
* Whether `pnpm run <name>` eventually runs electron-builder without --prepackaged, i.e. runs
* beforePack. A workflow job that packs through such a script is a packaging job even though the
* literal electron-builder line lives in package.json (daemon-relocation-spike's build:unpack).
*/
function reachesElectronBuilder(name, seen = new Set()) {
if (seen.has(name)) {
return false
}
seen.add(name)
const body = packageScripts[name]
if (typeof body !== 'string') {
return false
}
if (packsWithBeforePack(body)) {
return true
}
return [...body.matchAll(SCRIPT_INVOCATION)].some((match) =>
reachesElectronBuilder(match[1], seen)
)
}
/** Whether text invokes electron-builder in a way that reaches beforePack. */
function packsWithBeforePack(text) {
const invocations = [...text.matchAll(/[^\n]*electron-builder --config[^\n]*/g)].map(
(match) => match[0]
)
// --prepackaged short-circuits doPack before emitBeforePack, so those jobs never run the guard.
return (
invocations.length > 0 &&
!invocations.every((invocation) => invocation.includes('--prepackaged'))
)
}
// Every job that packs an app and therefore runs beforePack. Listed so that a new packaging
// workflow has to be added here deliberately, with its bundle step, rather than slipping in.
const EXPECTED_PACKAGING_JOBS = [
'adhoc-mac-build.yml build-adhoc-mac',
'daemon-relocation-spike.yml spike',
'daily-mac-build.yml build-daily-mac',
'dev-channel-win-build.yml build-win',
'hourly-mac-build.yml build-hourly-mac',
'pr.yml package',
'pr.yml package_windows',
'release-cut.yml build',
'release-mac-build.yml build-mac',
'win-crash-survival-e2e.yml crash-survival',
'win-update-survival-e2e.yml survival',
'windows-signing-rehearsal.yml rehearse'
]
/**
* Raw source text per job, sliced by the parsed job boundaries. Why not yaml.stringify(job):
* re-serializing folds long lines, and the fold in dev-channel-win-build's build-win landed
* between `electron-builder` and `--config`, hiding a whole packaging job from this census.
*/
function packagingJobs() {
const jobs = []
for (const file of readdirSync(workflowsDir).filter((name) => name.endsWith('.yml'))) {
const source = readFileSync(join(workflowsDir, file), 'utf8')
const jobsNode = parseDocument(source).get('jobs', true)
const items = jobsNode?.items ?? []
for (const [index, pair] of items.entries()) {
const end = index + 1 < items.length ? items[index + 1].key.range[0] : jobsNode.range[2]
const text = source.slice(pair.key.range[0], end)
const packsViaScript = [...text.matchAll(SCRIPT_INVOCATION)].some((match) =>
reachesElectronBuilder(match[1])
)
if (!packsWithBeforePack(text) && !packsViaScript) {
continue
}
jobs.push({ label: `${file} ${String(pair.key.value)}`, text })
}
}
return jobs
}
describe('mobile web bundle packaging coverage', () => {
it('finds every packaging job', () => {
// A rename or a restructure that shrank this list would make every assertion below vacuous.
const labels = packagingJobs().map((job) => job.label)
expect(labels.length).toBeGreaterThanOrEqual(EXPECTED_PACKAGING_JOBS.length)
expect(labels.toSorted()).toEqual(EXPECTED_PACKAGING_JOBS.toSorted())
})
it.each(packagingJobs().map((job) => [job.label, job]))(
'produces out/mobile-web before electron-builder packs: %s',
(_label, job) => {
// Job granularity, not step ordering: the failure this exists for is a job that never builds
// the bundle at all, which is what beforePack turns into a hard packaging failure.
expect(job.text).toMatch(BUNDLE_PRODUCER)
}
)
})
describe('the build scripts the census trusts', () => {
// The census only checks that a packaging job invokes one of these. If a chain stopped calling
// build:mobile-web, every job would still look covered while packaging failed at beforePack.
it.each(BUNDLE_PRODUCING_SCRIPTS)('%s runs build:mobile-web', (name) => {
expect(packageScripts[name]).toBeTypeOf('string')
expect(reachesBundleBuild(name)).toBe(true)
})
it('pr.yml package builds the bundle by hand, because it never calls build:release', () => {
const source = readFileSync(join(workflowsDir, 'pr.yml'), 'utf8')
expect(source).toMatch(/- name: Build mobile web bundle\n\s+run: pnpm run build:mobile-web\n/)
})
})