Files
orca/mobile/scripts/rpc-recording-pin-guard.test.ts
Jinwoo Hong f949d5fcc4 ci(mobile): fail CI when the RPC recording pin leaves main's history or the corpus does not reproduce (#21156)
* test(mobile): fail CI when the RPC recording pin leaves main's history

`mobile/rpc-foundation/pilot-scenarios.json` carries the commit every golden
claims it was recorded from, and `--record` refuses on any other tree. A
behaviour-change branch pins its own last fenced commit, which stops being
reachable the moment the branch squash-merges: nobody can record on main again
until a hand-made repin lands, and until now only a human noticed. #21123 was
that, and so was the repin after #20954.

`scripts/rpc-recording-pin-guard.mts ancestry` fails when the pin is not an
ancestor of the commit under test, and prints the repin recipe. It refuses to
answer on a shallow clone rather than trusting grafted history, so the job
checks out with `fetch-depth: 0`. Ordinary product drift past a reachable pin
is not a failure.

`reproduce` makes the other claim the corpus header makes, which the recording
suites do not: they replay the goldens against the CURRENT tree, so a golden
recorded somewhere other than the pin -- a merge that auto-merged golden JSON,
a refresh copied back from a scratch directory -- passes them and is what the
header exists to deny. It checks the pin out detached, lays this tree's
recorder and manifest over it, and lets the same suites compare in place, so
the comparison is `compareGolden` with lockfile and platform masked as ever.

It runs unconditionally on a push to main, which has no `verify` job and is
where a squash lands a spliced corpus. On a pull request it runs only when the
corpus, the manifest or the recorder moved: nothing else can move the verdict
away from the one the base commit published, and `verify` replays the corpus
against the branch tree meanwhile.

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

* test(mobile): judge the recording pin against the tree it was read from

Round-1 review of the pin guard.

The pull_request ancestry check read the pin out of the merge preview and judged
it against the branch head. Those differ whenever main repins after the branch
point, so ordinary stale branches failed, and the instruction told the author to
repin to their own head -- which creates the unreachable pin the guard exists to
catch. Judge the checked-out tree instead.

`git worktree prune` in the reproduce teardown was repository-wide. This git
directory is shared by every worktree on the machine (611 registered here), so
it could deregister an unrelated one whose directory was momentarily missing.
`worktree remove --force` alone is enough; a failure to remove is now reported
rather than papered over.

Also: the concurrency group is per commit on main, because GitHub cancels a
pending run in a group whatever `cancel-in-progress` says; the skip gate fails
closed when a provenance path stops matching instead of skipping forever; the
census-boundary comment states the rule the code uses; and five exports with no
consumer are now module-private.

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

* test(mobile): let an untracked golden and the guard itself buy a reproduction

Two bot findings on the skip gate.

`git diff` sees tracked paths only, but the reproduction's overlay copy and its
census both read the corpus directory as it sits on disk, so an untracked golden
or manifest is input to the verdict and used to skip the run that would judge it.
Enumerate untracked entries under the provenance paths the way the recorder
already does, and run rather than skip: an unjudged local addition is the case
the reproduction exists for.

The guard script is now a provenance path of its own, so a change to it re-runs
the reproduction it implements. Left alone deliberately: run-process.ts and the
workflow's `paths:` scope over src/shared, which is a pre-existing gap for the
whole mobile workflow rather than this job's.

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

* test(mobile): refuse to reproduce when the suite list has drifted from the files

Round-2 review.

The suite names reach vitest as positional filename filters, and vitest exits 0
when only some of them match. A renamed census suite therefore dropped out of the
reproduction silently and the guard still printed that the corpus reproduces:
three files and 761 tests instead of four and 762, exit 0. Resolve every name
under the recorder overlay before spawning, and throw naming the drifted entry.
The unit case walks the list and omits each name in turn, so no single rename can
slip past it. This is the same fail-open shape as the renamed-pathspec finding.

Also: pass an explicit directory type to `symlink`, since Windows needs one and a
junction needs no privilege where a real symlink does; and build the throwaway
test repositories with `symbolic-ref` rather than `--initial-branch`, which needs
git 2.28 against a declared baseline of 2.25.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 02:34:03 -04:00

