Files
orca/config/scripts/mobile-web-bundle-packaging-workflow-contract.test.mjs
T
Jinwoo Hong ca2ae89011 ci: install mobile dependencies in every desktop packaging job, pin mobile page source to LF (OTA phase C, C0.6) (#21425)
* chore(mobile): pin mobile page source to LF so a Windows checkout keeps buildId

The Phase C web bundle hashes every text byte under mobile/src and mobile/app into
its asset digests and from there into buildId. There is no global text=auto, so a
CRLF checkout on Windows would give the Windows release a different buildId for
identical source, the same failure the src/mobile-web pin above exists for.

All 1924 tracked files in those directories are already LF in the index, so the
pin renormalises nothing. mobile/web-entry does not exist yet; the pin is
forward-looking for the Phase C entry point.

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

* ci: install mobile dependencies in every desktop packaging job

Ten workflows reach build:release/build:desktop and none of them installs
mobile/node_modules. Root has no react-native, react-native-web or expo, so once
the mobile web bundle builds from mobile/ its packaging jobs would fail at
electron-builder's beforePack with an unresolvable import.

Extract the frozen mobile install that pr.yml's static analysis job already ran
inline into .github/actions/install-mobile-dependencies, and invoke it from every
job the packaging census enumerates, after the root install and before the build.
Same --frozen-lockfile, same lockfile-drift guard, and still no --ignore-scripts:
mobile's postinstall generates the gitignored webview engine modules that tracked
source imports. pr.yml now uses the action too, so there is one definition.

Where a packaging job's setup-node caches the pnpm store, mobile/pnpm-lock.yaml
joins cache-dependency-path so a mobile lockfile change invalidates it. Two jobs
(daemon-relocation-spike, win-update-survival-e2e) do not cache at all and are
left alone.

The census test grows a per-job assertion that the action is present, so a new
packaging job has to add the install deliberately rather than discover it at
beforePack. release-cut's composite-action restore is no longer Windows-only:
every platform consumes this action now, so any of them can be the leg whose cut
ref predates it.

No job builds anything different; this only makes mobile/node_modules present.

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

* test(ci): assert the mobile install contract on the shared action

pr.yml's static analysis job no longer carries the install inline, so the scope
test's findIndex by step name resolved to -1. Match the step by the action it
uses, and read working-directory and --frozen-lockfile off the action itself so
the job cannot keep the step while the action stops installing anything.

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

* chore(mobile): exempt binary asset types from the mobile LF pin

/mobile/{src,app,web-entry}/** text eol=lf would mark a future PNG or font as
text and rewrite its bytes on a Windows checkout. Exempt the asset types an RN
page carries, the same way src/mobile-web exempts its PNG.

-text after text eol=lf wins: probed a CRLF-bearing .png under the pin, it stays
i/crlf attr/-text while a sibling .ts still normalises to i/lf. No tracked file
changes classification; the 1924 files under mobile/src and mobile/app stay i/lf.

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

* ci: gate the mobile install with the build it feeds in the cached lanes

win-crash-survival, win-update-survival and daemon-relocation-spike all skip
electron-builder on an installer/unpacked cache hit, so an unconditional mobile
install spent time on node_modules nothing then consumed. Move each `uses:`
below its cache step and carry the same cache-hit condition as the build.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 05:44:11 -04:00

169 lines
6.3 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)
}
)
it.each(packagingJobs().map((job) => [job.label, job]))(
'installs mobile/node_modules before electron-builder packs: %s',
(_label, job) => {
// mobile is a separate pnpm project, so the root install leaves it empty and the bundle
// build cannot resolve React Native or Expo. One definition, so no job hand-rolls it.
expect(job.text).toContain('uses: ./.github/actions/install-mobile-dependencies')
}
)
})
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/)
})
})