@@ -43,10 +43,11 @@ concurrency:
4343 cancel-in-progress : ${{ github.event_name == 'pull_request' }}
4444
4545env :
46- # The tool was renamed snapi -> break-check (package @clerk/break-check, binary
47- # break-check, repo clerk/break-check). Pinned to a pkg.pr.new build of a
48- # specific commit on main.
49- BREAK_CHECK_PACKAGE : https://pkg.pr.new/clerk/break-check/@clerk/break-check@aae2962cd76869d26dfc06e438c4af825827078b
46+ # break-check (https://github.com/clerk/break-check) snapshots only these
47+ # packages' declaration surfaces; kept in sync with break-check.config.json.
48+ # `build:declarations` is filtered to them so the rest of the monorepo doesn't
49+ # build. Promoted into $GITHUB_ENV in the PR job so the Action's setup-command
50+ # (which runs in a worktree) sees it alongside init-blacksmith's TURBO_ARGS.
5051 BREAK_CHECK_FILTERS : >-
5152 --filter=@clerk/astro
5253 --filter=@clerk/backend
6970 --filter=@clerk/vue
7071
7172jobs :
73+ # Snapshot the base branch's API surface once per push and publish it as an
74+ # artifact. PRs download the snapshot matching their base.sha instead of
75+ # rebuilding it, so the check only builds the PR head. Falls back to a base
76+ # rebuild when no artifact exists (first PR, expired, or base not yet pushed).
7277 publish-baseline :
7378 if : github.event_name == 'push'
7479 name : Publish API Baseline
7580 runs-on : ' blacksmith-8vcpu-ubuntu-2204'
7681 continue-on-error : true
82+ permissions :
83+ contents : read
7784 defaults :
7885 run :
7986 shell : bash
80- timeout-minutes : ${{ vars.TIMEOUT_MINUTES_NORMAL && fromJSON(vars.TIMEOUT_MINUTES_NORMAL) || 10 }}
87+ timeout-minutes : ${{ vars.TIMEOUT_MINUTES_NORMAL && fromJSON(vars.TIMEOUT_MINUTES_NORMAL) || 15 }}
8188
8289 steps :
8390 - name : Checkout Repo
84- uses : actions/checkout@v4
91+ uses : actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
8592 with :
93+ persist-credentials : false
8694 filter : ' blob:none'
8795 show-progress : false
8896
@@ -99,51 +107,53 @@ jobs:
99107
100108 - name : Generate API snapshot
101109 run : |
102- pnpm dlx --package "$BREAK_CHECK_PACKAGE" break-check snapshot \
110+ npx --yes "@clerk/ break-check@0.1.1" snapshot \
103111 --output "$GITHUB_WORKSPACE/.api-snapshots-baseline"
104112
105- - name : Resolve break-check cache key
106- id : break-check-key
107- run : echo "ref=${BREAK_CHECK_PACKAGE##*@}" >> "$GITHUB_OUTPUT"
108-
109- - name : Save baseline to cache
110- uses : actions/cache/save@v4
113+ - name : Upload baseline artifact
114+ uses : actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
111115 with :
116+ name : break-check-baseline
112117 path : .api-snapshots-baseline
113- # Fold the break-check commit into the key: a snapshot produced by one
114- # break-check version must not be reused by another, since discovery
115- # changes (e.g. wildcard subpath expansion) make the surfaces
116- # incomparable and the diff degenerates into thousands of phantom
117- # additions.
118- key : break-check-baseline-${{ steps.break-check-key.outputs.ref }}-${{ github.sha }}
118+ retention-days : 30
119+ if-no-files-found : error
120+ # .api-snapshots-baseline is dot-prefixed; upload-artifact treats it as
121+ # hidden and skips it without this flag.
122+ include-hidden-files : true
119123
120124 check-api :
121125 if : ${{ github.event_name == 'pull_request' && github.event.pull_request.draft == false }}
122126 name : API Changes
123127 runs-on : ' blacksmith-8vcpu-ubuntu-2204'
128+ # Advisory: a red break-check does not block merge today. Flip to a gate
129+ # later (fail-on-breaking + policy-mode + a required status check).
124130 continue-on-error : true
125131 permissions :
126132 contents : read
127133 pull-requests : write
134+ # The Action downloads the baseline artifact via the GitHub API.
135+ actions : read
128136 defaults :
129137 run :
130138 shell : bash
131- timeout-minutes : ${{ vars.TIMEOUT_MINUTES_NORMAL && fromJSON(vars.TIMEOUT_MINUTES_NORMAL) || 10 }}
139+ timeout-minutes : ${{ vars.TIMEOUT_MINUTES_NORMAL && fromJSON(vars.TIMEOUT_MINUTES_NORMAL) || 20 }}
132140
133141 steps :
134142 - name : Checkout Repo
135- uses : actions/checkout@v4
143+ uses : actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
136144 with :
137- # Pin the "current" side of the diff to the PR head, not the
138- # refs/pull/N/merge ref checkout resolves by default. The merge ref is
139- # the head merged into the moving tip of the base branch, so once main
140- # advances it absorbs unrelated changes and break-check reports them as
141- # this PR's own (clerk/break-check#32). The baseline is already pinned
142- # to base.sha (cache key + worktree fallback below).
145+ # Check out the PR head commit, not the refs/pull/N/merge ref that
146+ # checkout resolves by default (it absorbs base-branch drift, so once
147+ # main advances the diff reports unrelated changes as the PR's own).
148+ # This also makes the head's blobs local so the in-place build needs no
149+ # extra fetch. persist-credentials: false keeps the token out of the
150+ # checkout where the PR's own build scripts run; the repo is public, so
151+ # the Action's on-demand blob fetch in the fallback base rebuild still
152+ # works unauthenticated.
143153 ref : ${{ github.event.pull_request.head.sha }}
144- fetch-depth : 100
145- fetch-tags : false
154+ fetch-depth : 0
146155 filter : ' blob:none'
156+ persist-credentials : false
147157 show-progress : false
148158
149159 - name : Setup
@@ -154,248 +164,27 @@ jobs:
154164 turbo-team : ${{ vars.TURBO_TEAM }}
155165 turbo-token : ${{ secrets.TURBO_TOKEN }}
156166
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
176-
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.
180- - name : Determine API surface changed
181- id : gate
182- env :
183- BASE_SHA : ${{ github.event.pull_request.base.sha }}
184- HEAD_SHA : ${{ github.event.pull_request.head.sha }}
185- run : |
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-
229- try {
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-
257- fs.appendFileSync(process.env.GITHUB_OUTPUT,`changed=${changed}\n`);
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
167+ - name : Promote break-check filters into the job env
168+ # init-blacksmith exports TURBO_ARGS via $GITHUB_ENV, which the Action's
169+ # worktree setup-command inherits. Promote the package filters the same
170+ # way so the command can stay one shared line for the head build and the
171+ # fallback base rebuild.
172+ run : echo "BREAK_CHECK_FILTERS=$BREAK_CHECK_FILTERS" >> "$GITHUB_ENV"
264173
265- - name : Resolve break-check cache key
266- id : break-check-key
267- if : steps.gate.outputs.changed == 'true'
268- run : echo "ref=${BREAK_CHECK_PACKAGE##*@}" >> "$GITHUB_OUTPUT"
269-
270- - name : Restore baseline from cache
271- id : baseline-cache
272- if : steps.gate.outputs.changed == 'true'
273- uses : actions/cache/restore@v4
174+ - name : Break Check
175+ uses : clerk/break-check@93e6163d6696ca437667562b31332e0c573692ab # v0.1.1
274176 with :
275- path : .api-snapshots-baseline
276- # Keyed on the break-check commit too, so bumping break-check misses the
277- # stale baseline and the worktree fallback below rebuilds it with the
278- # same version the PR runs (see publish-baseline for the rationale).
279- key : break-check-baseline-${{ steps.break-check-key.outputs.ref }}-${{ github.event.pull_request.base.sha }}
280-
281- - name : Install baseline dependencies
282- if : steps.gate.outputs.changed == 'true' && steps.baseline-cache.outputs.cache-matched-key == ''
283- working-directory : .worktrees/break-check-baseline
284- run : pnpm install --frozen-lockfile
285-
286- - name : Build baseline declarations
287- if : steps.gate.outputs.changed == 'true' && steps.baseline-cache.outputs.cache-matched-key == ''
288- working-directory : .worktrees/break-check-baseline
289- # --continue past per-package failures and don't fail the step: the base
290- # ref may not build declarations for packages that only gained
291- # build:declarations support in this PR. break-check snapshots only the
292- # base's own coverage below, so a partial build is fine; a needed package
293- # that fails to build still surfaces when `break-check snapshot` finds no
294- # .d.ts.
295- run : pnpm turbo build:declarations $TURBO_ARGS $BREAK_CHECK_FILTERS --continue || true
296-
297- - name : Generate baseline API snapshots
298- if : steps.gate.outputs.changed == 'true' && steps.baseline-cache.outputs.cache-matched-key == ''
299- working-directory : .worktrees/break-check-baseline
300- run : |
301- pnpm dlx --package "$BREAK_CHECK_PACKAGE" break-check snapshot \
302- --output "$GITHUB_WORKSPACE/.api-snapshots-baseline"
303-
304- - name : Detect API changes
305- if : steps.gate.outputs.changed == 'true'
306- env :
307- BREAK_CHECK_ANTHROPIC_API_KEY : ${{ secrets.BREAK_CHECK_ANTHROPIC_API_KEY }}
308- run : |
309- pnpm dlx --package "$BREAK_CHECK_PACKAGE" break-check detect \
310- --baseline .api-snapshots-baseline \
311- --output api-changes-report.md \
312- --ai-apply-downgrades \
313- --fail-on-breaking
314-
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.
318-
319- - name : Upload API changes report
320- uses : actions/upload-artifact@v4
321- if : always()
322- with :
323- name : api-changes-report
324- path : api-changes-report.md
325- if-no-files-found : ignore
326- retention-days : 5
327-
328- - name : Post break-check report
329- if : always()
330- uses : actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
331- env :
332- RUN_URL : ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
333- HEAD_SHA : ${{ github.event.pull_request.head.sha }}
334- with :
335- script : |
336- const fs = require('fs');
337- const reportPath = 'api-changes-report.md';
338- if (!fs.existsSync(reportPath)) {
339- core.info('No break-check report found; skipping comment.');
340- return;
341- }
342-
343- const marker = '<!-- break-check-report -->';
344- const report = fs.readFileSync(reportPath, 'utf-8');
345-
346- let body;
347- if (report.includes('## No API Changes Detected')) {
348- body = `${marker}\n**Break Check**: no API changes detected across the tracked packages.`;
349- } else {
350- // GitHub rejects comment bodies over 65536 chars. Read the report
351- // from disk and post via the API so we never hit an arg-length
352- // limit, and truncate with a pointer to the full artifact when a
353- // genuinely large diff would overflow the comment.
354- const LIMIT = 64000;
355- const head = `${marker}\n`;
356- if (head.length + report.length <= LIMIT) {
357- body = head + report;
358- } else {
359- const notice =
360- `\n\n> **Note**\n> Report truncated to fit GitHub's comment limit. ` +
361- `The full report is attached as the \`api-changes-report\` artifact on ` +
362- `[this run](${process.env.RUN_URL}).\n`;
363- const budget = LIMIT - head.length - notice.length;
364- body = head + report.slice(0, budget) + notice;
365- }
366- }
367-
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.
372- const ranSha = (process.env.HEAD_SHA || '').slice(0, 7);
373- if (ranSha) {
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>`;
375- }
376-
377- const comments = await github.paginate(github.rest.issues.listComments, {
378- owner: context.repo.owner,
379- repo: context.repo.repo,
380- issue_number: context.issue.number,
381- per_page: 100,
382- });
383- const existing = comments.find(
384- (c) => c.user?.type === 'Bot' && c.body && c.body.includes(marker),
385- );
386-
387- if (existing) {
388- await github.rest.issues.updateComment({
389- owner: context.repo.owner,
390- repo: context.repo.repo,
391- comment_id: existing.id,
392- body,
393- });
394- } else {
395- await github.rest.issues.createComment({
396- owner: context.repo.owner,
397- repo: context.repo.repo,
398- issue_number: context.issue.number,
399- body,
400- });
401- }
177+ break-check-version : ' 0.1.1'
178+ # The PR head is already checked out, so build it in place rather than
179+ # in a second head worktree.
180+ head-ref : ' '
181+ # Reuse the snapshot published for the PR's base commit; the Action
182+ # falls back to a base rebuild when no matching artifact is found.
183+ baseline-artifact-name : break-check-baseline
184+ setup-command : pnpm install --frozen-lockfile && pnpm turbo build:declarations $TURBO_ARGS $BREAK_CHECK_FILTERS
185+ # Enables the AI reviewer. The downgrade policy lives in
186+ # break-check.config.json (ai.applyDowngrades). Empty on fork PRs
187+ # (secrets are withheld there), where the rule-based diff still runs.
188+ anthropic-api-key : ${{ secrets.BREAK_CHECK_ANTHROPIC_API_KEY }}
189+ comment : ' true'
190+ fail-on-breaking : ' false'
0 commit comments