Skip to content

Commit 4be4772

Browse files
committed
fix(ci): resolve manual audit base from the open pull request
1 parent 65f35e1 commit 4be4772

4 files changed

Lines changed: 199 additions & 11 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
if [ "$GITHUB_EVENT_NAME" = pull_request ]; then
5+
git fetch --depth=1 origin "$GITHUB_BASE_REF"
6+
echo "ref=origin/$GITHUB_BASE_REF" >> "$GITHUB_OUTPUT"
7+
elif [ "$GITHUB_EVENT_NAME" = workflow_dispatch ]; then
8+
if [ "$GITHUB_REF_TYPE" != branch ]; then
9+
echo 'Manual diff audits require a branch with exactly one open pull request.' >&2
10+
exit 1
11+
fi
12+
13+
pr_pages=$(gh api --method GET "repos/$GITHUB_REPOSITORY/pulls" \
14+
-f state=open -f "head=${GITHUB_REPOSITORY%%/*}:$GITHUB_REF_NAME" --paginate --slurp)
15+
matching_prs=$(jq -ce --arg repository "$GITHUB_REPOSITORY" --arg branch "$GITHUB_REF_NAME" \
16+
'[.[][] | select(.state == "open" and .head.ref == $branch and .head.repo.full_name == $repository)]' \
17+
<<< "$pr_pages")
18+
if [ "$(jq 'length' <<< "$matching_prs")" != 1 ]; then
19+
echo 'Manual diff audits require exactly one open pull request for the dispatched branch.' >&2
20+
exit 1
21+
fi
22+
if [ "$(jq -r '.[0].head.sha' <<< "$matching_prs")" != "$GITHUB_SHA" ]; then
23+
echo 'The pull request head changed after dispatch; dispatch again for its current head.' >&2
24+
exit 1
25+
fi
26+
27+
base_sha=$(jq -r '.[0].base.sha' <<< "$matching_prs")
28+
if [[ ! "$base_sha" =~ ^[0-9a-f]{40}$ ]]; then
29+
echo 'The pull request did not provide a valid base commit SHA.' >&2
30+
exit 1
31+
fi
32+
git fetch --depth=1 origin "$base_sha"
33+
echo "ref=$base_sha" >> "$GITHUB_OUTPUT"
34+
elif [ -n "${GITHUB_BEFORE:-}" ] &&
35+
[ "$GITHUB_BEFORE" != 0000000000000000000000000000000000000000 ]; then
36+
git fetch --depth=1 origin "$GITHUB_BEFORE"
37+
echo "ref=$GITHUB_BEFORE" >> "$GITHUB_OUTPUT"
38+
else
39+
echo 'ref=HEAD~1' >> "$GITHUB_OUTPUT"
40+
fi

‎.github/workflows/checks.yml‎

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,9 @@ jobs:
435435
# kept off the test shards so neither waits on the other.
436436
lint:
437437
name: lint
438+
permissions:
439+
contents: read
440+
pull-requests: read
438441
runs-on: *runner-4vcpu
439442
timeout-minutes: 15
440443

