@@ -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