Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
256 changes: 207 additions & 49 deletions .claude/skills/sync-upstream/SKILL.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions .claude/skills/sync-upstream/references/file-ownership.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
29 changes: 29 additions & 0 deletions .claude/skills/sync-upstream/references/sync-lessons.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <tag>` 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" --
<test> <inputs>`) 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 "<sha>^" -- <paths>`), 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.
1 change: 0 additions & 1 deletion .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
9 changes: 7 additions & 2 deletions config/fork-ownership.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -6570,7 +6575,7 @@
"removed": 0
},
".gitignore": {
"added": 14,
"added": 15,
"removed": 2
},
".oxlintrc.json": {
Expand Down
147 changes: 147 additions & 0 deletions config/scripts/fork-sync-gate/check-pr-verify-selections.mjs
Original file line number Diff line number Diff line change
@@ -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.<k>` is a declared `code_paths`
* output, and one backed by `steps.filter` is a key `pr-code-change-scope.mjs` actually emits;
* - every `needs.<job>.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 = /(?<![\w$./-])((?:config|\.github)\/[\w./-]+\.(?:mjs|cjs|js|ts|sh|json|ya?ml))\b/g

function matches(pattern, text) {
return [...String(text ?? '').matchAll(pattern)]
}

function verifyEnv(verifyJob) {
const env = {}
for (const step of verifyJob.steps ?? []) {
Object.assign(env, step.env ?? {})
}
return env
}

/**
* Findings for a parsed pr.yml. `emittedSelections` is the set of keys the scope script prints;
* `pathExists` answers whether a repository-relative path is present.
*/
export function findVerifySelectionProblems(workflow, { emittedSelections, pathExists }) {
const problems = []
const jobs = workflow?.jobs ?? {}
const verify = jobs.verify
if (!verify) {
return ['pr.yml has no verify job']
}
const verifyNeeds = new Set([verify.needs ?? []].flat())
const codePathsOutputs = jobs.code_paths?.outputs ?? {}
const env = verifyEnv(verify)
const script = (verify.steps ?? []).map((step) => 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()
}
101 changes: 101 additions & 0 deletions config/scripts/fork-sync-gate/check-pr-verify-selections.test.mjs
Original file line number Diff line number Diff line change
@@ -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([])
})
})
Loading
Loading