From 1e45b627ce7bb5b2ad665d426d73489686879a10 Mon Sep 17 00:00:00 2001 From: zpyoung Date: Mon, 28 Sep 2026 12:31:36 -0500 Subject: [PATCH 1/5] docs(sync-skill): re-merge seams without -X ours, and drop the retired mobile-web typecheck --- .claude/skills/sync-upstream/SKILL.md | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/.claude/skills/sync-upstream/SKILL.md b/.claude/skills/sync-upstream/SKILL.md index e5fd4dc78acf..fdbd9cd38265 100644 --- a/.claude/skills/sync-upstream/SKILL.md +++ b/.claude/skills/sync-upstream/SKILL.md @@ -392,6 +392,19 @@ output lists — `checkout.txt`, `remove.txt`, `ours.txt`, `merge-review.txt`. ` no shell command: those paths took a real three-way merge and must be read by hand against the manifest's declared lines. +`-X ours` resolved every conflicting hunk in those files toward the fork, so re-merge each seam file +without it (`git merge-file --diff3 -p` with `$MERGE_HEAD_PRE`, `$PREV_TAG`, `$UPSTREAM_TARGET`) and +resolve every file that reports a conflict. Do this even where `--verify-residuals` passes: a +discarded upstream hunk the same size as the fork's keeps the budget intact. v1.4.215 had two such +files that the residual check never flagged: `pr-e2e-gate-contract.test.mjs`, still naming a job +upstream had retired, and `NativeChatToolLine.tsx`, with a hover-group rename lost. + +The `ours.txt` three-way merge has a matching blind spot. A clean merge imports upstream's new +assertions into an exception-owned contract test even when they name a workflow or job the fork +deletes. v1.4.215 did this for `track-community-prs.yaml` and `package_windows`. Nothing flags it +until PR CI, so grep every merged exception test for the paths the manifest marks `deleted: true` +and for jobs missing from the fork's `pr.yml`. + The reference also carries three per-sync checklists that are part of this step, not optional extras: **tier-2 forked-copy replay**, **tier-4 pending-upstream review**, and **upstream feature-collision review**. Each can surface a decision the reference routes to a human. Under @@ -646,19 +659,21 @@ Order: `pnpm install --frozen-lockfile` (re-run it here — the merge may have t **Never run `pnpm typecheck` here.** `AGENTS.md` forbids it on this machine: it is `run-typecheck-projects-in-parallel.mjs`, which spawns a `tsc --noEmit` per project concurrently, -and with several sync worktrees open that saturates every core. Run the same four projects one -after another instead — same coverage, no concurrency — clearing the build cache before each, since +and with several sync worktrees open that saturates every core. Run the projects it lists in +`TYPECHECK_PROJECTS` one after another instead — same coverage, no concurrency — clearing the build cache before each, since composite projects cache errors across the `git checkout` swaps this step is full of: ```sh find config -maxdepth 1 -name '*.tsbuildinfo' -delete && pnpm run typecheck:node find config -maxdepth 1 -name '*.tsbuildinfo' -delete && pnpm run typecheck:web find config -maxdepth 1 -name '*.tsbuildinfo' -delete && pnpm run typecheck:cli -find config -maxdepth 1 -name '*.tsbuildinfo' -delete && \ - pnpm exec tsc --noEmit -p config/tsconfig.mobile-web.json ``` -Four separate commands on purpose, not a loop: under the sandboxed shell a command in a `for` body +Read the project list from the tag's `config/scripts/run-typecheck-projects-in-parallel.mjs` rather +than from here, because upstream edits it. v1.4.215 retired `config/tsconfig.mobile-web.json` in +#22193, and a gate that still ran it failed with a missing-file error that names no type problem. + +Separate commands on purpose, not a loop: under the sandboxed shell a command in a `for` body can come back "command not found", which inverts the result silently, and a loop's `|| break` would swallow the exit code this gate turns on. Read each one's status before running the next. `zsh` has no `PIPESTATUS`, so do not pipe these into `tail` and expect `$?` to mean anything — redirect to a From 32ea833801649844d67d90a3d99d43e662859a33 Mon Sep 17 00:00:00 2001 From: zpyoung Date: Sun, 11 Oct 2026 09:04:20 -0500 Subject: [PATCH 2/5] docs(sync-skill): stop early on an undecided sync escalation, and force-add ignored tree-conflict paths --- .claude/skills/sync-upstream/SKILL.md | 28 ++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/.claude/skills/sync-upstream/SKILL.md b/.claude/skills/sync-upstream/SKILL.md index e5fd4dc78acf..5c7c8661db49 100644 --- a/.claude/skills/sync-upstream/SKILL.md +++ b/.claude/skills/sync-upstream/SKILL.md @@ -256,6 +256,27 @@ This merge is fork-against-fork, so `-X ours` is wrong here — it would discard Then re-run the Step 8 gate whole before pushing: the merge can break a file neither side broke. +### An undecided escalation may already block this tag + +A run that stops on a human decision leaves no PR, so the check above cannot see it. It leaves a +ledger `decision` naming the tag and a `sync-resolved/-` tag instead. Until someone +decides, every later run repeats the same merge against the same inputs and stops in the same place: +decision-298 stopped v1.4.222, v1.4.223 and v1.4.224 syncs on four consecutive days, the last two +against a `main` that had gained only README badge commits. Check before Step 3: + +```sh +orca ledger list --type decision --state open --json # titles naming a sync tag +git log --oneline --no-merges ""..origin/main \ + | grep -v 'Update README downloads badge' +git diff --stat "" "$UPSTREAM_TARGET" -- +``` + +If the decision is still `proposed`, `main` gained no fork commit since the run that raised it, and +the new tag leaves the decision's files unchanged, stop before Step 3 and report "needs attention: + is still undecided; nothing merged". The run cannot settle the decision, and re-running +the merge only re-proves it. If any of the three has moved, run the normal procedure: the collision +may have dissolved, and that is worth finding out. + ## Step 3 — Backup Build a UTC stamp for the run (`date -u +%Y%m%d-%H%M%SZ`, or the PowerShell equivalent on Windows) @@ -356,10 +377,15 @@ keeping the fork's version is correct — the fork owns its own version line. auto-resolvable, and only in the fork's favor: - **"deleted by them / modified by us"** (upstream deleted it, the fork modified it) → keep the - fork's file: `git checkout --ours -- ` then `git add `. + fork's file: `git checkout --ours -- ` then `git add -f `. The `-f` is needed because + `docs/**` is gitignored while upstream still tracks files under it, and a plain `git add` of a + conflicted ignored path refuses with "paths are ignored" and exits non-zero. - **"deleted by us / modified by them"** (the fork deleted it, upstream modified it) → honor the fork's deletion: `git rm -f `. +Run the two rules as separate commands. Chained with `&&`, a refused `git add` silently skips +every `git rm`, and the commit then fails on paths you believed were resolved. + Then `git commit --no-edit --no-verify`. Record `resolution=auto-ours+tree` and log every path touched with the rule applied. `--no-verify` is required, not a shortcut: husky's pre-commit hook runs `oxlint` over the staged tree, and the `-X ours` tree is exactly the tree Step 6 exists to From 5cbbb311686b2d33fa57e9f75da65aa77d839775 Mon Sep 17 00:00:00 2001 From: zpyoung Date: Mon, 19 Oct 2026 14:06:55 -0500 Subject: [PATCH 3/5] docs(sync-skill): stack a directed sync on an open older sync PR, and recognize tag-tip workflow swaps --- .claude/skills/sync-upstream/SKILL.md | 7 +++++ .../sync-upstream/references/sync-lessons.md | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/.claude/skills/sync-upstream/SKILL.md b/.claude/skills/sync-upstream/SKILL.md index 9abd2c1500e8..db7d95c456f3 100644 --- a/.claude/skills/sync-upstream/SKILL.md +++ b/.claude/skills/sync-upstream/SKILL.md @@ -199,6 +199,13 @@ resolution and races it. Stop before Step 3 — nothing backed up, merged, or pu next run syncs `$STABLE_TAG` fresh. Hit on 2026-09-10 (#48, v1.4.198 vs v1.4.199) and 2026-09-26 (#92, v1.4.209 vs v1.4.212). +That stop is for the unattended run. When a person directs the run past it, stack instead of +racing: back up `main`, `git reset --hard` to the older PR's head, merge `$STABLE_TAG` onto it with +`-X ours`, and resolve from Step 6 as usual, with the older tag's resolution already in the tree. +Open the result as a new PR. Landing it lands both tags in one merge commit, and GitHub marks the +older PR merged because its head becomes an ancestor of `main`. Proven 2026-10-19: #122 (v1.4.230) +on #120 (v1.4.227). + A PR titled `sync: absorb upstream $STABLE_TAG` is this run's work, already done. **Adopt it — do not re-merge.** Verify it before you build on it, because its body is its own account and not evidence: diff --git a/.claude/skills/sync-upstream/references/sync-lessons.md b/.claude/skills/sync-upstream/references/sync-lessons.md index 3ac4231bc5c9..b6ddc372b274 100644 --- a/.claude/skills/sync-upstream/references/sync-lessons.md +++ b/.claude/skills/sync-upstream/references/sync-lessons.md @@ -356,3 +356,32 @@ want the same action for opposite reasons — so say which one happened in the r `v1.4.206` at `b10226a7ed`, the next run sees a tag `main` does not contain and merges it, leaving `main` with two distinct "release: v1.4.206" commits. That merge is legitimate — the tag would be a real new release — but the duplicate is worth expecting rather than diagnosing from scratch. + +## A tag whose last commit swaps in main's workflows + +**What happened.** Upstream now tags some stable releases on top of a release-branch commit that +replaces that branch's `.github/workflows/` files with `main`'s, so the release token is allowed to +push the tag. v1.4.227 carried `a220036892` ("ci: carry main's workflow files so the release tag +can be pushed", 63 files) and v1.4.230 carried `597425845e` ("ci: match main's workflow files so the +release token can tag this branch", 9 files) directly below `release: v1.4.230`. The release +branch's `config/scripts/` tests stayed at the branch's own age, so the tag disagrees with itself: +on PR #122, 8 workflow-contract tests were red with the test and every file it reads byte-identical +to `v1.4.230`. `pr.yml` also gained main's `dependency_isolation` job (+65 lines) without the scope +script that emits its selection, so `verify` failed every PR with "missing or invalid selection". +Step 6 resets upstream-owned workflows to the tag, which is exactly what imports the mismatch. + +**The tell.** The commit directly below `release: ` touches only `.github/workflows/`: + +```sh +git log --oneline -3 "$UPSTREAM_TARGET" +git show --stat --format='%h %s' "$UPSTREAM_TARGET^" | tail -3 +``` + +A test red in CI whose file and inputs all match the tag (`git diff --quiet "$UPSTREAM_TARGET" -- + `) is the same thing seen from the other end. It is not merge damage and not flake. + +**The right move.** For every upstream-owned workflow that commit touched, take its parent's copy +(`git checkout "^" -- `), which is the release branch's own consistent pair of workflow +and test. For a fork-owned workflow such as `pr.yml`, rebuild from the parent too, or strip that +commit's hunks out of it by hand. Do not take main's version of the scripts to match the workflows, +and do not edit the red tests: they are upstream-owned and correct for the release they ship in. From 6277d512b505c442f9b6a8919e5efd90f85817e8 Mon Sep 17 00:00:00 2001 From: zpyoung Date: Mon, 19 Oct 2026 15:28:33 -0500 Subject: [PATCH 4/5] feat(fork-sync-gate): check locally what PR CI would reject before a sync pushes - check-pr-verify-selections: every job verify requires has a selection pr-code-change-scope.mjs emits, and every script pr.yml runs exists (it names v1.4.230's unroutable dependency_isolation job) - replay-pr-preflight: runs pr.yml's preflight steps locally in one pass instead of one CI round per failing step - wait-pr-checks: one background wait that exits on the first gating failure, a moved head, or a green gate after an advisory window - pr.yml: drop the mobile CI tools step's reference to a test the fork deletes, which the selection check found - .gitignore: ignore the release-JavaScript benchmark's default output dir --- .github/workflows/pr.yml | 1 - .gitignore | 1 + config/fork-ownership.json | 9 +- .../check-pr-verify-selections.mjs | 147 +++++++++++++ .../check-pr-verify-selections.test.mjs | 101 +++++++++ .../fork-sync-gate/replay-pr-preflight.mjs | 208 ++++++++++++++++++ .../replay-pr-preflight.test.mjs | 83 +++++++ .../scripts/fork-sync-gate/wait-pr-checks.mjs | 160 ++++++++++++++ .../fork-sync-gate/wait-pr-checks.test.mjs | 52 +++++ 9 files changed, 759 insertions(+), 3 deletions(-) create mode 100644 config/scripts/fork-sync-gate/check-pr-verify-selections.mjs create mode 100644 config/scripts/fork-sync-gate/check-pr-verify-selections.test.mjs create mode 100644 config/scripts/fork-sync-gate/replay-pr-preflight.mjs create mode 100644 config/scripts/fork-sync-gate/replay-pr-preflight.test.mjs create mode 100644 config/scripts/fork-sync-gate/wait-pr-checks.mjs create mode 100644 config/scripts/fork-sync-gate/wait-pr-checks.test.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9267e29dfb55..88d2832097a9 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -596,7 +596,6 @@ jobs: config/scripts/mobile-test-change-scope.test.mjs config/scripts/pr-ready-check-reuse.test.mjs config/scripts/ci-dependency-download-cache.test.mjs - config/scripts/win32-test-lane-registration.test.mjs config/scripts/vitest-node-runtime-boundary.test.ts config/scripts/vitest-sqlite-runtime-boundary.test.ts diff --git a/.gitignore b/.gitignore index 4ee3b5dee97b..2e05a5989b72 100644 --- a/.gitignore +++ b/.gitignore @@ -191,5 +191,6 @@ ci-shards/ # Paired CI timing reports and temporary baseline plugin. .build/preflight-benchmark/ /.build/windows-native-benchmark/ +/.build/release-javascript-measurements/ .local/static-analysis-research/ diff --git a/config/fork-ownership.json b/config/fork-ownership.json index 8ad8d8cc219e..2ea605c07fbd 100644 --- a/config/fork-ownership.json +++ b/config/fork-ownership.json @@ -163,6 +163,11 @@ "purpose": "Precheck that lets the daily sync automation skip days with no upstream stable release and nothing to release.", "globs": ["**/fork-sync-precheck/**"] }, + { + "name": "fork-sync-gate", + "purpose": "Local checks and a CI wait that let the sync skill find what PR CI would reject before pushing, and drive the PR from one background command.", + "globs": ["**/fork-sync-gate/**"] + }, { "name": "fork-ownership-guard", "purpose": "Teaches upstream's PR aggregate-gate tests that the fork's ownership guard is an ungated job required by verify.", @@ -787,7 +792,7 @@ "path": ".gitignore", "feature": "fork-infra", "kind": "passthrough", - "lines": ["!docs/fork-upstreaming.md"] + "lines": ["!docs/fork-upstreaming.md", "/.build/release-javascript-measurements/"] }, { "path": ".oxlintrc.json", @@ -6570,7 +6575,7 @@ "removed": 0 }, ".gitignore": { - "added": 14, + "added": 15, "removed": 2 }, ".oxlintrc.json": { diff --git a/config/scripts/fork-sync-gate/check-pr-verify-selections.mjs b/config/scripts/fork-sync-gate/check-pr-verify-selections.mjs new file mode 100644 index 000000000000..2ad290fe8a9f --- /dev/null +++ b/config/scripts/fork-sync-gate/check-pr-verify-selections.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +/** + * Proves `.github/workflows/pr.yml` can route every job its `verify` gate requires, so a sync never + * pushes a workflow whose gate fails every PR with "missing or invalid selection". + * + * Checks, all against the working tree: + * - every `check_job` selection read from `needs.code_paths.outputs.` is a declared `code_paths` + * output, and one backed by `steps.filter` is a key `pr-code-change-scope.mjs` actually emits; + * - every `needs..result` it reads names a job that exists and is listed in `verify.needs`; + * - every repository script or local action the workflow runs exists. + * + * Exits 1 with one line per finding; prints nothing else on success unless `--verbose`. + */ +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { parse } from 'yaml' + +const WORKFLOW = '.github/workflows/pr.yml' +const SCOPE_SCRIPT = 'config/scripts/pr-code-change-scope.mjs' +const CHECK_JOB_CALL = /^\s*check_job\s+(\S+)\s+"\$(\w+)"\s+"\$(\w+)"/gm +const CODE_PATHS_OUTPUT = /needs\.code_paths\.outputs\.([\w-]+)/g +const STEP_OUTPUT = /steps\.([\w-]+)\.outputs\.([\w-]+)/g +const JOB_RESULT = /needs\.([\w-]+)\.result/g +const REPO_PATH = /(? step.run ?? '').join('\n') + + for (const [, name, resultVar, shouldRunVar] of matches(CHECK_JOB_CALL, script)) { + const shouldRun = env[shouldRunVar] + if (shouldRun === undefined) { + problems.push(`verify: check_job ${name} reads $${shouldRunVar}, which verify never sets`) + } + for (const [, output] of matches(CODE_PATHS_OUTPUT, shouldRun)) { + const producer = codePathsOutputs[output] + if (producer === undefined) { + problems.push( + `verify: check_job ${name} reads code_paths output "${output}", which code_paths never declares` + ) + continue + } + for (const [, stepId, key] of matches(STEP_OUTPUT, producer)) { + if (stepId === 'filter' && !emittedSelections.has(key)) { + problems.push( + `verify: check_job ${name} needs selection "${key}", which ${SCOPE_SCRIPT} never emits` + ) + } + } + } + for (const [, job] of matches(JOB_RESULT, env[resultVar])) { + if (!jobs[job]) { + problems.push( + `verify: check_job ${name} reads the result of job "${job}", which does not exist` + ) + } else if (!verifyNeeds.has(job)) { + problems.push(`verify: check_job ${name} reads job "${job}", which verify does not need`) + } + } + } + + for (const [jobName, job] of Object.entries(jobs)) { + const referenced = new Set() + if (typeof job.uses === 'string' && job.uses.startsWith('./')) { + referenced.add(job.uses.slice(2)) + } + for (const step of job.steps ?? []) { + if (typeof step.uses === 'string' && step.uses.startsWith('./')) { + referenced.add(step.uses.slice(2)) + } + for (const [, path] of matches(REPO_PATH, step.run)) { + referenced.add(path) + } + } + for (const path of referenced) { + if (!pathExists(path)) { + problems.push(`${jobName}: runs ${path}, which does not exist`) + } + } + } + return problems +} + +/** Keys the scope script prints when every tracked file counts as changed. */ +function emittedSelectionKeys() { + const files = execFileSync('git', ['ls-files'], { encoding: 'utf8', maxBuffer: 1 << 28 }) + const output = execFileSync('node', [SCOPE_SCRIPT], { + encoding: 'utf8', + input: files, + maxBuffer: 1 << 26 + }) + return new Set( + output + .split('\n') + .map((line) => line.split('=')[0].trim()) + .filter(Boolean) + ) +} + +function main() { + const workflow = parse(readFileSync(WORKFLOW, 'utf8')) + const problems = findVerifySelectionProblems(workflow, { + emittedSelections: emittedSelectionKeys(), + pathExists: (path) => existsSync(path) + }) + if (problems.length > 0) { + console.error(`${WORKFLOW} cannot route everything its verify gate requires:`) + for (const problem of problems) { + console.error(` ${problem}`) + } + process.exit(1) + } + if (process.argv.includes('--verbose')) { + console.log(`${WORKFLOW}: every verify selection has a producer and every script exists`) + } +} + +const entry = process.argv[1] +if (entry && import.meta.url === pathToFileURL(resolve(entry)).href) { + main() +} diff --git a/config/scripts/fork-sync-gate/check-pr-verify-selections.test.mjs b/config/scripts/fork-sync-gate/check-pr-verify-selections.test.mjs new file mode 100644 index 000000000000..c16e2d76387e --- /dev/null +++ b/config/scripts/fork-sync-gate/check-pr-verify-selections.test.mjs @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { findVerifySelectionProblems } from './check-pr-verify-selections.mjs' + +// shaped after v1.4.230's pr.yml, whose release branch shipped dependency_isolation without routing +function workflow({ verifyNeeds = ['code_paths', 'lint', 'dependency_isolation'] } = {}) { + return parse(` +jobs: + code_paths: + outputs: + lint: \${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.lint }} + dependency_isolation: \${{ steps.filter.outputs.dependency_isolation }} + steps: + - id: filter + run: node config/scripts/pr-code-change-scope.mjs + lint: + steps: + - uses: ./.github/actions/install-node-dependencies + - run: node config/scripts/check-lint.mjs + dependency_isolation: + steps: + - run: pnpm exec vitest run config/scripts/undeclared-dependency-resolution.test.ts + verify: + needs: ${JSON.stringify(verifyNeeds)} + steps: + - env: + LINT: \${{ needs.lint.result }} + LINT_SHOULD_RUN: \${{ needs.code_paths.outputs.lint }} + DEPENDENCY_ISOLATION: \${{ needs.dependency_isolation.result }} + DEPENDENCY_ISOLATION_SHOULD_RUN: \${{ needs.code_paths.outputs.dependency_isolation }} + run: | + check_job lint "$LINT" "$LINT_SHOULD_RUN" + check_job dependency_isolation "$DEPENDENCY_ISOLATION" "$DEPENDENCY_ISOLATION_SHOULD_RUN" +`) +} + +const present = new Set([ + '.github/actions/install-node-dependencies', + 'config/scripts/pr-code-change-scope.mjs', + 'config/scripts/check-lint.mjs', + 'config/scripts/undeclared-dependency-resolution.test.ts' +]) +const pathExists = (path) => present.has(path) + +describe('findVerifySelectionProblems', () => { + it('accepts a workflow whose every selection is emitted and every script exists', () => { + expect( + findVerifySelectionProblems(workflow(), { + emittedSelections: new Set(['lint', 'dependency_isolation']), + pathExists + }) + ).toEqual([]) + }) + + it('names a verify selection the scope script never emits', () => { + expect( + findVerifySelectionProblems(workflow(), { + emittedSelections: new Set(['lint']), + pathExists + }) + ).toEqual([ + 'verify: check_job dependency_isolation needs selection "dependency_isolation", which config/scripts/pr-code-change-scope.mjs never emits' + ]) + }) + + it('names a script the workflow runs that the tree does not have', () => { + const missingTest = (path) => + path !== 'config/scripts/undeclared-dependency-resolution.test.ts' && pathExists(path) + expect( + findVerifySelectionProblems(workflow(), { + emittedSelections: new Set(['lint', 'dependency_isolation']), + pathExists: missingTest + }) + ).toEqual([ + 'dependency_isolation: runs config/scripts/undeclared-dependency-resolution.test.ts, which does not exist' + ]) + }) + + it('names a checked job that verify does not wait for', () => { + expect( + findVerifySelectionProblems(workflow({ verifyNeeds: ['code_paths', 'lint'] }), { + emittedSelections: new Set(['lint', 'dependency_isolation']), + pathExists + }) + ).toEqual([ + 'verify: check_job dependency_isolation reads job "dependency_isolation", which verify does not need' + ]) + }) + + it('ignores selections produced by steps other than the scope filter', () => { + const fromE2eFilter = workflow() + fromE2eFilter.jobs.code_paths.outputs.dependency_isolation = + '${{ steps.e2e_filter.outputs.should_run }}' + expect( + findVerifySelectionProblems(fromE2eFilter, { + emittedSelections: new Set(['lint']), + pathExists + }) + ).toEqual([]) + }) +}) diff --git a/config/scripts/fork-sync-gate/replay-pr-preflight.mjs b/config/scripts/fork-sync-gate/replay-pr-preflight.mjs new file mode 100644 index 000000000000..4da7f7b9f351 --- /dev/null +++ b/config/scripts/fork-sync-gate/replay-pr-preflight.mjs @@ -0,0 +1,208 @@ +#!/usr/bin/env node +/** + * Runs the `run:` steps of a `.github/workflows/pr.yml` job (default `preflight`) locally, in order, + * and reports every failure in one pass, so a sync finds what CI's static-analysis job would reject + * before pushing instead of one CI round per failing step. + * + * It reads the workflow rather than a kept list, so it follows upstream's step changes. A step is + * skipped, and named in the report, when it cannot mean the same thing locally: its condition reads + * another step's output or a selection a sync PR never takes, it uses an expression this script + * cannot evaluate, or it is a typecheck, which the sync gate runs one project at a time. + * + * Usage: replay-pr-preflight.mjs [--job ] [--base ] [--only ] + */ +import { execFileSync, spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { parse } from 'yaml' + +const WORKFLOW = '.github/workflows/pr.yml' +const STEP_TIMEOUT_MS = 20 * 60 * 1000 +const ALWAYS_CONDITIONS = new Set(['', '!cancelled()', 'success()', 'always()']) +// selections every sync-sized PR takes; anything else may be false on a real PR +const SYNC_SELECTIONS = new Set(['static_analysis', 'typecheck']) +const EXPRESSION = /\$\{\{\s*([^}]*?)\s*\}\}/g + +/** The value of one `${{ }}` expression, or undefined when it cannot be evaluated locally. */ +export function evaluateExpression(expression, context) { + const selection = /^needs\.code_paths\.outputs\.([\w-]+)\s*==\s*'true'$/.exec(expression) + if (selection) { + return SYNC_SELECTIONS.has(selection[1]) ? 'true' : undefined + } + const repoVariable = /^vars\.\w+\s*\|\|\s*'([^']*)'$/.exec(expression) + if (repoVariable) { + return repoVariable[1] + } + switch (expression) { + case 'github.event.pull_request.base.sha': + return context.baseSha + case 'needs.code_paths.outputs.changed_count': + return String(context.changedCount) + case "job.status == 'success'": + return 'true' + default: + return undefined + } +} + +/** `text` with every expression substituted, or `{ unknown }` naming the first one it cannot. */ +export function substituteExpressions(text, context) { + let unknown + const value = String(text).replace(EXPRESSION, (whole, expression) => { + const result = evaluateExpression(expression, context) + if (result === undefined) { + unknown ??= expression + return whole + } + return result + }) + return unknown === undefined ? { value } : { unknown } +} + +function conditionVerdict(condition) { + const text = String(condition ?? '').trim() + if (ALWAYS_CONDITIONS.has(text)) { + return { run: true } + } + const selection = /^needs\.code_paths\.outputs\.([\w-]+)\s*==\s*'true'$/.exec(text) + if (selection && SYNC_SELECTIONS.has(selection[1])) { + return { run: true } + } + return { run: false, reason: `condition is CI-only: ${text}` } +} + +/** One entry per step: `{ name, script, env }` to run, or `{ name, skip }` with the reason. */ +export function planSteps(job, context) { + const plan = [] + for (const [index, step] of (job.steps ?? []).entries()) { + if (typeof step.run !== 'string') { + continue + } + const name = step.name ?? step.id ?? `step ${index}` + const verdict = conditionVerdict(step.if) + if (!verdict.run) { + plan.push({ name, skip: verdict.reason }) + continue + } + if (/\btypecheck(:ci)?\b/.test(step.run) && /pnpm (run )?typecheck/.test(step.run)) { + plan.push({ name, skip: 'typecheck runs one project at a time in the sync gate instead' }) + continue + } + const env = {} + const unknown = [] + for (const [key, raw] of Object.entries({ ...job.env, ...step.env })) { + const result = substituteExpressions(raw, context) + if (result.unknown !== undefined) { + unknown.push(result.unknown) + } + env[key] = result.value + } + const script = substituteExpressions(step.run, context) + if (script.unknown !== undefined) { + unknown.push(script.unknown) + } + if (unknown.length > 0) { + plan.push({ + name, + skip: `uses an expression this replay cannot evaluate: ${unknown.join(', ')}` + }) + continue + } + plan.push({ name, script: script.value, env }) + } + return plan +} + +function git(args) { + return execFileSync('git', args, { encoding: 'utf8', maxBuffer: 1 << 28 }).trim() +} + +function option(name, fallback) { + const at = process.argv.indexOf(name) + return at === -1 ? fallback : process.argv[at + 1] +} + +function main() { + const jobId = option('--job', 'preflight') + const only = option('--only') + const baseSha = git(['merge-base', option('--base', 'origin/main'), 'HEAD']) + const changedCount = git(['diff', '--name-only', '--no-renames', baseSha, 'HEAD']) + .split('\n') + .filter(Boolean).length + const job = parse(readFileSync(WORKFLOW, 'utf8'))?.jobs?.[jobId] + if (!job) { + console.error(`${WORKFLOW} has no job "${jobId}"`) + process.exit(2) + } + const plan = planSteps(job, { baseSha, changedCount }).filter( + (step) => !only || step.name.includes(only) + ) + const scratch = mkdtempSync(join(tmpdir(), 'replay-pr-preflight-')) + const runnerFiles = { + GITHUB_OUTPUT: join(scratch, 'output'), + GITHUB_ENV: join(scratch, 'env'), + GITHUB_STEP_SUMMARY: join(scratch, 'summary') + } + for (const file of Object.values(runnerFiles)) { + writeFileSync(file, '') + } + // workflow steps call bun and other tools by bare name; the repo pins them in node_modules/.bin + const path = [resolve('node_modules/.bin'), process.env.PATH].join(delimiter) + console.log( + `Replaying ${WORKFLOW} job ${jobId} against ${baseSha.slice(0, 10)} (${changedCount} changed files)` + ) + + const failures = [] + for (const [index, step] of plan.entries()) { + if (step.skip) { + console.log(`SKIP ${step.name} (${step.skip})`) + continue + } + const log = join(scratch, `step-${index}.log`) + const started = Date.now() + const result = spawnSync('bash', ['--noprofile', '--norc', '-e', '-c', step.script], { + encoding: 'utf8', + env: { + ...process.env, + ...step.env, + ...runnerFiles, + CI: 'true', + PATH: path, + RUNNER_TEMP: scratch + }, + maxBuffer: 1 << 28, + timeout: STEP_TIMEOUT_MS, + windowsHide: true + }) + const output = `${result.stdout ?? ''}${result.stderr ?? ''}` + writeFileSync(log, output) + const seconds = Math.round((Date.now() - started) / 1000) + if (result.status === 0) { + console.log(`PASS ${step.name} (${seconds}s)`) + continue + } + const why = result.error ? result.error.message : `exit ${result.status}` + console.log(`FAIL ${step.name} (${why}, ${seconds}s): ${log}`) + console.log( + output + .trimEnd() + .split('\n') + .slice(-30) + .map((line) => ` ${line}`) + .join('\n') + ) + failures.push(step.name) + } + if (failures.length > 0) { + console.error(`\n${failures.length} step(s) failed: ${failures.join(', ')}`) + process.exit(1) + } + console.log('\nEvery replayable step succeeded.') +} + +const entry = process.argv[1] +if (entry && import.meta.url === pathToFileURL(resolve(entry)).href) { + main() +} diff --git a/config/scripts/fork-sync-gate/replay-pr-preflight.test.mjs b/config/scripts/fork-sync-gate/replay-pr-preflight.test.mjs new file mode 100644 index 000000000000..429fc6d9e659 --- /dev/null +++ b/config/scripts/fork-sync-gate/replay-pr-preflight.test.mjs @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { planSteps, substituteExpressions } from './replay-pr-preflight.mjs' + +const context = { baseSha: 'abc123', changedCount: 812 } + +describe('substituteExpressions', () => { + it('fills the base SHA, changed count, sync selections and repo-variable defaults', () => { + expect( + substituteExpressions( + "check ${{ github.event.pull_request.base.sha }} ${{ needs.code_paths.outputs.changed_count }} ${{ needs.code_paths.outputs.static_analysis == 'true' }} ${{ vars.MODE || 'shadow' }}", + context + ) + ).toEqual({ value: 'check abc123 812 true shadow' }) + }) + + it('reports an expression it cannot evaluate instead of guessing', () => { + expect(substituteExpressions('${{ steps.plan.outputs.shards }}', context)).toEqual({ + unknown: 'steps.plan.outputs.shards' + }) + }) +}) + +describe('planSteps', () => { + // the shapes pr.yml's preflight job uses: gated phases, CI-only smoke steps, and a parallel typecheck + const job = parse(` +steps: + - uses: actions/checkout@v6 + - name: Lint + if: '!cancelled()' + env: + PREFLIGHT_PHASE_SELECTED: \${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: \${{ job.status == 'success' }} + run: node node_modules/oxlint/bin/oxlint + - name: Enforce React Doctor on changed lines + if: needs.code_paths.outputs.static_analysis == 'true' + env: + CHANGED_COUNT: \${{ needs.code_paths.outputs.changed_count }} + run: node config/scripts/check-react-doctor-changed.mjs "\${{ github.event.pull_request.base.sha }}" + - name: Boot orcad and round-trip a terminal + if: steps.orcad-smoke-scope.outputs.run == 'true' + run: pnpm run smoke:orcad-terminal + - name: Test Mobile CI tools + if: needs.code_paths.outputs.mobile_ci_tools == 'true' + run: node config/scripts/run-vitest.mjs run + - name: Typecheck + run: pnpm run typecheck:ci --bun-concurrency=2 + - name: Upload plan + run: echo "\${{ steps.unit-plan.outputs.shards }}" +`) + + it('runs gated phases with the selections a sync PR takes', () => { + const [lint, reactDoctor] = planSteps(job, context) + expect(lint).toEqual({ + name: 'Lint', + script: 'node node_modules/oxlint/bin/oxlint', + env: { PREFLIGHT_PHASE_SELECTED: 'true', PREFLIGHT_PRIOR_SUCCESS: 'true' } + }) + expect(reactDoctor).toEqual({ + name: 'Enforce React Doctor on changed lines', + script: 'node config/scripts/check-react-doctor-changed.mjs "abc123"', + env: { CHANGED_COUNT: '812' } + }) + }) + + it('skips, with a reason, every step that cannot mean the same thing locally', () => { + expect(planSteps(job, context).slice(2)).toEqual([ + { + name: 'Boot orcad and round-trip a terminal', + skip: "condition is CI-only: steps.orcad-smoke-scope.outputs.run == 'true'" + }, + { + name: 'Test Mobile CI tools', + skip: "condition is CI-only: needs.code_paths.outputs.mobile_ci_tools == 'true'" + }, + { name: 'Typecheck', skip: 'typecheck runs one project at a time in the sync gate instead' }, + { + name: 'Upload plan', + skip: 'uses an expression this replay cannot evaluate: steps.unit-plan.outputs.shards' + } + ]) + }) +}) diff --git a/config/scripts/fork-sync-gate/wait-pr-checks.mjs b/config/scripts/fork-sync-gate/wait-pr-checks.mjs new file mode 100644 index 000000000000..bfb253ee094a --- /dev/null +++ b/config/scripts/fork-sync-gate/wait-pr-checks.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +/** + * Waits on a sync PR's checks and exits on the first thing the run must act on, so Step 10 waits + * with one background command instead of a hand-written poll loop. + * + * Gating checks are the jobs of the `PR Checks` workflow (`pr.yml`), whose `PR checks passed` job is + * the merge gate; every other workflow's check is advisory. Once the gate succeeds the wait gives + * advisory checks up to `--advisory-minutes` to settle, then reports them rather than blocking. + * + * Exit codes: 0 merge-ready, 1 a gating check failed, 2 timed out, 3 the head moved (another + * writer pushed), 4 the PR is no longer open, 5 the wait could not read the PR. + * + * Usage: wait-pr-checks.mjs --pr --head [--repo ] [--interval-seconds 120] + * [--advisory-minutes 15] [--timeout-minutes 240] + */ +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +export const GATE_WORKFLOW = 'PR Checks' +export const GATE_CHECK = 'PR checks passed' +const FAILED = new Set([ + 'FAILURE', + 'CANCELLED', + 'TIMED_OUT', + 'ACTION_REQUIRED', + 'STARTUP_FAILURE', + 'ERROR' +]) +const SETTLED = new Set(['SUCCESS', 'SKIPPED', 'NEUTRAL', ...FAILED]) + +function normalize(entry) { + if (entry.__typename === 'StatusContext') { + const state = String(entry.state ?? '').toUpperCase() + return { + name: entry.context, + workflow: '', + conclusion: state === 'PENDING' || state === 'EXPECTED' ? '' : state, + url: entry.targetUrl ?? '' + } + } + return { + name: entry.name, + workflow: entry.workflowName ?? '', + conclusion: entry.status === 'COMPLETED' ? String(entry.conclusion ?? '').toUpperCase() : '', + url: entry.detailsUrl ?? '' + } +} + +/** Where a PR's checks stand: the gate's state, plus gating and advisory checks needing attention. */ +export function classifyRollup(rollup) { + const checks = (rollup ?? []).map(normalize) + const gating = checks.filter((check) => check.workflow === GATE_WORKFLOW) + const advisory = checks.filter((check) => check.workflow !== GATE_WORKFLOW) + const gate = gating.find((check) => check.name === GATE_CHECK) + let gateState = 'pending' + if (gate?.conclusion === 'SUCCESS') { + gateState = 'success' + } else if (gate && FAILED.has(gate.conclusion)) { + gateState = 'failure' + } + return { + gate: gateState, + gatingFailures: gating.filter((check) => FAILED.has(check.conclusion)), + advisoryFailures: advisory.filter((check) => FAILED.has(check.conclusion)), + advisoryPending: advisory.filter((check) => !SETTLED.has(check.conclusion)) + } +} + +function option(name, fallback) { + const at = process.argv.indexOf(name) + return at === -1 ? fallback : process.argv[at + 1] +} + +function readPr(repo, pr) { + // an ambient GITHUB_TOKEN overrides gh's keyring login, which is the one with access to the fork + const env = { ...process.env } + delete env.GITHUB_TOKEN + const output = execFileSync( + 'gh', + ['pr', 'view', pr, '--repo', repo, '--json', 'state,headRefOid,statusCheckRollup'], + { encoding: 'utf8', env, maxBuffer: 1 << 26, windowsHide: true } + ) + return JSON.parse(output) +} + +function list(label, checks) { + for (const check of checks) { + console.log( + `${label} ${check.name} [${check.workflow || 'status'}] ${check.conclusion || 'PENDING'} ${check.url}` + ) + } +} + +function pause(seconds) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, seconds * 1000) +} + +function main() { + const pr = option('--pr') + const head = option('--head') + if (!pr || !head) { + console.error('usage: wait-pr-checks.mjs --pr --head ') + process.exit(5) + } + const repo = option('--repo', 'zpyoung/orca') + const intervalSeconds = Number(option('--interval-seconds', '120')) + const advisoryMs = Number(option('--advisory-minutes', '15')) * 60_000 + const deadline = Date.now() + Number(option('--timeout-minutes', '240')) * 60_000 + let gateGreenAt = 0 + let readFailures = 0 + + while (Date.now() < deadline) { + let view + try { + view = readPr(repo, pr) + readFailures = 0 + } catch (error) { + readFailures += 1 + console.error( + `could not read PR #${pr} (${readFailures}/5): ${String(error.stderr ?? error.message).trim()}` + ) + if (readFailures >= 5) { + process.exit(5) + } + pause(intervalSeconds) + continue + } + if (view.state !== 'OPEN') { + console.log(`PR #${pr} is ${view.state}`) + process.exit(4) + } + if (!view.headRefOid.startsWith(head)) { + console.log(`PR #${pr} head moved from ${head} to ${view.headRefOid}: another writer pushed`) + process.exit(3) + } + const status = classifyRollup(view.statusCheckRollup) + if (status.gatingFailures.length > 0) { + list('GATING FAILED', status.gatingFailures) + process.exit(1) + } + if (status.gate === 'success') { + gateGreenAt ||= Date.now() + if (status.advisoryPending.length === 0 || Date.now() - gateGreenAt >= advisoryMs) { + console.log(`${GATE_CHECK} is green on ${view.headRefOid}: merge-ready`) + list('ADVISORY FAILED', status.advisoryFailures) + list('ADVISORY PENDING', status.advisoryPending) + process.exit(0) + } + } + pause(intervalSeconds) + } + console.log(`timed out waiting on PR #${pr}`) + process.exit(2) +} + +const entry = process.argv[1] +if (entry && import.meta.url === pathToFileURL(resolve(entry)).href) { + main() +} diff --git a/config/scripts/fork-sync-gate/wait-pr-checks.test.mjs b/config/scripts/fork-sync-gate/wait-pr-checks.test.mjs new file mode 100644 index 000000000000..bb8e6ab2ec8f --- /dev/null +++ b/config/scripts/fork-sync-gate/wait-pr-checks.test.mjs @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { classifyRollup } from './wait-pr-checks.mjs' + +function check(name, workflowName, conclusion) { + return { + __typename: 'CheckRun', + name, + workflowName, + status: conclusion ? 'COMPLETED' : 'IN_PROGRESS', + conclusion: conclusion ?? '', + detailsUrl: `https://example.test/${name}` + } +} + +describe('classifyRollup', () => { + it('stays pending until the PR Checks gate settles', () => { + expect( + classifyRollup([ + check('lint', 'PR Checks', 'SUCCESS'), + check('PR checks passed', 'PR Checks') + ]).gate + ).toBe('pending') + }) + + it('reports a failed pr.yml job as gating before the gate itself settles', () => { + const status = classifyRollup([ + check('fork ownership guard', 'PR Checks', 'FAILURE'), + check('PR checks passed', 'PR Checks') + ]) + expect(status.gatingFailures.map((failed) => failed.name)).toEqual(['fork ownership guard']) + }) + + it('treats other workflows as advisory, whatever their state', () => { + // benchmark and SSH-host workflows ran on every sync PR without being part of verify + const status = classifyRollup([ + check('PR checks passed', 'PR Checks', 'SUCCESS'), + check('compilers', 'Preflight benchmark'), + check('updater', 'Updater', 'FAILURE'), + { __typename: 'StatusContext', context: 'external', state: 'PENDING', targetUrl: '' } + ]) + expect(status.gate).toBe('success') + expect(status.gatingFailures).toEqual([]) + expect(status.advisoryFailures.map((failed) => failed.name)).toEqual(['updater']) + expect(status.advisoryPending.map((pending) => pending.name)).toEqual(['compilers', 'external']) + }) + + it('counts a cancelled gate as a failure', () => { + expect(classifyRollup([check('PR checks passed', 'PR Checks', 'CANCELLED')]).gate).toBe( + 'failure' + ) + }) +}) From 4b04d547efcc260931a5eb86e5e75ec9b87880dd Mon Sep 17 00:00:00 2001 From: zpyoung Date: Mon, 19 Oct 2026 15:29:37 -0500 Subject: [PATCH 5/5] docs(sync-skill): stack on an open sync PR, ask instead of stopping, and match CI locally From the v1.4.230 retro, which read every session of that sync: - an open older-tag sync PR is now the base to stack on and close, not a hard stop that stalled the sync for days three times - escalations put the decision to the human through orca ask and wait up to 12h; a run never ends its turn while its PR is open - Step 8 adds a clean-tree check, the verify-selection check, both PR guards and a local replay of pr.yml's preflight job - Step 6 sweeps the release for upstream signing, runner and repo identity - Step 10 defines pr.yml as gating and every other workflow as advisory, waits with one background command, and Step 11 merges after a 15-minute advisory window with --match-head-commit - one writer per sync PR; fix commits stage by path - the fix policy may remove a pr.yml job the release cannot route, with a ledger entry to restore it - qualifying skill-only lesson PRs are merged by the run --- .claude/skills/sync-upstream/SKILL.md | 210 +++++++++++++----- .../references/file-ownership.md | 6 +- 2 files changed, 163 insertions(+), 53 deletions(-) diff --git a/.claude/skills/sync-upstream/SKILL.md b/.claude/skills/sync-upstream/SKILL.md index 0a7eb4dd597f..59c9f82fc5af 100644 --- a/.claude/skills/sync-upstream/SKILL.md +++ b/.claude/skills/sync-upstream/SKILL.md @@ -25,8 +25,23 @@ previous runs of this exact procedure, and it exists so you do not spend the bud wrong turn one of them already paid for. Arguments: `--unattended` suppresses every confirmation prompt. It does **not** grant extra -latitude: an unattended run stops and reports wherever an attended run would ask a human, and the -decisions this skill routes to a human stay routed to a human. +latitude: the decisions this skill routes to a human stay routed to a human. + +**Escalating a decision means asking and waiting, attended or not.** Put the decision to the human +through `orca ask` (load the `orca-ask` skill for the CLI), then wait with +`orca-ask-wait 43200` run as a background command, which polls for up to 12 hours. On an +answer, continue as the human chose. On `declined`, `timed_out` or `unavailable`, stop and report +"needs attention: " — that is the only path to a decision-shaped stop. Build each +option from what you have read, naming the files it touches and which local and remote callers +depend on them, never from a feature's name: v1.4.230's first relay ask offered to retire +`native-chat-relay` outright when its renderer half also served local chat, and an agent had to +catch it. Read the gating code before asking at all — a second ask that run was withdrawn after one +`rg` showed the new store was dark by default. + +**A run ends only at Step 16.** Every wait — on CI, on an ask, on a release build — is a background +command whose exit re-invokes you. Never end the turn, schedule a wakeup, or hand back to the human +while the run's PR is open. The 2026-10-16 run ended its turn behind a background poll; its green PR +#120 then sat unmerged for 68 hours while the next three daily runs stopped behind it. The run needs a disposable branch in a workspace of its own; the scheduled automation supplies one by creating a fresh worktree per run. This skill never creates, renames, or deletes a branch or a @@ -188,23 +203,27 @@ env -u GITHUB_TOKEN gh pr list --repo zpyoung/orca --state open \ --json number,title,url,headRefName,headRefOid ``` -**A sync PR for an older tag stops the run here.** If an open PR is titled -`sync: absorb upstream ` for any tag other than `$STABLE_TAG`, upstream shipped a newer stable -release while an earlier sync was still unlanded. Neither continuation is safe: stable tags live on -per-release branches, so the older tag is not an ancestor of the newer one and the adoption checks -below fail by construction; and merging `$STABLE_TAG` onto a fresh `main` re-derives the older PR's -resolution and races it. Stop before Step 3 — nothing backed up, merged, or pushed — and report -"needs attention: sync PR # for is still open; land or close it before syncing -`$STABLE_TAG`". Once it lands, the newer tag is an ordinary one-release sync; once it is closed, the -next run syncs `$STABLE_TAG` fresh. Hit on 2026-09-10 (#48, v1.4.198 vs v1.4.199) and 2026-09-26 -(#92, v1.4.209 vs v1.4.212). - -That stop is for the unattended run. When a person directs the run past it, stack instead of -racing: back up `main`, `git reset --hard` to the older PR's head, merge `$STABLE_TAG` onto it with -`-X ours`, and resolve from Step 6 as usual, with the older tag's resolution already in the tree. -Open the result as a new PR. Landing it lands both tags in one merge commit, and GitHub marks the -older PR merged because its head becomes an ancestor of `main`. Proven 2026-10-19: #122 (v1.4.230) -on #120 (v1.4.227). +**A sync PR for an older tag is this run's base: stack on it, then close it.** If an open PR is +titled `sync: absorb upstream ` for a tag older than `$STABLE_TAG`, upstream shipped a newer +stable release while an earlier sync was still unlanded. Merging `$STABLE_TAG` onto a fresh `main` +would re-derive that PR's resolution and race it, and adopting it fails by construction, since +stable tags live on per-release branches and the older tag is not an ancestor of the newer one. +Build on its resolution instead: + +1. Check it as a base with the adoption checks below, testing that **its** tag is merged in place + of `$UPSTREAM_TARGET`. If it is not built on today's `main`, merge `main` into it first exactly + as that section describes. Red CI on it is not a reason to skip it: its failures become this + run's, and most of them are still real against the newer tag. +2. Back up as Step 3 says. In Step 4, reset to the older PR's head instead of `$ORIGIN_MAIN_OLD`, + and run Steps 5–8 as usual from there, with the older tag as `$PREV_TAG` for every Step 6 audit + diff: its resolution is already in the tree. +3. At Step 9, open the new PR, then close the older one with a comment naming its successor: + `env -u GITHUB_TOKEN gh pr close --repo zpyoung/orca --comment "Superseded by #, which + carries this resolution and $STABLE_TAG."` Leave its branch and backup ref alone. + +Landing the new PR lands both tags in one merge commit. Proven 2026-10-19: #122 (v1.4.230) on #120 +(v1.4.227). This used to be a hard stop, and it stalled the sync for days at a time: 2026-09-10 +(#48), 2026-09-26 (#92), and 2026-10-17 through 2026-10-19 (#120). A PR titled `sync: absorb upstream $STABLE_TAG` is this run's work, already done. **Adopt it — do not re-merge.** Verify it before you build on it, because its body is its own account and not evidence: @@ -440,9 +459,9 @@ and for jobs missing from the fork's `pr.yml`. The reference also carries three per-sync checklists that are part of this step, not optional extras: **tier-2 forked-copy replay**, **tier-4 pending-upstream review**, and **upstream -feature-collision review**. Each can surface a decision the reference routes to a human. Under -`--unattended`, that is a stopping condition: go to Step 16 with "needs attention: " -rather than choosing a side. +feature-collision review**. Each can surface a decision the reference routes to a human. Escalate +it as the Arguments section describes rather than choosing a side, and only an unanswered ask goes +to Step 16 with "needs attention: ". **`ours.txt` is the list that loses work silently, so audit it.** An exception wins the whole file, which also discards every unrelated upstream change under that path — no conflict, no marker, and @@ -608,6 +627,23 @@ that contains that upstream tag and nothing newer (`merge-base --is-ancestor` bo modules the harness loads are byte-identical across the two, repoint the constant, and declare it as a seam beside its siblings. +**Sweep the release for upstream identity the fork must not inherit.** Upstream hardcodes its own +signing team, bundle ids, runners and repository, and every new hardcode resets to the tag here. +v1.4.230 brought five: its skills install URL and Blacksmith runner labels failed PR CI, and its Apple +team id `6CX3WHS9HZ` passed PR CI and failed the first release build, which is the only place +signing is checked: + +```sh +git diff --name-only -G '6CX3WHS9HZ|com\.stablyai|blacksmith-|stablyai/orca' \ + "$PREV_TAG" "$UPSTREAM_TARGET" -- . ':!docs' ':!*.md' ':!*.test.*' ':!*.spec.*' ':!**/tests/**' +``` + +Every listed file is a decision. A user-facing link to upstream stays: pointing those at upstream +is deliberate. Anything the fork's build, signing, release, CI runners or skill installs read must +end up fork-owned, as a seam or an exception, before the push. A hit in a file the fork already +owns is usually upstream's new code next to the fork's existing override, so read the merged file +rather than the diff. + Commit the ownership resolution as a single follow-up commit on top of the merge, again with `--no-verify` for the reason in Step 5; Step 7 expects exactly one such extra commit. @@ -654,8 +690,16 @@ A fix may: it is never an escalation on its own. The fix is names and types only, taken from upstream's own adoption commit for that rule; `references/file-ownership.md` § *When upstream tightens the linter* has the method and its limits +- remove a job from the fork's `pr.yml` that the release cannot route: its `verify` selection has + no producer in the tag's `pr-code-change-scope.mjs`, or a script it runs does not exist in the + tag. `check-pr-verify-selections.mjs` (Step 8) names both cases. Remove the job, its `verify.needs` + entry, its `check_job` line and its env, and file a ledger `deferred` entry to restore it once a + stable tag ships the rest of the change: a later three-way merge against this tag sees only the + fork's removal, so it never brings the job back on its own. v1.4.230's release branch shipped + `dependency_isolation` that way (`597425845e`), and `verify` failed every PR until it was removed -Escalate — stop, leave the PR open, report "needs attention" — for: +Escalate — ask as the Arguments section describes, and on no answer stop, leave the PR open, and +report "needs attention" — for: - any resolution that would cost fork feature functionality: upstream reworked the code a fork feature hangs off, and every fix available loses behavior this fork ships @@ -675,7 +719,8 @@ Never, on either gate: green; "probably pre-existing" is a thing to prove in Step 10, not a reason to move on Each fix is its own commit, its message naming what broke and why the fix is the right one. Every -fix commit appears in the Step 16 report. +fix commit appears in the Step 16 report. Stage a fix by path (`git add `, or `git add -u` +for tracked files), never with `git add -A` or `git commit -a`. ## Step 8 — Verification gate @@ -688,7 +733,7 @@ specifies, including the manifest checks (`--verify-seams`, `--verify-residuals` fakes a result if skipped. Order: `pnpm install --frozen-lockfile` (re-run it here — the merge may have taken upstream's -`pnpm-lock.yaml`) → manifest checks → typecheck → `pnpm lint`. +`pnpm-lock.yaml`) → manifest checks → typecheck → `pnpm lint` → the CI-parity checks below. **Never run `pnpm typecheck` here.** `AGENTS.md` forbids it on this machine: it is `run-typecheck-projects-in-parallel.mjs`, which spawns a `tsc --noEmit` per project concurrently, @@ -717,6 +762,32 @@ file instead. unchecked — 216 errors across 110 files on `main` at v1.4.207, none of them a sync's doing. Do not add it to the gate, and do not treat its output as merge damage. +**Then run what PR CI checks before any test starts.** The `static analysis and typecheck` job +stops at its first failing step and the unit shards wait behind it, so every step this gate skips +costs a whole CI round to discover. v1.4.230 spent four rounds there, one new step each, and its +unit tests first ran on the fifth push. Commit first, because two of these read committed state, +then run each as its own command: + +```sh +git status --porcelain --untracked-files=all # must print nothing +node config/scripts/fork-sync-gate/check-pr-verify-selections.mjs +node .github/scripts/check-root-directory-entries.mjs "$(git merge-base origin/main HEAD)" HEAD +node .github/scripts/check-fork-ownership.mjs "$(git rev-parse origin/main)" "$(git rev-parse HEAD)" +node config/scripts/fork-sync-gate/replay-pr-preflight.mjs +``` + +- **The clean-tree check** catches build output before it is committed. Benchmark and sandbox runs + write into the tree, and `git add -A` swept `.build/release-javascript-measurements/` into a + v1.4.230 fix commit, which failed both guards below. +- **`check-pr-verify-selections.mjs`** proves `pr.yml` can route every job `verify` requires and + that every script it runs exists. The fix policy says what to do when it names a job the release + cannot route. +- **The two guards** are `pr.yml`'s own repository checks, run exactly as CI runs them. +- **`replay-pr-preflight.mjs`** reads the `preflight` job out of `pr.yml` and runs every step it can + locally, in order, without stopping at the first failure. It skips the typecheck (run above, one + project at a time) and steps whose condition only CI can evaluate, and names each skip. A full + replay takes about three minutes. + Every step is absolute: stop at the first failure and treat it as a hard fail. The reference's rule-tightening carve-out is the one exception, and it is an exception about *how the tree is fixed*, not about tolerating a failure — lint must still pass before the push, and the mechanical @@ -727,9 +798,9 @@ local run through a remote Docker host, and that host is a single shared box. Sh suite reports failures the code did not cause — timeouts, perf budgets, and temp-file races that land on a different random subset each run and clear the moment the same files run alone. Chasing them costs the run an hour and answers a question PR CI answers properly: the `test` matrix in -`.github/workflows/pr.yml` runs the same suite on clean hosted runners, sharded 16 ways across two -Node versions, and it is a required check for `verify`. A local pass never authorized a merge, and a -local failure was never trustworthy on its own — so the local run buys nothing the PR does not. +`.github/workflows/pr.yml` runs the same suite sharded across clean hosted runners, and it is a +required check for `verify`. A local pass never authorized a merge, and a local failure was never +trustworthy on its own — so the local run buys nothing the PR does not. A hard fail here is not the end of the run — work it under the fix policy above, then re-run the gate from the top. Re-run it whole: a fix for a typecheck error routinely breaks lint, and a partial @@ -784,20 +855,40 @@ Record `PR_NUMBER` and `PR_URL`. If a PR already exists for this branch, reuse i (`env -u GITHUB_TOKEN gh pr view "$SYNC_BRANCH" --repo zpyoung/orca --json number,url`) instead of opening a second one. +**One writer per sync PR.** Two sessions drove #122 at once: commits landed while the lead waited +on a poll, and the two merge commands ran two seconds apart. Record yourself as its driver +(`orca worktree set --worktree active --comment "driving PR # from session " --json`). Before +every push, fetch the branch and confirm its head is the commit you last pushed or adopted. A head +you did not write means another writer is active: stop editing, read its commits, and continue +from them or step back, rather than pushing over them. The wait in Step 10 exits on exactly that. + ## Step 10 — Drive the PR green -The run owns this PR until every required check passes or the budget runs out. The budget is -**4 hours** from PR creation; compute the deadline once and honor it. +The run owns this PR until its gate passes or the budget runs out. The budget is **4 hours** from +PR creation, not counting time spent waiting on an answer to an ask; compute the deadline once and +honor it. + +**Gating and advisory checks.** The merge gate is the `PR checks passed` job (job id `verify`) of the +`PR Checks` workflow, `.github/workflows/pr.yml`, on the PR's exact head commit, and every job of +that workflow is gating. Every other workflow's check is advisory: the benchmark comparisons, the +SSH host matrices, `updater` and the rest. Advisory checks are reported and never block the merge. A +failed advisory check is re-run once when its log shows a runner or network fault, and is named in +the report either way. Don't fix one in a sync PR: the benchmark workflows already carry fork-aware +baselines (`fork-benchmark-baseline`), and they still fail on a sync-sized diff by construction. -Poll the rollup rather than the web UI, at roughly two-minute intervals — `gh` shares the account's -API rate limit, so do not tighten that: +Wait with one background command, which polls every two minutes (`gh` shares the account's API rate +limit, so do not tighten it): ```sh -env -u GITHUB_TOKEN gh pr view "$PR_NUMBER" --repo zpyoung/orca \ - --json statusCheckRollup,mergeStateStatus,mergeable +node config/scripts/fork-sync-gate/wait-pr-checks.mjs --pr "$PR_NUMBER" --head "$(git rev-parse HEAD)" \ + --advisory-minutes 15 --timeout-minutes ``` -Wait between polls with a background wait or a monitor, never a foreground `sleep`. +It exits `1` on the first failed gating job and names it, which is usually well before `verify` +settles. It exits `0` once the gate is green and the advisory checks have settled, or 15 minutes +after the gate went green, whichever comes first. It exits `3` when the head moves under you (see +*One writer per sync PR*), `4` when the PR closes, and `2` at the deadline. Read `verify`'s own log +when it fails: it is not a rollup, and its last lines name the selection or job that failed. This is where the test suite runs, so a failing `tests node /` check is the run's problem to resolve, exactly like any other check. @@ -891,18 +982,23 @@ Every real failure goes through the fix policy above: diagnose the cause, fix it allows, commit it on its own, push, and let CI re-run. There is no fixed attempt limit — keep going while you are making progress and the budget holds. -Stop and report "needs attention", PR left open, nothing merged, when: the policy says escalate, the -same check fails after a fix aimed at it, or the deadline passes. Name the check, link the run, and -state what you tried. +Stop and report "needs attention", PR left open, nothing merged, when: an escalation's ask goes +unanswered, the same check fails after a fix aimed at it, or the deadline passes. Name the check, +link the run, and state what you tried. ## Step 11 — Merge the PR -Only when every required check is green. +Only when the Step 10 wait exited `0`: `PR checks passed` is green on the exact head, and the +advisory window has closed. First append to the PR body every fix commit pushed since it opened and +every advisory check still failing or pending. Then merge that exact commit: ```sh -env -u GITHUB_TOKEN gh pr merge "$PR_NUMBER" --repo zpyoung/orca --merge +env -u GITHUB_TOKEN gh pr merge "$PR_NUMBER" --repo zpyoung/orca --merge \ + --match-head-commit "$(git rev-parse HEAD)" ``` +`--match-head-commit` refuses the merge if another writer pushed after the wait cleared it. + `--merge` is mandatory. `--squash` and `--rebase` both mint new SHAs, which breaks this fork in two ways at once: it flattens the merge with `$UPSTREAM_TARGET`, so every later sync re-conflicts against a tag `main` no longer descends from, and it rewrites fork commit SHAs, so @@ -1052,7 +1148,8 @@ Open it from its own branch — never `$SYNC_BRANCH`, which has already merged: git fetch origin main git checkout -b "sync-lessons/${STABLE_TAG}" origin/main # edit .claude/skills/sync-upstream/** only -git commit -am "docs(sync-skill): " +git add .claude/skills/sync-upstream/ +git commit -m "docs(sync-skill): " git push -u origin "sync-lessons/${STABLE_TAG}" env -u GITHUB_TOKEN gh pr create --repo zpyoung/orca --base main \ --title "sync-skill: " --body-file @@ -1062,8 +1159,20 @@ The body carries the evidence: the command that misled you, its output, the SHA what the edit changes for the next run. A reviewer must be able to check the claim without reconstructing the run. -**This PR is never auto-merged and never merged by the run.** Leave it open and name it in the -report. It blocks nothing — the sync is already merged and any release already dispatched. +**The run merges this PR itself once it qualifies.** Unmerged lessons teach nobody: three sat open +for weeks (#99, #114, #125), and the v1.4.230 run hit #99's problem again. A lesson PR qualifies when +all of these hold: + +- `git diff --name-only origin/main...HEAD` lists only paths under `.claude/skills/sync-upstream/` +- no hunk of `git diff origin/main...HEAD` falls inside `## Hard safety rules` or the fix policy's + "Never, on either gate" list. Any doubt about that means it does not qualify +- the Step 10 wait on it exits `0`, with `fork ownership guard` and `PR checks passed` green on its + exact head + +Then merge it as Step 11 merges the sync PR (`--merge --match-head-commit`). If one does not +qualify, leave it open and name it in the report. Do the same for every earlier run's lesson PR +still open, before opening this one, so the backlog never builds. It blocks nothing either way: the +sync is already merged and any release already dispatched. Scope is `.claude/skills/sync-upstream/**` and nothing else. A lesson about the release procedure belongs to the `release` skill: report it, do not edit it from here. @@ -1088,7 +1197,8 @@ belongs to the `release` skill: report it, do not edit it from here. - Release: `nothing to release` | `skipped ()` | `published ` with the release URL | `needs attention` with the release skill's classified cause, plus every run, re-run, recut, and fix PR the skill reported -- Learned: `nothing` | the lesson in one line and the URL of the skill PR left open for review +- Learned: `nothing` | the lesson in one line and the URL of the skill PR, merged or left open with + the Step 15 condition it failed; plus every earlier lesson PR this run merged - All "needs attention" items ## Hard safety rules @@ -1109,9 +1219,9 @@ belongs to the `release` skill: report it, do not edit it from here. - A fork commit unreachable after the merge is a hard failure, never a warning to push through. - Tests are PR CI's job, not the local gate's. Never re-add `pnpm test` to Step 8 to "check first", and never treat its absence as licence to open a PR from a tree Step 8 rejected. -- A failing check is never tolerated. Re-running one once to rule out flake is not tolerating it — a - reproduced failure is either fixed under the fix policy or escalated with the PR left open, even - when it provably pre-dates the sync. +- A failing gating check (Step 10 defines which are gating) is never tolerated. Re-running one once + to rule out flake is not tolerating it — a reproduced failure is either fixed under the fix policy + or escalated with the PR left open, even when it provably pre-dates the sync. - Never buy a green check by weakening what it checks: no skipped or narrowed tests, no `max-lines` disable or baseline bump, no relaxed lint rule, no loosened ownership guard. The fix policy is the whole of what a run may do to a red gate. @@ -1127,6 +1237,6 @@ belongs to the `release` skill: report it, do not edit it from here. its own limits are part of that invocation. If it reports "nothing to release", that is the end of it. - This skill is revised by its own runs, but only through Step 15: a separate branch, a PR the run - never merges, and no edit to `## Hard safety rules` or the fix policy's never-list. Never edit the - skill on `$SYNC_BRANCH`, and never edit it as a way of getting a check green — a rule rewritten to - match what a run did is not a lesson, it is a cover-up. + merges only when Step 15's conditions all hold, and no edit to `## Hard safety rules` or the fix + policy's never-list. Never edit the skill on `$SYNC_BRANCH`, and never edit it as a way of getting + a check green — a rule rewritten to match what a run did is not a lesson, it is a cover-up. diff --git a/.claude/skills/sync-upstream/references/file-ownership.md b/.claude/skills/sync-upstream/references/file-ownership.md index 56a09e1b5377..3f5f175943da 100644 --- a/.claude/skills/sync-upstream/references/file-ownership.md +++ b/.claude/skills/sync-upstream/references/file-ownership.md @@ -380,9 +380,9 @@ absorbed a line the fork was carrying, and the seam should be re-read before the Re-baseline by rerunning the recorder and committing the new numbers with the resolution, never as a sweep to make the check quiet. -Typecheck and `pnpm lint` are absolute: a failure is a failure. Run the typecheck as the four -per-project commands in `SKILL.md` § Step 8, never as `pnpm typecheck` — `AGENTS.md` forbids the -parallel form on this machine. Neither is run against a +Typecheck, `pnpm lint` and the CI-parity checks are absolute: a failure is a failure. Run the +typecheck as the per-project commands in `SKILL.md` § Step 8, never as `pnpm typecheck` — `AGENTS.md` +forbids the parallel form on this machine. Neither is run against a baseline for comparison, and there is nothing to compare against — the tree either compiles and lints or it does not.