From ab067b94b52ddaa9e890f4744cad582700cf9ae2 Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 1 Jun 2026 20:26:41 -0500 Subject: [PATCH 1/2] ci(repo): gate api-changes detect on turbo cache-hit signal Skip the break-check snapshot/detect work (and the baseline worktree rebuild) when every tracked package's #build was a turbo cache HIT, since identical build inputs mean the emitted declarations are unchanged. A MISS, or any uncertainty (missing summary, cold cache, parse error), still runs detect as before. Keys on #build rather than #build:declarations so the gate stays correct as packages migrate declaration emission into the bundler (e.g. shared via tsdown has no build:declarations task); build:declarations dependsOn build, so a #build HIT already implies unchanged declarations for every package. On a cache-hit skip, post no comment. A turbo HIT is an inference that nothing changed, not a break-check run, so a "no breaking changes" clearance would overclaim (a revert to an older still-cached state HITs the cache yet differs from base). The "no API changes" comment is only posted when detect actually ran and found nothing, and break-check comments are now stamped with the head SHA they ran on, so a comment left unrefreshed by a later skipped push is recognizable as stale. --- .changeset/gate-api-changes-turbo-cache.md | 2 + .github/workflows/api-changes.yml | 66 +++++++++++++++++++--- 2 files changed, 60 insertions(+), 8 deletions(-) create mode 100644 .changeset/gate-api-changes-turbo-cache.md diff --git a/.changeset/gate-api-changes-turbo-cache.md b/.changeset/gate-api-changes-turbo-cache.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/gate-api-changes-turbo-cache.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/workflows/api-changes.yml b/.github/workflows/api-changes.yml index 5bd3e317ca6..00712c5ad2e 100644 --- a/.github/workflows/api-changes.yml +++ b/.github/workflows/api-changes.yml @@ -150,16 +150,53 @@ jobs: uses: ./.github/actions/init-blacksmith with: cache-enabled: true + turbo-summarize: 'true' turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} turbo-team: ${{ vars.TURBO_TEAM }} turbo-token: ${{ secrets.TURBO_TOKEN }} + - name: Build current declarations + run: pnpm turbo build:declarations $TURBO_ARGS $BREAK_CHECK_FILTERS + + # Gate the expensive snapshot/detect work on turbo's content hashing. We key on + # the per-package `#build` task (not `#build:declarations`): `build` is the task + # every tracked package has, and `build:declarations` dependsOn build with only a + # global tsconfig of its own, so a `#build` cache HIT means the package source and + # its whole ^build closure are unchanged and its emitted `.d.ts` is identical, + # whether declarations come from a separate tsc pass or straight out of the bundler + # (e.g. @clerk/shared via tsdown, which has no build:declarations task at all). + # Keying on build:declarations would go blind to such packages and could + # false-skip a real change in a leaf one as the tsc->bundler migration proceeds. + # Only a MISS pays for the baseline rebuild + break-check detect below; any + # uncertainty (missing summary, parse error, schema drift, cold cache) yields + # changed=true and runs detect. + - name: Determine API surface changed + id: gate + run: | + # shellcheck disable=SC2016 + node -e ' + const fs=require("fs"),p=require("path"),dir=".turbo/runs"; + let changed=true; // default: when unsure, run detect + try { + const f=fs.readdirSync(dir).filter(n=>n.endsWith(".json")).map(n=>p.join(dir,n)) + .map(n=>[n,fs.statSync(n).mtimeMs]).sort((a,b)=>b[1]-a[1])[0][0]; + const s=JSON.parse(fs.readFileSync(f,"utf8")); + const builds=(s.tasks||[]).filter(t=>(t.taskId||"").endsWith("#build")); + // Only trust a "false" when we actually saw the build tasks and every one HIT. + changed = builds.length===0 || builds.some(t=>t.cache?.status!=="HIT"); + } catch (e) { console.log("gate: falling back to changed=true:", e.message); } + fs.appendFileSync(process.env.GITHUB_OUTPUT,`changed=${changed}\n`); + console.log("tracked build cache miss / unknown:", changed); + ' + - name: Resolve break-check cache key id: break-check-key + if: steps.gate.outputs.changed == 'true' run: echo "ref=${BREAK_CHECK_PACKAGE##*@}" >> "$GITHUB_OUTPUT" - name: Restore baseline from cache id: baseline-cache + if: steps.gate.outputs.changed == 'true' uses: actions/cache/restore@v4 with: path: .api-snapshots-baseline @@ -168,15 +205,12 @@ jobs: # same version the PR runs (see publish-baseline for the rationale). key: break-check-baseline-${{ steps.break-check-key.outputs.ref }}-${{ github.event.pull_request.base.sha }} - - name: Build current declarations - run: pnpm turbo build:declarations $TURBO_ARGS $BREAK_CHECK_FILTERS - - name: Fetch base commit - if: steps.baseline-cache.outputs.cache-matched-key == '' + if: steps.gate.outputs.changed == 'true' && steps.baseline-cache.outputs.cache-matched-key == '' run: git fetch origin "${{ github.event.pull_request.base.sha }}" --depth=1 - name: Create baseline worktree - if: steps.baseline-cache.outputs.cache-matched-key == '' + if: steps.gate.outputs.changed == 'true' && steps.baseline-cache.outputs.cache-matched-key == '' run: | mkdir -p .worktrees git worktree add --detach .worktrees/break-check-baseline "${{ github.event.pull_request.base.sha }}" @@ -194,12 +228,12 @@ jobs: fi - name: Install baseline dependencies - if: steps.baseline-cache.outputs.cache-matched-key == '' + if: steps.gate.outputs.changed == 'true' && steps.baseline-cache.outputs.cache-matched-key == '' working-directory: .worktrees/break-check-baseline run: pnpm install --frozen-lockfile - name: Build baseline declarations - if: steps.baseline-cache.outputs.cache-matched-key == '' + if: steps.gate.outputs.changed == 'true' && steps.baseline-cache.outputs.cache-matched-key == '' working-directory: .worktrees/break-check-baseline # --continue past per-package failures and don't fail the step: the base # ref may not build declarations for packages that only gained @@ -210,13 +244,14 @@ jobs: run: pnpm turbo build:declarations $TURBO_ARGS $BREAK_CHECK_FILTERS --continue || true - name: Generate baseline API snapshots - if: steps.baseline-cache.outputs.cache-matched-key == '' + if: steps.gate.outputs.changed == 'true' && steps.baseline-cache.outputs.cache-matched-key == '' working-directory: .worktrees/break-check-baseline run: | pnpm dlx --package "$BREAK_CHECK_PACKAGE" break-check snapshot \ --output "$GITHUB_WORKSPACE/.api-snapshots-baseline" - name: Detect API changes + if: steps.gate.outputs.changed == 'true' env: BREAK_CHECK_ANTHROPIC_API_KEY: ${{ secrets.BREAK_CHECK_ANTHROPIC_API_KEY }} run: | @@ -225,6 +260,12 @@ jobs: --output api-changes-report.md \ --fail-on-breaking + # Note: on the cache-hit skip path we intentionally post nothing. A turbo HIT is + # an inference that declarations did not change, not a break-check analysis, so a + # "no breaking changes" clearance would overclaim (e.g. a revert to an older + # still-cached state HITs the cache yet differs from base). The "no API changes" + # comment below is only ever posted when detect actually ran and found nothing. + - name: Upload API changes report uses: actions/upload-artifact@v4 if: always() @@ -239,6 +280,7 @@ jobs: uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 env: RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} with: script: | const fs = require('fs'); @@ -273,6 +315,14 @@ jobs: } } + // Stamp the head SHA detect actually ran on. Because cache-hit pushes are + // skipped silently (no comment update), this lets a reviewer see whether this + // comment reflects the current head or an earlier push. + const ranSha = (process.env.HEAD_SHA || '').slice(0, 7); + if (ranSha) { + body += `\n\nLast ran on \`${ranSha}\`. Pushes that change no tracked declarations (turbo cache hit) are skipped and don't update this comment.`; + } + const comments = await github.paginate(github.rest.issues.listComments, { owner: context.repo.owner, repo: context.repo.repo, From 0313d87ddcdf4c81c2224de97f3a313a1713842b Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 2 Jun 2026 15:30:36 -0500 Subject: [PATCH 2/2] ci(repo): compare api gate hashes to base --- .github/workflows/api-changes.yml | 168 +++++++++++++++++++----------- 1 file changed, 109 insertions(+), 59 deletions(-) diff --git a/.github/workflows/api-changes.yml b/.github/workflows/api-changes.yml index f5df2e9b20e..d4c778dd0f1 100644 --- a/.github/workflows/api-changes.yml +++ b/.github/workflows/api-changes.yml @@ -150,44 +150,117 @@ jobs: uses: ./.github/actions/init-blacksmith with: cache-enabled: true - turbo-summarize: 'true' turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} turbo-team: ${{ vars.TURBO_TEAM }} turbo-token: ${{ secrets.TURBO_TOKEN }} - - name: Build current declarations - run: pnpm turbo build:declarations $TURBO_ARGS $BREAK_CHECK_FILTERS + - name: Fetch base commit + run: git fetch origin "${{ github.event.pull_request.base.sha }}" --depth=1 + + - name: Create baseline worktree + run: | + mkdir -p .worktrees + git worktree add --detach .worktrees/break-check-baseline "${{ github.event.pull_request.base.sha }}" + # Snapshot the base ref with the coverage it actually had. Only seed the + # config when the base tracks no coverage at all; otherwise packages + # newly added to coverage in this PR get diffed against a baseline that + # never tracked them (every export reads as a phantom change against the + # base's bundled .d.ts), and the base ref may not even build their + # declarations yet. A base from before this rename still names its config + # snapi.config.json, so check both names. This reads the base's real + # coverage; it is not rename-compat, and goes no-op once main carries + # break-check.config.json. + if [ ! -f .worktrees/break-check-baseline/break-check.config.json ] && [ ! -f .worktrees/break-check-baseline/snapi.config.json ]; then + cp break-check.config.json .worktrees/break-check-baseline/break-check.config.json + fi - # Gate the expensive snapshot/detect work on turbo's content hashing. We key on - # the per-package `#build` task (not `#build:declarations`): `build` is the task - # every tracked package has, and `build:declarations` dependsOn build with only a - # global tsconfig of its own, so a `#build` cache HIT means the package source and - # its whole ^build closure are unchanged and its emitted `.d.ts` is identical, - # whether declarations come from a separate tsc pass or straight out of the bundler - # (e.g. @clerk/shared via tsdown, which has no build:declarations task at all). - # Keying on build:declarations would go blind to such packages and could - # false-skip a real change in a leaf one as the tsc->bundler migration proceeds. - # Only a MISS pays for the baseline rebuild + break-check detect below; any - # uncertainty (missing summary, parse error, schema drift, cold cache) yields - # changed=true and runs detect. + # Gate the expensive snapshot/detect work on turbo's content hashing, but compare + # the PR head against the pinned base SHA. A cache HIT only means an output already + # exists for the task hash; it does not prove the PR matches its base. - name: Determine API surface changed id: gate + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | - # shellcheck disable=SC2016 - node -e ' - const fs=require("fs"),p=require("path"),dir=".turbo/runs"; - let changed=true; // default: when unsure, run detect + node <<'EOF' + const cp = require('child_process'); + const fs = require('fs'); + const path = require('path'); + + const workspace = process.env.GITHUB_WORKSPACE; + const baseWorktree = path.join(workspace, '.worktrees/break-check-baseline'); + const filters = process.env.BREAK_CHECK_FILTERS.trim().split(/\s+/); + const turbo = path.join(workspace, 'node_modules/.bin/turbo'); + let changed = true; // default: when unsure, run detect + + const parseTurboJson = output => { + const start = output.indexOf('{'); + if (start === -1) { + throw new Error('turbo dry run did not produce JSON'); + } + return JSON.parse(output.slice(start)); + }; + + const runTurboDry = cwd => { + const output = cp.execFileSync(turbo, ['build:declarations', '--dry=json', ...filters], { + cwd, + encoding: 'utf8', + maxBuffer: 100 * 1024 * 1024, + }); + return parseTurboJson(output); + }; + + const apiTaskHashes = summary => { + const entries = (summary.tasks || []) + .filter(t => { + const taskId = t.taskId || ''; + return taskId.endsWith('#build') || taskId.endsWith('#build:declarations'); + }) + .map(t => [t.taskId, t.hash]); + + if (entries.length === 0) { + throw new Error('turbo dry run contained no API task hashes'); + } + + return new Map(entries); + }; + try { - const f=fs.readdirSync(dir).filter(n=>n.endsWith(".json")).map(n=>p.join(dir,n)) - .map(n=>[n,fs.statSync(n).mtimeMs]).sort((a,b)=>b[1]-a[1])[0][0]; - const s=JSON.parse(fs.readFileSync(f,"utf8")); - const builds=(s.tasks||[]).filter(t=>(t.taskId||"").endsWith("#build")); - // Only trust a "false" when we actually saw the build tasks and every one HIT. - changed = builds.length===0 || builds.some(t=>t.cache?.status!=="HIT"); - } catch (e) { console.log("gate: falling back to changed=true:", e.message); } + const changedFiles = cp + .execFileSync('git', ['diff', '--name-only', process.env.BASE_SHA, process.env.HEAD_SHA], { + cwd: workspace, + encoding: 'utf8', + }) + .trim() + .split(/\n/) + .filter(Boolean); + + const forcedFiles = changedFiles.filter( + f => f === 'break-check.config.json' || f === '.github/workflows/api-changes.yml', + ); + + if (forcedFiles.length > 0) { + console.log('gate: workflow/config changed; running detect:', forcedFiles.join(', ')); + } else { + const head = apiTaskHashes(runTurboDry(workspace)); + const base = apiTaskHashes(runTurboDry(baseWorktree)); + const allTaskIds = new Set([...head.keys(), ...base.keys()]); + + changed = [...allTaskIds].some(taskId => head.get(taskId) !== base.get(taskId)); + } + } catch (e) { + console.log('gate: falling back to changed=true:', e.message); + changed = true; + } + fs.appendFileSync(process.env.GITHUB_OUTPUT,`changed=${changed}\n`); - console.log("tracked build cache miss / unknown:", changed); - ' + console.log('tracked API task hash changed / unknown:', changed); + EOF + + - name: Build current declarations + if: steps.gate.outputs.changed == 'true' + run: pnpm turbo build:declarations $TURBO_ARGS $BREAK_CHECK_FILTERS - name: Resolve break-check cache key id: break-check-key @@ -205,28 +278,6 @@ jobs: # same version the PR runs (see publish-baseline for the rationale). key: break-check-baseline-${{ steps.break-check-key.outputs.ref }}-${{ github.event.pull_request.base.sha }} - - name: Fetch base commit - if: steps.gate.outputs.changed == 'true' && steps.baseline-cache.outputs.cache-matched-key == '' - run: git fetch origin "${{ github.event.pull_request.base.sha }}" --depth=1 - - - name: Create baseline worktree - if: steps.gate.outputs.changed == 'true' && steps.baseline-cache.outputs.cache-matched-key == '' - run: | - mkdir -p .worktrees - git worktree add --detach .worktrees/break-check-baseline "${{ github.event.pull_request.base.sha }}" - # Snapshot the base ref with the coverage it actually had. Only seed the - # config when the base tracks no coverage at all; otherwise packages - # newly added to coverage in this PR get diffed against a baseline that - # never tracked them (every export reads as a phantom change against the - # base's bundled .d.ts), and the base ref may not even build their - # declarations yet. A base from before this rename still names its config - # snapi.config.json, so check both names. This reads the base's real - # coverage; it is not rename-compat, and goes no-op once main carries - # break-check.config.json. - if [ ! -f .worktrees/break-check-baseline/break-check.config.json ] && [ ! -f .worktrees/break-check-baseline/snapi.config.json ]; then - cp break-check.config.json .worktrees/break-check-baseline/break-check.config.json - fi - - name: Install baseline dependencies if: steps.gate.outputs.changed == 'true' && steps.baseline-cache.outputs.cache-matched-key == '' working-directory: .worktrees/break-check-baseline @@ -261,11 +312,9 @@ jobs: --ai-apply-downgrades \ --fail-on-breaking - # Note: on the cache-hit skip path we intentionally post nothing. A turbo HIT is - # an inference that declarations did not change, not a break-check analysis, so a - # "no breaking changes" clearance would overclaim (e.g. a revert to an older - # still-cached state HITs the cache yet differs from base). The "no API changes" - # comment below is only ever posted when detect actually ran and found nothing. + # Note: on the hash-equal skip path we intentionally post nothing. The "no API + # changes" comment below is only ever posted when detect actually ran and found + # nothing. - name: Upload API changes report uses: actions/upload-artifact@v4 @@ -316,12 +365,13 @@ jobs: } } - // Stamp the head SHA detect actually ran on. Because cache-hit pushes are - // skipped silently (no comment update), this lets a reviewer see whether this - // comment reflects the current head or an earlier push. + // Stamp the head SHA detect actually ran on. Because pushes whose tracked + // declarations match the base are skipped silently (no comment update), this + // lets a reviewer see whether this comment reflects the current head or an + // earlier push. const ranSha = (process.env.HEAD_SHA || '').slice(0, 7); if (ranSha) { - body += `\n\nLast ran on \`${ranSha}\`. Pushes that change no tracked declarations (turbo cache hit) are skipped and don't update this comment.`; + body += `\n\nLast ran on \`${ranSha}\`. Pushes that change no tracked declarations (no API surface change vs. base) are skipped and don't update this comment.`; } const comments = await github.paginate(github.rest.issues.listComments, {