Files
orca/.github/workflows/mobile.yml
T
Brennan Benson c5fc0c6f26 fix(ci): keep a squash-merged RPC recording pin reachable through its pull request (#23720)
* fix(ci): keep a squash-merged RPC recording pin reachable through its pull request

Main's "RPC recording pin" check has been red since #22762: that branch pinned
the recording corpus to its own commit 03995ae, and the squash-merge left that
commit out of main's history. Every behaviour-change squash did the same, and
each needed a hand-made repin PR to clear it (#23565, #23535, #23046 and more).

The guard now accepts a pin that is either in this history or in the head of the
pull request whose squash wrote it into the manifest. It finds that pull request
from the `(#n)` subject of the commit that added the pin and fetches
`refs/pull/<n>/head`, which GitHub keeps after the branch is deleted. The
reproduce step uses the same lookup, so it can still check the pinned tree out.

* fix(ci): give the recording pin lookup room to walk a blobless clone

In CI's blobless clone, `git log -S` fetches the manifest's blobs one commit at a
time, a few seconds each. Under the 30 s process default the walk was killed after
a handful of manifest commits, which main's history already exceeds (up to 7
manifest commits between a pin landing and the next pin change), and the guard
then failed with an empty "Could not find the commit that pinned" error. The
lookup and the pull request fetch now carry explicit budgets and say when they
timed out.

The not-an-ancestor instruction now names the pull request whose head was
checked, or says the commit that pinned it names none.

Adds the two merge-preview shapes the guard runs on: a branch opened after a
squash resolves main's pin through the squash's pull request, and a branch whose
rebase dropped its own pinned commit fails on its pull request instead of on main.

* fix(mobile): tell a missing recording pin apart from product drift

After a squash the pinned commit can live only in its pull request's head, so a
clone that never fetched it makes `git diff --quiet <baseline>` exit 128. The
recorder reported that as "Product sources or lockfile differ from the pinned
main baseline", which sends the developer to repin a tree that may match. It now
prints git's error and the command that fetches the pin.

* fix(ci): ask GitHub which pull request holds a squash-dropped recording pin

The recording pin guard found the pull request that keeps a squash-dropped
pin by walking main's first-parent history for the commit that wrote the pin
into the manifest and reading "(#n)" off its subject. A merger who edits the
squash title loses the number, and the push to main turns red anyway. That
already happened on main: of the 22 squashes that left a pin outside main's
history, #21674's title had no "(#n)".

The guard now asks GitHub for the pull requests associated with the pinned
commit (GET /repos/{owner}/{repo}/commits/{sha}/pulls) and, for each in turn,
fetches refs/pull/<n>/head and accepts only when git proves the pin is an
ancestor of that head. GitHub only nominates candidates, so a wrong answer can
fail the guard but never pass it. The endpoint named the right pull request
for all 22 historical cases, #21674 included, and names none for commits a
force-push orphaned.

This removes the pickaxe walk, its 600 s budget and its lazy blob fetches in
a blobless clone, the first-parent subtlety, and the subject regex. A revert
that restores an older pull-request-only pin now resolves too, because the
lookup is by the pin itself rather than by the commit that last wrote it.

CI passes the job token to both guard steps and grants the job
pull-requests: read. Local runs work without a token on this public repo and
send GITHUB_TOKEN or GH_TOKEN when set. A failed lookup throws with the HTTP
status, and names the rate limit when an unauthenticated call is refused.
2026-09-28 21:17:07 -07:00

242 lines
10 KiB
YAML

name: Mobile Checks
on:
pull_request:
types:
- opened
- synchronize
- reopened
paths:
- 'mobile/**'
# Mobile launch contracts exercise the real host dispatcher and durable receipt store.
- 'src/main/agent-launch/**'
- 'src/main/runtime/rpc/**'
- 'src/main/runtime/runtime-rpc/**'
- 'src/main/runtime/runtime-rpc.ts'
- 'src/main/runtime/device-registry.ts'
- 'src/main/runtime/orca-runtime.ts'
- 'src/main/runtime/agent-session-*.ts'
- 'src/main/native-chat/agent-session-wire/**'
- 'src/shared/agent-launch-*.ts'
- 'src/shared/agent-session-*.ts'
- 'src/shared/new-workspace/worktree-create-collision.ts'
# Why: the mobile terminal link parsers are conformance-tested against
# these shared fixtures; desktop-side fixture edits must re-run this suite.
- 'src/shared/terminal-file-link-conformance.ts'
# Why: mobile imports the negotiated capability names directly and records
# the whole capability read verbatim in its goldens, so a capability added
# desktop-side rewrites a mobile fixture and must re-run this suite.
- 'src/shared/protocol-version.ts'
# Why: mobile's rpc-params-contract.ts is a type-only re-export of the
# generated params catalog, and mobile/tsconfig.json includes **/*.ts. A
# schema edit anywhere under here changes mobile's types, so a desktop-only
# change can break mobile's typecheck with no other mobile signal.
- 'src/shared/rpc-contract/**'
# Why: this job holds the only checks that load the Fastfile, so edits to
# it or to the release workflow it guards must re-run them.
- '.github/workflows/mobile.yml'
- '.github/actions/install-node-dependencies/**'
- '.github/workflows/mobile-ios-release.yml'
- 'config/scripts/mobile-release-check-scope*'
- 'config/scripts/mobile-test-change-scope*'
- 'config/scripts/pr-code-change-scope.mjs'
- 'config/scripts/mobile-recording-pin-checkout.test.mjs'
# Why main too: a squash is where a spliced corpus lands, and where a behaviour-change branch's
# own pinned commit leaves main's history for its pull request's head ref, which the guard follows.
push:
branches:
- main
paths:
- 'mobile/**'
- '.github/workflows/mobile.yml'
concurrency:
# Per commit on main, not per branch. GitHub cancels any PENDING run in a group when a new one
# queues, whatever `cancel-in-progress` says, so one shared main group drops the middle merge of
# three -- and a pin that breaks there is exactly what this workflow now checks for.
group: mobile-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
jobs:
verify:
if: github.event_name == 'pull_request'
# Why ARM: 209s of this job is Vitest and nothing here needs x86: no Android SDK, emulator, gradle,
# Hermes or Watchman, no docker, and no artifacts. The Gemfile.lock lists the generic `ruby`
# platform, so frozen bundler installs without an aarch64-linux entry.
runs-on: ubuntu-24.04-arm
env:
# Why: an unfrozen bundler silently re-resolves when Gemfile.lock drifts
# from the Gemfile, which is how the release jobs could land on different
# fastlane versions in the first place. Fail here instead.
BUNDLE_FROZEN: 'true'
defaults:
run:
working-directory: mobile
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 2
- uses: ./.github/actions/install-node-dependencies
with:
cache-dependency-path: |
pnpm-lock.yaml
mobile/pnpm-lock.yaml
- name: Detect Ruby release inputs
id: ruby-scope
shell: bash
working-directory: .
run: |
# Keep deletions when release files move into an application directory.
if ! git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/mobile-release-changes"; then
echo 'should_run=true' >> "$GITHUB_OUTPUT"
elif ! node config/scripts/mobile-release-check-scope.mjs "$RUNNER_TEMP/mobile-release-changes"; then
echo 'should_run=true' >> "$GITHUB_OUTPUT"
fi
# bundler-cache installs mobile/Gemfile.lock, so this job is also what
# proves the pinned fastlane the release workflow depends on still
# resolves — before a release run finds out.
- name: Setup Ruby and fastlane
if: steps.ruby-scope.outputs.should_run != 'false'
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
working-directory: mobile
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Both compilers are read-only; finish them before starting the test workers.
- name: Typecheck
id: production-types
background: true
run: pnpm typecheck
# Why a ratchet and not the raw typecheck: mobile/tsconfig.json excludes test files, so until
# tsconfig.test.json existed nothing checked them, and at introduction 127 of the 632 had
# drifted. This fails when a test file that checks today stops checking, when a test leaves
# the program, and on @ts-nocheck; the baseline may only shrink.
- name: Typecheck tests (ratchet)
run: pnpm run check:tests-typecheck
- wait: production-types
# This includes the bridged replay of the whole recording corpus, which used to be a second
# step of its own behind RPC_FOUNDATION_BRIDGE=1. A gate nobody can forget to set is the point:
# it fails when a divergence class grows, when a divergence lands in no class at all, or when
# one of the 103 goldens inside the C1 page closure changes the verdict it is pinned to. It is
# ~3 min of test time on its own, and Vitest runs it on a worker beside the rest of the suite,
# so folding it in costs a fraction of that in wall time and one step less to skip.
- name: Detect mobile test inputs
id: test-scope
shell: bash
working-directory: .
run: |
if ! git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/mobile-test-changes"; then
echo 'should_run=true' >> "$GITHUB_OUTPUT"
elif ! node config/scripts/mobile-test-change-scope.mjs "$RUNNER_TEMP/mobile-test-changes"; then
echo 'should_run=true' >> "$GITHUB_OUTPUT"
fi
- name: Test
if: steps.test-scope.outputs.should_run != 'false'
env:
ORCA_BACKGROUND_LAUNCH: '1'
run: pnpm test
- name: Test iOS release version resolution
if: steps.ruby-scope.outputs.should_run != 'false'
run: ruby fastlane/ios_release_version_test.rb
- name: Test TestFlight lane arguments
if: steps.ruby-scope.outputs.should_run != 'false'
run: ruby fastlane/fastfile_testflight_arguments_test.rb
# Why: nothing else in CI loads the Fastfile, so a syntax error, a broken
# require, or an undefined constant only surfaces mid-release — the
# ios-distribute job failed every run for six days that way. `lanes` just
# loads and lists, so it needs no App Store Connect credentials and makes
# no network calls to Apple.
- name: Smoke-check the Fastfile
if: steps.ruby-scope.outputs.should_run != 'false'
env:
FASTLANE_SKIP_UPDATE_CHECK: '1'
FASTLANE_OPT_OUT_USAGE: '1'
run: bundle exec fastlane lanes
- name: Lint
run: pnpm lint
- name: Check formatting
run: pnpm format:check
recording-pin:
name: RPC recording pin
# Why ARM: pure Node plus git; the golden comparison masks `platform`.
runs-on: ubuntu-24.04-arm
# Why pull-requests: the guard asks GitHub which pull requests hold a pin main's history lacks.
permissions:
contents: read
pull-requests: read
defaults:
run:
working-directory: mobile
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# The reachability verdict is read straight off history. On a shallow checkout
# `git merge-base --is-ancestor` answers from grafted parents, so the guard refuses to
# answer at all rather than reporting a pass it has no evidence for -- and the pinned tree
# below has to be checkable out.
fetch-depth: 0
# Ancestry needs commits; the pinned worktree fetches its historical blobs on demand.
filter: blob:none
- uses: ./.github/actions/install-node-dependencies
with:
cache-dependency-path: |
pnpm-lock.yaml
mobile/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Seconds. No `--ref`, so the pin is judged against the same tree it was read out of. On a
# pull request that is the merge preview, which already carries main's repins; judging the
# branch head instead fails every branch cut before the day's repin, and its instruction would
# tell the author to pin their own head. A branch that pins its own commit still passes on the
# push after the squash: the guard asks GitHub which pull requests hold the pin and fetches
# their `refs/pull/<n>/head`, which GitHub keeps for good. The token lifts the API rate limit.
- name: Check the recording pin is reachable
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
run: pnpm exec tsx scripts/rpc-recording-pin-guard.mts reachable
# ~2 min locally for the record itself, so it is gated rather than run twice over. A pull
# request that moves none of the corpus, the manifest or the recorder cannot move this
# verdict away from the one the base commit already published, and `verify` replays the
# corpus against the branch tree in the meantime. A push to main has no `verify` job and is
# where a squash lands a spliced corpus, so there it always runs.
- name: Reproduce the corpus from the pinned tree
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
PIN_GUARD_BASE: ${{ github.event.pull_request.base.sha }}
run: |
if [ -n "$PIN_GUARD_BASE" ]; then
pnpm exec tsx scripts/rpc-recording-pin-guard.mts reproduce --if-changed-since "$PIN_GUARD_BASE"
else
pnpm exec tsx scripts/rpc-recording-pin-guard.mts reproduce
fi