Skip to content

ci: detect public API breakage with swift-api-digester - #1090

Merged
pblazej merged 10 commits into
mainfrom
blaze/api-digester
Aug 18, 2026
Merged

ci: detect public API breakage with swift-api-digester#1090
pblazej merged 10 commits into
mainfrom
blaze/api-digester

Conversation

@pblazej

@pblazej pblazej commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Adds an api-check workflow that catches accidental public API changes on every PR.

It builds LiveKit for distribution (BUILD_LIBRARY_FOR_DISTRIBUTION=YES) at HEAD and at the PR base, dumps each module's API surface with swift-api-digester -dump-sdk, and diagnoses the delta both ways round. Breaking changes fail the job; additions are reported alongside them for review. Runs for macOS and iOS, since a good chunk of the public surface sits behind #if os(iOS); both builds share one package cache so the WebRTC xcframework is downloaded once. ~5 min per leg.

.github/api-check/api-check.swift follows the same swift-sh + swiftly run +xcode shape as .github/size-check. It's a standalone workflow rather than a ci.yaml job, matching how cocoapods-lint and codeql are already split out — which also buys workflow_dispatch inputs for comparing any two refs on demand, and a paths filter so the repo's most expensive job is skipped for PRs that can't move the public API.

Reporting additions

The digester only reports what a consumer would trip over. Diffing the other way round surfaces additions: whatever disappears going from HEAD back to the base is new in HEAD. A reversed report isn't purely additive — changed types, renames and the like appear mirrored — so only the two sections meaning "present here, absent there" are read as additions, with their wording flipped back.

Why not swiftlang's soundness

Its api-breakage job is pinned to runs-on: ubuntu-latest in a Swift container, which a caller can't override — and this package is Apple-only. Running its command directly on macOS fails too:

$ swift package diagnose-api-breaking-changes 768f7ec0
Build complete! (43.81s)
error: baseline for LiveKit contains no symbols, swift-api-digester output:
  <unknown>:0: error: missing required module 'RustLiveKitUniFFI'
error: fatalError

SwiftPM gives the digester no framework search path for the xcframework dependencies, and the command has no flag to pass one. This job supplies -F from xcodebuild's products dir instead.

Known blind spot

Inserting a case into the middle of an @objc enum is reported as an addition, but the raw-value shift it causes for every later case is not — the digester doesn't see raw values.

Verified on CI

Two throwaway commits deliberately broke and extended ConnectionState's public API to see both halves reported live (run):

### ❌ Breaking
**Removed Decls**
- TypeAlias ConnectionState.ID has been removed
- Var ConnectionState.id has been removed
**Protocol Conformance Change**
- Enum ConnectionState has removed conformance to Identifiable

### ✅ Added
- EnumElement ConnectionState.resuming has been added

Both have since been reverted in 7c6dccfSources/ matches main, so this PR now only adds the check.

pblazej and others added 2 commits August 17, 2026 14:39
Adds an `api-check` job that builds LiveKit for distribution at HEAD and at
the PR base, dumps each module's API surface with `swift-api-digester
-dump-sdk`, and diagnoses the delta. Anything removed or changed
incompatibly fails the job and lands in the run summary.

Runs for macOS and iOS, since a good chunk of the public surface is behind
`#if os(iOS)`. Both builds share one package cache so the WebRTC xcframework
is downloaded once.

swiftlang's `soundness` workflow can't be reused: its api-breakage job is
pinned to `ubuntu-latest`, and `swift package diagnose-api-breaking-changes`
fails on this package even on macOS — SwiftPM gives the digester no framework
search path for the xcframework dependencies, so the module won't load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Temporary. Drops a public conformance and its `id` property so the new
`api-check` job reports a real breakage on CI. Revert before merging.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

⚠️ This PR does not contain any files in the .changes directory.

devin-ai-integration[bot]

This comment was marked as resolved.

pblazej and others added 3 commits August 17, 2026 14:44
Matches how cocoapods-lint and codeql are already split out: self-contained
PR gate, own tool install, no artifacts shared with build-and-test.

Being its own workflow buys three things it can't have inside ci.yaml:
`workflow_dispatch` inputs, so any two refs can be compared on demand; a
`paths` filter, so the repo's most expensive job is skipped for pull requests
that can't move the public API; and no push trigger, since the check is a
comparison and only a pull request carries a base.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The digester only reports what a consumer would trip over, never additions.
Diffing the other way round surfaces them: whatever disappears going from HEAD
back to the base is new in HEAD.

A reversed report is not purely additive, though — changed types, renames and
the like appear mirrored in both directions — so only the two sections that
mean "present here, absent there" are read as additions, with their wording
flipped back. Breaking changes still fail the job; additions are reported for
review, and an additions-only run now writes a summary and passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Temporary, alongside the Identifiable removal. Also demonstrates the known
blind spot: the new case shifts every later @objc raw value and the digester
reports only the addition, never the shift. Revert before merging.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reverts e0eabdf and 02ce72f, which deliberately broke and extended
ConnectionState's public API so the new job could be seen reporting both on
CI. Sources/ is back to matching main; only the check itself remains.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

A checkout leaves only the checked-out branch as a local ref; every other
branch arrives as `origin/<name>`, and `git worktree add --detach` disables the
DWIM that would otherwise resolve the bare name. So a manually dispatched run
died on `fatal: invalid reference: main` unless it happened to be dispatched
from main. Try the ref as given, then `origin/<ref>`.

Only branches were affected — the pull_request path passes a SHA, and a
checkout with fetch-depth 0 fetches `+refs/tags/*:refs/tags/*` regardless of
`fetch-tags: false`, so tags resolved already.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

pblazej and others added 2 commits August 18, 2026 13:12
Protocol Conformance Change is not one-directional. Besides the removals the
reversed report is read for, it files diagnostics that are themselves worded as
additions — `has added inherited protocol`, `has added a conformance to an
existing protocol` — plus `is now/no longer an optional requirement`. All are
breaking, and none were touched by the relabelling, so a mirror of a forward
finding could print verbatim under "Added".

Gate on the relabelling instead: a line survives only if inverting it actually
changed the text. What can't be inverted is dropped, and the forward report
already covers it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ShellOut joins its arguments into one shell command without quoting them, so a
dispatched `base` reached git as source text. `nosuchref; touch FILE` created
the file — and then resolve() accepted the ref, because the injected command
exited 0 and ShellOut only sees the exit status, so the build went on into a
worktree that was never created.

Two changes: the workflow passes the value through env instead of interpolating
it into the run script, so it is never substituted as shell text; and the ref is
checked against the characters git actually permits before any shell call. That
excludes nothing valid — git rejects refs containing space, `~`, `^`, `:`, `?`,
`*`, `[` or `\` — and a bad ref now fails with a message naming it.

Only `base` was ever user-supplied; the platform comes from the matrix literal.
Dispatching needs write access, so this was never reachable from a fork.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@pblazej
pblazej merged commit eebf780 into main Aug 18, 2026
53 of 55 checks passed
@pblazej
pblazej deleted the blaze/api-digester branch August 18, 2026 12:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants