Skip to content

Commit 9e1f122

Browse files
authored
fix(ci): attribute pull requests against the tested target parent (#9644)
1 parent 18d12ad commit 9e1f122

3 files changed

Lines changed: 123 additions & 9 deletions

File tree

‎.github/CI.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
The `changes` job runs Turborepo 2.9.14's `affectedTasks` query before installing application dependencies or provisioning services. `turbo.json` records package dependency propagation; `apps/sim/turbo.json` partitions the app into shared runtime, server, workspace UI, and acceptance tasks. `.github/ci-suites.json` turns those tasks into job matrices. The uploaded `ci-plan` artifact names changed files, selected tasks, attribution reasons, and any fallback.
44

5-
A PR compares its merge base with the checked-out merge commit. Main/staging pushes execute all checks; the parent `ci` workflow skips checks on `dev` pushes, while PRs targeting `dev` still run checks. A manual `checks` run executes all checks unless supplied a full base commit SHA to reproduce a scoped plan. Missing history, an empty diff, unknown files outside the workspace graph, root script/CI changes, or a planner error run every check. Missing selection outputs fail the aggregate gate. The gate also rejects an omitted dependency, any failed/cancelled job, and any skip that its explicit selection flag did not authorize.
5+
A PR compares the checked-out GitHub merge commit with its first parent: the actual target revision tested by that merge. The PR event's base SHA can become stale when the target advances. Scoping requires a matching GitHub merge ref and SHA, exactly two merge parents, and a usable target parent; otherwise every check runs. This works with the existing depth-two checkout without fetching older ancestry. Main/staging pushes execute all checks; the parent `ci` workflow skips checks on `dev` pushes, while PRs targeting `dev` still run checks. A manual `checks` run executes all checks unless supplied a full base commit SHA to reproduce a scoped plan. Missing history, an empty diff, unknown files outside the workspace graph, root script/CI changes, or a planner error run every check. Missing selection outputs fail the aggregate gate. The gate also rejects an omitted dependency, any failed/cancelled job, and any skip that its explicit selection flag did not authorize.
66

7-
Dedicated docs verification uses the same planner, comparing pushes with their previous commit and PRs with their merge base. Site acceptance follows `docs#build` and its transitive package dependencies. The app's docs-consumer task depends on server runtime and that docs build: backend-only changes run the platform consumer against the docs app, while site changes also run corpus and browser checks. Independent SDK and unit-only edits skip those production jobs. The required `docs HTTP` gate verifies explicit site/platform selections before accepting any skip.
7+
Dedicated docs verification uses the same planner, comparing pushes with their previous commit and PRs with the tested merge's first parent. Site acceptance follows `docs#build` and its transitive package dependencies. The app's docs-consumer task depends on server runtime and that docs build: backend-only changes run the platform consumer against the docs app, while site changes also run corpus and browser checks. Independent SDK and unit-only edits skip those production jobs. The required `docs HTTP` gate verifies explicit site/platform selections before accepting any skip.
88

99
Docs production builds run once and publish an artifact to isolated HTTP, corpus, Chromium, and WebKit jobs. Source checks keep their own checkout because generator verification mutates inputs. Content caches use separate lint/source/build namespaces; generation validates every restore, repairs missing declarations, and removes obsolete modules. The Next build cache keeps its supported incremental behavior. Content and Next caches have size limits, and Next cache files expire after seven days; successful content eviction happens after all consumers finish.
1010

‎scripts/ci-changes.test.ts‎

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import { spawnSync } from 'node:child_process'
22
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
33
import { tmpdir } from 'node:os'
44
import { dirname, join, resolve } from 'node:path'
5-
import { fileURLToPath } from 'node:url'
5+
import { fileURLToPath, pathToFileURL } from 'node:url'
66
import { afterEach, describe, expect, it } from 'vitest'
77

88
const root = fileURLToPath(new URL('../', import.meta.url))
99
const script = resolve(root, '.github/scripts/ci-changes.sh')
1010
const fixtures: string[] = []
1111
interface Plan {
12+
base: string
13+
files: string[]
1214
flags: Record<string, boolean>
1315
fallback?: string
1416
http: { group: string }[]
@@ -102,13 +104,17 @@ function fixture() {
102104
git(repo, 'commit', '--quiet', '-m', 'base')
103105
return { repo, base: git(repo, 'rev-parse', 'HEAD') }
104106
}
105-
function classify(repo: string, base: string): Plan {
107+
function classify(repo: string, base: string, environment: Record<string, string> = {}): Plan {
106108
const report = join(repo, 'plan.json')
107109
const result = spawnSync('bash', [script, base], {
108110
cwd: repo,
109111
encoding: 'utf8',
110112
env: {
111113
...process.env,
114+
GITHUB_EVENT_NAME: 'workflow_dispatch',
115+
GITHUB_REF: 'refs/heads/fixture',
116+
GITHUB_SHA: '',
117+
...environment,
112118
PATH: `${resolve(root, 'node_modules/.bin')}:${process.env.PATH}`,
113119
CI_PLAN_PATH: report,
114120
},
@@ -119,6 +125,39 @@ function classify(repo: string, base: string): Plan {
119125
expect(result.stdout).toContain(`${flag}=${value}\n`)
120126
return plan
121127
}
128+
function pullRequestFixture(files: string[], depth = 2) {
129+
const { repo: source, base } = fixture()
130+
git(source, 'checkout', '--quiet', '-b', 'contribution')
131+
for (const file of files) {
132+
write(source, file, `${file}\n`)
133+
git(source, 'add', '-A')
134+
git(source, 'commit', '--quiet', '-m', `contribute ${file}`)
135+
}
136+
git(source, 'checkout', '--quiet', '-b', 'target', base)
137+
write(source, 'apps/sim/lib/upstream.ts', 'export const upstream = true\n')
138+
git(source, 'add', '-A')
139+
git(source, 'commit', '--quiet', '-m', 'advance target')
140+
const target = git(source, 'rev-parse', 'HEAD')
141+
git(source, 'merge', '--quiet', '--no-ff', '-m', 'synthetic PR merge', 'contribution')
142+
const head = git(source, 'rev-parse', 'HEAD')
143+
const repo = mkdtempSync(join(tmpdir(), 'ci-pr-merge-'))
144+
fixtures.push(repo)
145+
git(repo, 'init', '--quiet')
146+
git(repo, 'remote', 'add', 'origin', pathToFileURL(source).href)
147+
git(repo, 'fetch', '--quiet', `--depth=${depth}`, 'origin', head)
148+
git(repo, 'checkout', '--quiet', '--detach', 'FETCH_HEAD')
149+
expect(git(repo, 'rev-parse', '--is-shallow-repository')).toBe('true')
150+
return {
151+
repo,
152+
base,
153+
target,
154+
environment: {
155+
GITHUB_EVENT_NAME: 'pull_request',
156+
GITHUB_REF: 'refs/pull/42/merge',
157+
GITHUB_SHA: head,
158+
},
159+
}
160+
}
122161
function classifyFiles(files: string[]): Plan {
123162
const { repo, base } = fixture()
124163
for (const file of files) write(repo, file, `${file}\n`)
@@ -133,6 +172,71 @@ afterEach(() => {
133172
for (const repo of fixtures.splice(0)) rmSync(repo, { recursive: true, force: true })
134173
})
135174
describe('CI task attribution through real Turborepo', () => {
175+
it('scopes a shallow multi-commit PR against its tested target after the event base goes stale', () => {
176+
const files = ['packages/ts-sdk/src/index.ts', 'packages/ts-sdk/src/second.ts']
177+
const { repo, base, target, environment } = pullRequestFixture(files)
178+
const plan = classify(repo, base, environment)
179+
expect(plan.fallback).toBeUndefined()
180+
expect(plan.base).toBe(target)
181+
expect(plan.files).toEqual(files)
182+
expect(groups(plan)).toEqual([])
183+
expect(plan.testPackages).toEqual(['simstudio-ts-sdk'])
184+
expect(plan.typecheckPackages).toEqual(['simstudio-ts-sdk'])
185+
expect(plan.flags).toMatchObject({
186+
test: true,
187+
test_app: false,
188+
integration: false,
189+
build: false,
190+
desktop_live: false,
191+
e2e_build: false,
192+
docs: false,
193+
})
194+
})
195+
it('keeps an earlier server change when the final PR commit only changes the SDK', () => {
196+
const files = ['apps/sim/lib/server.ts', 'packages/ts-sdk/src/second.ts']
197+
const { repo, base, target, environment } = pullRequestFixture(files)
198+
const plan = classify(repo, base, environment)
199+
expect(plan.fallback).toBeUndefined()
200+
expect(plan.base).toBe(target)
201+
expect(plan.files).toEqual(files)
202+
expect(plan.testPackages).toContain('simstudio-ts-sdk')
203+
expect(plan.flags).toMatchObject({
204+
test_app: true,
205+
integration: true,
206+
build: true,
207+
desktop_live: true,
208+
e2e_build: true,
209+
})
210+
})
211+
it.each<[string, Record<string, string>]>([
212+
['a branch ref', { GITHUB_REF: 'refs/heads/staging' }],
213+
['a missing ref', { GITHUB_REF: '' }],
214+
['a different triggering SHA', { GITHUB_SHA: 'f'.repeat(40) }],
215+
['a missing triggering SHA', { GITHUB_SHA: '' }],
216+
])('runs everything for a PR checkout with %s', (_label, invalid) => {
217+
const { repo, target, environment } = pullRequestFixture(['packages/ts-sdk/src/index.ts'])
218+
const plan = classify(repo, target, { ...environment, ...invalid })
219+
expect(plan.fallback).toBeDefined()
220+
expect(Object.values(plan.flags).every(Boolean)).toBe(true)
221+
})
222+
it('runs everything if a PR event checks out a non-merge commit', () => {
223+
const { repo, base } = fixture()
224+
write(repo, 'packages/ts-sdk/src/index.ts', 'export const value = 2\n')
225+
git(repo, 'commit', '--quiet', '-am', 'SDK change')
226+
const plan = classify(repo, base, {
227+
GITHUB_EVENT_NAME: 'pull_request',
228+
GITHUB_REF: 'refs/pull/42/merge',
229+
GITHUB_SHA: git(repo, 'rev-parse', 'HEAD'),
230+
})
231+
expect(plan.fallback).toBeDefined()
232+
expect(Object.values(plan.flags).every(Boolean)).toBe(true)
233+
})
234+
it('runs everything if a shallow PR checkout does not contain its merge parents', () => {
235+
const { repo, base, environment } = pullRequestFixture(['packages/ts-sdk/src/index.ts'], 1)
236+
const plan = classify(repo, base, environment)
237+
expect(plan.fallback).toBeDefined()
238+
expect(Object.values(plan.flags).every(Boolean)).toBe(true)
239+
})
136240
it('runs SDK tests without provisioning the app, browsers, or database', () => {
137241
const plan = classifyFiles(['packages/ts-sdk/src/index.ts'])
138242
expect(plan.fallback).toBeUndefined()

‎scripts/ci-plan.ts‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,22 @@ try {
4444
throw new Error('No comparison base; push and unscoped dispatch run the full suite')
4545
if (!/^[0-9a-f]{40}$/.test(sourceBase))
4646
throw new Error('Comparison base must be a full commit SHA')
47-
try {
48-
command('git', ['cat-file', '-e', `${sourceBase}^{commit}`])
49-
} catch {
50-
command('git', ['fetch', '--quiet', '--depth=2', 'origin', sourceBase])
47+
if (process.env.GITHUB_EVENT_NAME === 'pull_request') {
48+
if (!/^refs\/pull\/[1-9]\d*\/merge$/.test(process.env.GITHUB_REF ?? ''))
49+
throw new Error('PR attribution requires the GitHub merge ref')
50+
if (process.env.GITHUB_SHA !== command('git', ['rev-parse', 'HEAD']))
51+
throw new Error('PR attribution requires the checked-out GitHub merge SHA')
52+
const parents = command('git', ['show', '--no-patch', '--format=%P', 'HEAD']).split(' ')
53+
if (parents.length !== 2) throw new Error('PR attribution requires a two-parent merge')
54+
base = parents[0]
55+
} else {
56+
try {
57+
command('git', ['cat-file', '-e', `${sourceBase}^{commit}`])
58+
} catch {
59+
command('git', ['fetch', '--quiet', '--depth=2', 'origin', sourceBase])
60+
}
61+
base = command('git', ['merge-base', sourceBase, 'HEAD'])
5162
}
52-
base = command('git', ['merge-base', sourceBase, 'HEAD'])
5363
files = command('git', ['diff', '--no-renames', '--name-only', '-z', base, 'HEAD'])
5464
.split('\0')
5565
.filter(Boolean)

0 commit comments

Comments
 (0)