263 lines
13 KiB
TypeScript

import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { runProcess } from '../../src/shared/child-process/run-process'
import {
assertReproductionSuitesExist,
checkPinAncestry,
corpusProvenanceChanged,
removeScratchWorktree,
repinInstruction,
REPRODUCTION_SUITES
} from './rpc-recording-pin-guard.mts'
const scratch: string[] = []
async function git(cwd: string, ...args: string[]): Promise<string> {
const result = await runProcess({ program: 'git', args, cwd })
if (result.code !== 0) {
throw new Error(`git ${args.join(' ')} failed in ${cwd}: ${result.stderr}`)
}
return result.stdout.trim()
}
/** A repository of our own, so no verdict in this file can depend on — or touch — the real refs. */
async function throwawayRepository(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'pin-guard-'))
scratch.push(directory)
const repository = join(directory, 'repo')
await git(directory, 'init', '--quiet', 'repo')
// `--initial-branch=main` needs git >= 2.28; symbolic-ref before the first commit works on any.
await git(repository, 'symbolic-ref', 'HEAD', 'refs/heads/main')
await git(repository, 'config', 'user.email', 'pin-guard@example.invalid')
await git(repository, 'config', 'user.name', 'Pin Guard')
return repository
}
async function commit(repository: string, body: string): Promise<string> {
await writeFile(join(repository, 'product.ts'), `${body}\n`)
await git(repository, 'add', 'product.ts')
await git(repository, 'commit', '--quiet', '--no-verify', '--message', body)
return await git(repository, 'rev-parse', 'HEAD')
}
async function commitAt(repository: string, path: string, body: string): Promise<string> {
await mkdir(dirname(join(repository, path)), { recursive: true })
await writeFile(join(repository, path), `${body}\n`)
await git(repository, 'add', path)
await git(repository, 'commit', '--quiet', '--no-verify', '--message', path)
return await git(repository, 'rev-parse', 'HEAD')
}
const RECORDER_FILE = 'mobile/src/test-support/rpc-recording/run-recording.ts'
const GUARD_FILE = 'mobile/scripts/rpc-recording-pin-guard.mts'
/** Every provenance path: the gate refuses to answer when any one of them names nothing. */
async function seedProvenance(repository: string): Promise<string> {
await commitAt(repository, 'mobile/rpc-foundation/pilot-scenarios.json', 'pin')
await commitAt(repository, GUARD_FILE, 'guard')
return await commitAt(repository, RECORDER_FILE, 'recorder')
}
const MISSING_SHA = '0123456789abcdef0123456789abcdef01234567'
// File scope, not per-suite: a suite-local hook fires before the later suites have made theirs.
afterAll(async () => {
for (const directory of scratch) {
await rm(directory, { recursive: true, force: true })
}
})
describe('recording pin ancestry', () => {
it('passes when the pin is the commit itself', async () => {
const repository = await throwawayRepository()
const first = await commit(repository, 'one')
expect(await checkPinAncestry(repository, first, first)).toMatchObject({ ok: true })
})
it('passes on ordinary drift: the tree has moved on, the pin is still reachable', async () => {
const repository = await throwawayRepository()
const pin = await commit(repository, 'one')
const head = await commit(repository, 'two')
expect(await checkPinAncestry(repository, pin, head)).toMatchObject({ ok: true })
})
it('passes on a branch that pinned its own commit, judged against that branch', async () => {
const repository = await throwawayRepository()
await commit(repository, 'one')
await git(repository, 'switch', '--quiet', '--create', 'behaviour-change')
const branchPin = await commit(repository, 'two')
const branchHead = await commit(repository, 'three')
expect(await checkPinAncestry(repository, branchPin, branchHead)).toMatchObject({ ok: true })
})
it('passes a branch cut before main repinned, judged against the merge preview', async () => {
const repository = await throwawayRepository()
const branchPoint = await commitAt(repository, 'mobile/src/session/route.ts', 'base')
const mainPin = await commitAt(
repository,
'mobile/rpc-foundation/pilot-scenarios.json',
'repin'
)
await git(repository, 'switch', '--quiet', '--create', 'refactor', branchPoint)
const branchHead = await commitAt(repository, 'mobile/src/session/route.ts', 'migrated')
await git(repository, 'merge', '--quiet', '--no-edit', 'main')
const preview = await git(repository, 'rev-parse', 'HEAD')
// The preview is the tree CI checks out and reads the pin from, so it is the tree to judge.
expect(await checkPinAncestry(repository, mainPin, preview)).toMatchObject({ ok: true })
// The head sha would have failed this ordinary branch, and told the author to repin to it.
expect(await checkPinAncestry(repository, mainPin, branchHead)).toMatchObject({
ok: false,
failure: 'not-an-ancestor'
})
})
it('fails once that branch squash-merges and the pin leaves the history', async () => {
const repository = await throwawayRepository()
const base = await commit(repository, 'one')
await git(repository, 'switch', '--quiet', '--create', 'behaviour-change')
const branchPin = await commit(repository, 'two')
await git(repository, 'switch', '--quiet', 'main')
await git(repository, 'reset', '--quiet', '--hard', base)
const squashed = await commit(repository, 'two, squashed')
const verdict = await checkPinAncestry(repository, branchPin, squashed)
expect(verdict).toMatchObject({ ok: false, failure: 'not-an-ancestor' })
expect(verdict.ok).toBe(false)
if (verdict.ok) {
return
}
expect(verdict.message).toContain(branchPin)
expect(verdict.message).toContain('scripts/rpc-recording.mts --record')
expect(verdict.message).toContain('mobile/rpc-foundation/pilot-scenarios.json')
})
it('fails with the same instruction when the pin is no commit at all', async () => {
const repository = await throwawayRepository()
const head = await commit(repository, 'one')
const verdict = await checkPinAncestry(repository, MISSING_SHA, head)
expect(verdict).toMatchObject({ ok: false, failure: 'unreachable' })
expect(verdict.ok ? '' : verdict.message).toContain('scripts/rpc-recording.mts --record')
})
it('refuses to answer on a shallow clone instead of trusting grafted history', async () => {
const repository = await throwawayRepository()
const pin = await commit(repository, 'one')
await commit(repository, 'two')
const head = await commit(repository, 'three')
const clone = join(repository, '..', 'shallow')
await git(repository, 'clone', '--quiet', '--depth', '1', `file://${repository}`, clone)
// The pin is real and reachable in the full repository; only the missing history hides it.
expect(await checkPinAncestry(repository, pin, head)).toMatchObject({ ok: true })
const verdict = await checkPinAncestry(clone, pin, 'HEAD')
expect(verdict).toMatchObject({ ok: false, failure: 'shallow' })
expect(verdict.ok ? '' : verdict.message).toContain('fetch-depth: 0')
})
it('names the head and the pin in the instruction', () => {
expect(
repinInstruction('a'.repeat(40), 'b'.repeat(40), 'is not an ancestor of this commit')
).toContain(`git switch -c repin-rpc-recording ${'b'.repeat(40)}`)
})
})
describe('what a reproduction reads from the candidate tree', () => {
it('skips the run when only product sources moved', async () => {
const repository = await throwawayRepository()
await seedProvenance(repository)
const base = await commitAt(repository, 'mobile/src/session/route.ts', 'before')
await commitAt(repository, 'mobile/src/session/route.ts', 'after')
expect(await corpusProvenanceChanged(repository, base)).toBe(false)
})
it('runs when a golden moved', async () => {
const repository = await throwawayRepository()
await seedProvenance(repository)
const base = await commitAt(repository, 'mobile/rpc-foundation/goldens/a.json', '{}')
await commitAt(repository, 'mobile/rpc-foundation/goldens/a.json', '{"spliced": true}')
expect(await corpusProvenanceChanged(repository, base)).toBe(true)
})
it('runs when the pin itself moved', async () => {
const repository = await throwawayRepository()
await seedProvenance(repository)
const base = await commitAt(repository, 'mobile/rpc-foundation/pilot-scenarios.json', 'one')
await commitAt(repository, 'mobile/rpc-foundation/pilot-scenarios.json', 'two')
expect(await corpusProvenanceChanged(repository, base)).toBe(true)
})
it('ignores a corpus change the base branch made without this branch', async () => {
const repository = await throwawayRepository()
await seedProvenance(repository)
const branchPoint = await commitAt(repository, 'mobile/rpc-foundation/goldens/a.json', '{}')
await commitAt(repository, 'mobile/rpc-foundation/goldens/b.json', '{}')
const baseTip = await git(repository, 'rev-parse', 'HEAD')
await git(repository, 'switch', '--quiet', '--create', 'refactor', branchPoint)
await commitAt(repository, 'mobile/src/session/route.ts', 'migrated')
expect(await corpusProvenanceChanged(repository, baseTip)).toBe(false)
})
it('runs when the recorder moved, because every golden pins it by digest', async () => {
const repository = await throwawayRepository()
const base = await seedProvenance(repository)
await commitAt(repository, RECORDER_FILE, 'two')
expect(await corpusProvenanceChanged(repository, base)).toBe(true)
})
it('runs when the guard itself moved, because it decides the skip and drives the run', async () => {
const repository = await throwawayRepository()
const base = await seedProvenance(repository)
await commitAt(repository, GUARD_FILE, 'changed')
expect(await corpusProvenanceChanged(repository, base)).toBe(true)
})
it('runs on an untracked golden, which no diff of tracked paths can see', async () => {
const repository = await throwawayRepository()
const base = await seedProvenance(repository)
const golden = join(repository, 'mobile/rpc-foundation/goldens/local.json')
await mkdir(dirname(golden), { recursive: true })
await writeFile(golden, '{}\n')
// The overlay copy and the census both read the directory as it sits on disk.
expect(await corpusProvenanceChanged(repository, base)).toBe(true)
})
it('refuses to answer when a provenance path names nothing, instead of skipping forever', async () => {
const repository = await throwawayRepository()
const base = await seedProvenance(repository)
await git(repository, 'mv', 'mobile/rpc-foundation', 'mobile/rpc-corpus')
await git(repository, 'commit', '--quiet', '--no-verify', '--message', 'rename the corpus')
await expect(corpusProvenanceChanged(repository, base)).rejects.toThrow('not a tracked path')
})
})
describe('scratch worktree teardown', () => {
it('leaves an unrelated worktree registered when its directory is missing', async () => {
const repository = await throwawayRepository()
await commit(repository, 'one')
const trees = join(repository, '..', 'trees')
for (const name of ['scratch', 'kept', 'unmounted']) {
await git(repository, 'worktree', 'add', '--quiet', '--detach', join(trees, name))
}
// Stands in for a worktree on an unmounted volume: `git worktree prune` would deregister it.
await rm(join(trees, 'unmounted'), { recursive: true, force: true })
await removeScratchWorktree(repository, join(trees, 'scratch'))
const registered = await git(repository, 'worktree', 'list', '--porcelain')
expect(registered).toContain('trees/kept')
expect(registered).toContain('trees/unmounted')
expect(registered).not.toContain('trees/scratch')
})
})
describe('the suites a reproduction runs', () => {
const overlay = 'mobile/src/test-support/rpc-recording'
it('every name resolves to a file in this repository', () => {
expect(() => assertReproductionSuitesExist(resolve(import.meta.dirname, '../..'))).not.toThrow()
})
it('throws for the one that drifted, rather than letting vitest pass over it', async () => {
for (const renamed of REPRODUCTION_SUITES) {
const root = await mkdtemp(join(tmpdir(), 'pin-guard-suites-'))
scratch.push(root)
await mkdir(join(root, overlay), { recursive: true })
for (const suite of REPRODUCTION_SUITES.filter((name) => name !== renamed)) {
await writeFile(join(root, overlay, suite), '')
}
expect(() => assertReproductionSuitesExist(root)).toThrow(renamed)
}
})
})