Skip to content

Commit 0313d87

Browse files
committed
ci(repo): compare api gate hashes to base
1 parent dcdbd30 commit 0313d87

1 file changed

Lines changed: 109 additions & 59 deletions

File tree

.github/workflows/api-changes.yml

Lines changed: 109 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -150,44 +150,117 @@ jobs:
150150
uses: ./.github/actions/init-blacksmith
151151
with:
152152
cache-enabled: true
153-
turbo-summarize: 'true'
154153
turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
155154
turbo-team: ${{ vars.TURBO_TEAM }}
156155
turbo-token: ${{ secrets.TURBO_TOKEN }}
157156

158-
- name: Build current declarations
159-
run: pnpm turbo build:declarations $TURBO_ARGS $BREAK_CHECK_FILTERS
157+
- name: Fetch base commit
158+
run: git fetch origin "${{ github.event.pull_request.base.sha }}" --depth=1
159+
160+
- name: Create baseline worktree
161+
run: |
162+
mkdir -p .worktrees
163+
git worktree add --detach .worktrees/break-check-baseline "${{ github.event.pull_request.base.sha }}"
164+
# Snapshot the base ref with the coverage it actually had. Only seed the
165+
# config when the base tracks no coverage at all; otherwise packages
166+
# newly added to coverage in this PR get diffed against a baseline that
167+
# never tracked them (every export reads as a phantom change against the
168+
# base's bundled .d.ts), and the base ref may not even build their
169+
# declarations yet. A base from before this rename still names its config
170+
# snapi.config.json, so check both names. This reads the base's real
171+
# coverage; it is not rename-compat, and goes no-op once main carries
172+
# break-check.config.json.
173+
if [ ! -f .worktrees/break-check-baseline/break-check.config.json ] && [ ! -f .worktrees/break-check-baseline/snapi.config.json ]; then
174+
cp break-check.config.json .worktrees/break-check-baseline/break-check.config.json
175+
fi
160176
161-
# Gate the expensive snapshot/detect work on turbo's content hashing. We key on
162-
# the per-package `#build` task (not `#build:declarations`): `build` is the task
163-
# every tracked package has, and `build:declarations` dependsOn build with only a
164-
# global tsconfig of its own, so a `#build` cache HIT means the package source and
165-
# its whole ^build closure are unchanged and its emitted `.d.ts` is identical,
166-
# whether declarations come from a separate tsc pass or straight out of the bundler
167-
# (e.g. @clerk/shared via tsdown, which has no build:declarations task at all).
168-
# Keying on build:declarations would go blind to such packages and could
169-
# false-skip a real change in a leaf one as the tsc->bundler migration proceeds.
170-
# Only a MISS pays for the baseline rebuild + break-check detect below; any
171-
# uncertainty (missing summary, parse error, schema drift, cold cache) yields
172-
# changed=true and runs detect.
177+
# Gate the expensive snapshot/detect work on turbo's content hashing, but compare
178+
# the PR head against the pinned base SHA. A cache HIT only means an output already
179+
# exists for the task hash; it does not prove the PR matches its base.
173180
- name: Determine API surface changed
174181
id: gate
182+
env:
183+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
184+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
175185
run: |
176-
# shellcheck disable=SC2016
177-
node -e '
178-
const fs=require("fs"),p=require("path"),dir=".turbo/runs";
179-
let changed=true; // default: when unsure, run detect
186+
node <<'EOF'
187+
const cp = require('child_process');
188+
const fs = require('fs');
189+
const path = require('path');
190+
191+
const workspace = process.env.GITHUB_WORKSPACE;
192+
const baseWorktree = path.join(workspace, '.worktrees/break-check-baseline');
193+
const filters = process.env.BREAK_CHECK_FILTERS.trim().split(/\s+/);
194+
const turbo = path.join(workspace, 'node_modules/.bin/turbo');
195+
let changed = true; // default: when unsure, run detect
196+
197+
const parseTurboJson = output => {
198+
const start = output.indexOf('{');
199+
if (start === -1) {
200+
throw new Error('turbo dry run did not produce JSON');
201+
}
202+
return JSON.parse(output.slice(start));
203+
};
204+
205+
const runTurboDry = cwd => {
206+
const output = cp.execFileSync(turbo, ['build:declarations', '--dry=json', ...filters], {
207+
cwd,
208+
encoding: 'utf8',
209+
maxBuffer: 100 * 1024 * 1024,
210+
});
211+
return parseTurboJson(output);
212+
};
213+
214+
const apiTaskHashes = summary => {
215+
const entries = (summary.tasks || [])
216+
.filter(t => {
217+
const taskId = t.taskId || '';
218+
return taskId.endsWith('#build') || taskId.endsWith('#build:declarations');
219+
})
220+
.map(t => [t.taskId, t.hash]);
221+
222+
if (entries.length === 0) {
223+
throw new Error('turbo dry run contained no API task hashes');
224+
}
225+
226+
return new Map(entries);
227+
};
228+
180229
try {
181-
const f=fs.readdirSync(dir).filter(n=>n.endsWith(".json")).map(n=>p.join(dir,n))
182-
.map(n=>[n,fs.statSync(n).mtimeMs]).sort((a,b)=>b[1]-a[1])[0][0];
183-
const s=JSON.parse(fs.readFileSync(f,"utf8"));
184-
const builds=(s.tasks||[]).filter(t=>(t.taskId||"").endsWith("#build"));
185-
// Only trust a "false" when we actually saw the build tasks and every one HIT.
186-
changed = builds.length===0 || builds.some(t=>t.cache?.status!=="HIT");
187-
} catch (e) { console.log("gate: falling back to changed=true:", e.message); }
230+
const changedFiles = cp
231+
.execFileSync('git', ['diff', '--name-only', process.env.BASE_SHA, process.env.HEAD_SHA], {
232+
cwd: workspace,
233+
encoding: 'utf8',
234+
})
235+
.trim()
236+
.split(/\n/)
237+
.filter(Boolean);
238+
239+
const forcedFiles = changedFiles.filter(
240+
f => f === 'break-check.config.json' || f === '.github/workflows/api-changes.yml',
241+
);
242+
243+
if (forcedFiles.length > 0) {
244+
console.log('gate: workflow/config changed; running detect:', forcedFiles.join(', '));
245+
} else {
246+
const head = apiTaskHashes(runTurboDry(workspace));
247+
const base = apiTaskHashes(runTurboDry(baseWorktree));
248+
const allTaskIds = new Set([...head.keys(), ...base.keys()]);
249+
250+
changed = [...allTaskIds].some(taskId => head.get(taskId) !== base.get(taskId));
251+
}
252+
} catch (e) {
253+
console.log('gate: falling back to changed=true:', e.message);
254+
changed = true;
255+
}
256+
188257
fs.appendFileSync(process.env.GITHUB_OUTPUT,`changed=${changed}\n`);
189-
console.log("tracked build cache miss / unknown:", changed);
190-
'
258+
console.log('tracked API task hash changed / unknown:', changed);
259+
EOF
260+
261+
- name: Build current declarations
262+
if: steps.gate.outputs.changed == 'true'
263+
run: pnpm turbo build:declarations $TURBO_ARGS $BREAK_CHECK_FILTERS
191264