@@ -524,19 +527,14 @@ jobs:
524527
# It is fetched by SHA at depth 1; the audits diff two tips and need no
525528
# common ancestry. An all-zero `before` means the branch is new and has no
526529
# predecessor to diff, so `HEAD~1` remains the fallback there.
530+
# Manual runs pin the actual base SHA of the dispatched branch's unique open PR.
531+
# A merge commit's first parent does not identify a stacked PR's review base.
527532
- name: Resolve base ref for diff-based audits
528533
id: audit_base
529-
run: |
530-
if [ "${{ github.event_name }}" = "pull_request" ]; then
531-
git fetch --depth=1 origin "${{ github.base_ref }}"
532-
echo "ref=origin/${{ github.base_ref }}" >> "$GITHUB_OUTPUT"
533-
elif [ -n "${{ github.event.before }}" ] &&
534-
[ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
535-
git fetch --depth=1 origin "${{ github.event.before }}"
536-
echo "ref=${{ github.event.before }}" >> "$GITHUB_OUTPUT"
537-
else
538-
echo "ref=HEAD~1" >> "$GITHUB_OUTPUT"
539-
fi
534+
env:
535+
GH_TOKEN: ${{ github.token }}
536+
GITHUB_BEFORE: ${{ github.event.before }}
537+
run: bash .github/scripts/resolve-audit-base.sh
540538

541539
- name: Check block registry invariants
542540
run: bun run apps/sim/scripts/check-block-registry.ts "${{ steps.audit_base.outputs.ref }}"

‎.github/workflows/ci.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ permissions:
3434
jobs:
3535
checks:
3636
name: checks
37+
permissions:
38+
contents: read
39+
pull-requests: read
3740
if: github.ref != 'refs/heads/dev' || github.event_name == 'pull_request'
3841
uses: ./.github/workflows/checks.yml
3942

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { spawnSync } from 'node:child_process'
2+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
import { fileURLToPath } from 'node:url'
6+
import { afterEach, describe, expect, it } from 'vitest'
7+
8+
const script = fileURLToPath(new URL('../.github/scripts/resolve-audit-base.sh', import.meta.url))
9+
const fixtures: string[] = []
10+
11+
function git(cwd: string, ...args: string[]): string {
12+
const result = spawnSync('git', args, { cwd, encoding: 'utf8' })
13+
if (result.status !== 0) throw new Error(`git ${args.join(' ')}: ${result.stderr}`)
14+
return result.stdout.trim()
15+
}
16+
17+
function fixture() {
18+
const root = mkdtempSync(join(tmpdir(), 'audit-base-'))
19+
fixtures.push(root)
20+
const origin = join(root, 'origin.git')
21+
const repo = join(root, 'repo')
22+
git(root, 'init', '--quiet', '--bare', origin)
23+
git(root, 'init', '--quiet', '-b', 'foundation', repo)
24+
git(repo, 'config', 'user.email', 'test@example.com')
25+
git(repo, 'config', 'user.name', 'Test')
26+
git(repo, 'config', 'commit.gpgsign', 'false')
27+
git(repo, 'remote', 'add', 'origin', origin)
28+
git(repo, 'commit', '--quiet', '--allow-empty', '-m', 'initial')
29+
git(repo, 'branch', 'parent')
30+
writeFileSync(join(repo, 'foundation.sql'), 'SELECT 1;\n')
31+
git(repo, 'add', '.')
32+
git(repo, 'commit', '--quiet', '-m', 'foundation')
33+
const before = git(repo, 'rev-parse', 'HEAD')
34+
git(repo, 'checkout', '--quiet', 'parent')
35+
writeFileSync(join(repo, 'upstream.sql'), 'SELECT 2;\n')
36+
git(repo, 'add', '.')
37+
git(repo, 'commit', '--quiet', '-m', 'upstream')
38+
const base = git(repo, 'rev-parse', 'HEAD')
39+
git(repo, 'checkout', '--quiet', 'foundation')
40+
git(repo, 'merge', '--quiet', '--no-ff', 'parent', '-m', 'merge parent')
41+
const head = git(repo, 'rev-parse', 'HEAD')
42+
git(repo, 'push', '--quiet', 'origin', 'parent', 'foundation')
43+
const pr = {
44+
state: 'open',
45+
head: { ref: 'foundation', sha: head, repo: { full_name: 'example/repo' } },
46+
base: { sha: base },
47+
}
48+
return { root, repo, base, before, head, pr }
49+
}
50+
51+
function resolveBase(data: ReturnType<typeof fixture>, overrides: Record<string, string> = {}) {
52+
const output = join(data.root, 'output')
53+
writeFileSync(output, '')
54+
const result = spawnSync(
55+
'bash',
56+
[
57+
'--noprofile',
58+
'--norc',
59+
'-c',
60+
'gh() { printf "%s" "$PR_RESPONSE"; return "$API_EXIT"; }; source "$AUDIT_SCRIPT"',
61+
],
62+
{
63+
cwd: data.repo,
64+
encoding: 'utf8',
65+
env: {
66+
...process.env,
67+
AUDIT_SCRIPT: script,
68+
GITHUB_EVENT_NAME: 'workflow_dispatch',
69+
GITHUB_REPOSITORY: 'example/repo',
70+
GITHUB_REF_TYPE: 'branch',
71+
GITHUB_REF_NAME: 'foundation',
72+
GITHUB_SHA: data.head,
73+
GITHUB_BASE_REF: 'parent',
74+
GITHUB_BEFORE: '',
75+
GITHUB_OUTPUT: output,
76+
PR_RESPONSE: JSON.stringify([[data.pr]]),
77+
API_EXIT: '0',
78+
...overrides,
79+
},
80+
}
81+
)
82+
return { ...result, output: readFileSync(output, 'utf8').trim() }
83+
}
84+
85+
afterEach(() => {
86+
for (const root of fixtures.splice(0)) rmSync(root, { recursive: true, force: true })
87+
})
88+
89+
describe('diff audit base selection', () => {
90+
it('pins the actual PR base for a dispatched merge head instead of its first parent', () => {
91+
const data = fixture()
92+
const result = resolveBase(data)
93+
expect(result.status, result.stderr).toBe(0)
94+
expect(result.output).toBe(`ref=${data.base}`)
95+
expect(git(data.repo, 'diff', '--name-only', data.base, 'HEAD')).toBe('foundation.sql')
96+
expect(git(data.repo, 'diff', '--name-only', data.before, 'HEAD')).toBe('upstream.sql')
97+
})
98+
99+
it('fails closed when multiple open PRs across pages claim the dispatched branch', () => {
100+
const data = fixture()
101+
const result = resolveBase(data, { PR_RESPONSE: JSON.stringify([[data.pr], [data.pr]]) })
102+
expect(result.status).not.toBe(0)
103+
expect(result.output).toBe('')
104+
})
105+
106+
it.each(['missing', 'moved', 'foreign', 'tag', 'api', 'invalid-base', 'fetch'] as const)(
107+
'does not publish an audit base when resolution is %s',
108+
(failure) => {
109+
const data = fixture()
110+
const overrides: Record<string, string> = {}
111+
if (failure === 'missing') overrides.PR_RESPONSE = '[[]]'
112+
if (failure === 'moved') overrides.GITHUB_SHA = data.before
113+
if (failure === 'foreign') overrides.GITHUB_REPOSITORY = 'another/repo'
114+
if (failure === 'tag') overrides.GITHUB_REF_TYPE = 'tag'
115+
if (failure === 'api') overrides.API_EXIT = '73'
116+
if (failure === 'invalid-base')
117+
overrides.PR_RESPONSE = JSON.stringify([[{ ...data.pr, base: { sha: 'HEAD~1' } }]])
118+
if (failure === 'fetch')
119+
git(data.repo, 'remote', 'set-url', 'origin', join(data.root, 'missing.git'))
120+
const result = resolveBase(data, overrides)
121+
expect(result.status).not.toBe(0)
122+
expect(result.output).toBe('')
123+
}
124+
)
125+
126+
it('keeps the normal PR base branch even if no API association is available', () => {
127+
const data = fixture()
128+
const result = resolveBase(data, { GITHUB_EVENT_NAME: 'pull_request', API_EXIT: '73' })
129+
expect(result.status, result.stderr).toBe(0)
130+
expect(result.output).toBe('ref=origin/parent')
131+
expect(git(data.repo, 'rev-parse', 'origin/parent')).toBe(data.base)
132+
})
133+
134+
it('keeps the pre-push SHA for an existing branch', () => {
135+
const data = fixture()
136+
const result = resolveBase(data, { GITHUB_EVENT_NAME: 'push', GITHUB_BEFORE: data.before })
137+
expect(result.status, result.stderr).toBe(0)
138+
expect(result.output).toBe(`ref=${data.before}`)
139+
})
140+
141+
it('keeps the first-parent fallback for a new branch push', () => {
142+
const data = fixture()
143+
const result = resolveBase(data, { GITHUB_EVENT_NAME: 'push', GITHUB_BEFORE: '0'.repeat(40) })
144+
expect(result.status, result.stderr).toBe(0)
145+
expect(result.output).toBe('ref=HEAD~1')
146+
})
147+
})

0 commit comments

Comments
 (0)