Skip to content

Commit ef185f2

Browse files
authored
ci(repo): adopt the official @clerk/break-check action for API checks (#8781)
1 parent 2d6670c commit ef185f2

3 files changed

Lines changed: 67 additions & 273 deletions

File tree

.changeset/great-comics-attack.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

.github/workflows/api-changes.yml

Lines changed: 61 additions & 272 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,11 @@ concurrency:
4343
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
4444

4545
env:
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
@@ -69,20 +70,27 @@ env:
6970
--filter=@clerk/vue
7071
7172
jobs:
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

Comments
 (0)