192265
- name: Resolve break-check cache key
193266
id: break-check-key
@@ -205,28 +278,6 @@ jobs:
205278
# same version the PR runs (see publish-baseline for the rationale).
206279
key: break-check-baseline-${{ steps.break-check-key.outputs.ref }}-${{ github.event.pull_request.base.sha }}
207280

208-
- name: Fetch base commit
209-
if: steps.gate.outputs.changed == 'true' && steps.baseline-cache.outputs.cache-matched-key == ''
210-
run: git fetch origin "${{ github.event.pull_request.base.sha }}" --depth=1
211-
212-
- name: Create baseline worktree
213-
if: steps.gate.outputs.changed == 'true' && steps.baseline-cache.outputs.cache-matched-key == ''
214-
run: |
215-
mkdir -p .worktrees
216-
git worktree add --detach .worktrees/break-check-baseline "${{ github.event.pull_request.base.sha }}"
217-
# Snapshot the base ref with the coverage it actually had. Only seed the
218-
# config when the base tracks no coverage at all; otherwise packages
219-
# newly added to coverage in this PR get diffed against a baseline that
220-
# never tracked them (every export reads as a phantom change against the
221-
# base's bundled .d.ts), and the base ref may not even build their
222-
# declarations yet. A base from before this rename still names its config
223-
# snapi.config.json, so check both names. This reads the base's real
224-
# coverage; it is not rename-compat, and goes no-op once main carries
225-
# break-check.config.json.
226-
if [ ! -f .worktrees/break-check-baseline/break-check.config.json ] && [ ! -f .worktrees/break-check-baseline/snapi.config.json ]; then
227-
cp break-check.config.json .worktrees/break-check-baseline/break-check.config.json
228-
fi
229-
230281
- name: Install baseline dependencies
231282
if: steps.gate.outputs.changed == 'true' && steps.baseline-cache.outputs.cache-matched-key == ''
232283
working-directory: .worktrees/break-check-baseline
@@ -261,11 +312,9 @@ jobs:
261312
--ai-apply-downgrades \
262313
--fail-on-breaking
263314
264-
# Note: on the cache-hit skip path we intentionally post nothing. A turbo HIT is
265-
# an inference that declarations did not change, not a break-check analysis, so a
266-
# "no breaking changes" clearance would overclaim (e.g. a revert to an older
267-
# still-cached state HITs the cache yet differs from base). The "no API changes"
268-
# comment below is only ever posted when detect actually ran and found nothing.
315+
# Note: on the hash-equal skip path we intentionally post nothing. The "no API
316+
# changes" comment below is only ever posted when detect actually ran and found
317+
# nothing.
269318

270319
- name: Upload API changes report
271320
uses: actions/upload-artifact@v4
@@ -316,12 +365,13 @@ jobs:
316365
}
317366
}
318367
319-
// Stamp the head SHA detect actually ran on. Because cache-hit pushes are
320-
// skipped silently (no comment update), this lets a reviewer see whether this
321-
// comment reflects the current head or an earlier push.
368+
// Stamp the head SHA detect actually ran on. Because pushes whose tracked
369+
// declarations match the base are skipped silently (no comment update), this
370+
// lets a reviewer see whether this comment reflects the current head or an
371+
// earlier push.
322372
const ranSha = (process.env.HEAD_SHA || '').slice(0, 7);
323373
if (ranSha) {
324-
body += `\n\n<sub>Last ran on \`${ranSha}\`. Pushes that change no tracked declarations (turbo cache hit) are skipped and don't update this comment.</sub>`;
374+
body += `\n\n<sub>Last ran on \`${ranSha}\`. Pushes that change no tracked declarations (no API surface change vs. base) are skipped and don't update this comment.</sub>`;
325375
}
326376
327377
const comments = await github.paginate(github.rest.issues.listComments, {

0 commit comments

Comments
 (0)