From 4225782eacd7e64e32a23ec3daa53ca6bbaa8861 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 26 Oct 2026 23:27:24 +0000 Subject: [PATCH] chore(zero-swift): fail CI on undeclared public API breaks swift/scripts/api-breaks.sh dumps the public API of the last release (the mirror's newest tag) and of this checkout, with the digester, in a scratch repository shaped like the mirror, removes package declarations from both dumps, and compares them. It fails on a break unless a pending changeset or the version being released bumps zero-swift by a minor (below 1.0) or a major. Linux and macOS CI run it; macOS covers the Apple-only API. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm --- .changeset/README.md | 6 ++ .github/workflows/swift-macos.yml | 3 + .github/workflows/swift.yml | 8 ++- swift/scripts/api-breaks.sh | 107 ++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 1 deletion(-) create mode 100755 swift/scripts/api-breaks.sh diff --git a/.changeset/README.md b/.changeset/README.md index 95a50ed7ad..f0222b2add 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -19,3 +19,9 @@ request open with the pending version and changelog. Merging it sets the version in `packages/zero-swift-cli/package.json` and `swift/VERSION`, and the workflow then publishes the npm package and tags the SwiftPM mirror after approval in the `swift-release` environment. + +A change that breaks the Swift package's public API, so that an app building +against the last release no longer compiles, needs a `minor` changeset while +zero-swift is below 1.0, and a `major` one from 1.0. CI runs +`swift/scripts/api-breaks.sh`, which lists the breaks since the last release +and fails unless a changeset declares them. diff --git a/.github/workflows/swift-macos.yml b/.github/workflows/swift-macos.yml index 4269b0a3fb..40d13138e3 100644 --- a/.github/workflows/swift-macos.yml +++ b/.github/workflows/swift-macos.yml @@ -44,6 +44,9 @@ jobs: run: swift test - name: Docs run: scripts/docs.sh + # The Apple-only API, which Linux doesn't build. + - name: API breaks + run: scripts/api-breaks.sh - name: Test (iOS Simulator) run: | device="$(xcrun simctl list devices available -j | diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index f25bd4e635..edbdb28226 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -73,7 +73,7 @@ jobs: name: generated-vectors path: packages/zero-conformance/generated/ - name: Install system libraries - run: apt-get update && apt-get install -y --no-install-recommends libsqlite3-dev + run: apt-get update && apt-get install -y --no-install-recommends libsqlite3-dev jq - name: Format run: swift format lint --strict --recursive Sources Tests Package.swift - name: Build @@ -84,6 +84,12 @@ jobs: # Apple-only; swift-macos.yml checks it. - name: Docs run: scripts/docs.sh Zero ZeroTesting + # Fails on a public API break since the last release that no changeset + # declares. The checkout belongs to another user in the container. + - name: API breaks + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + scripts/api-breaks.sh # zbugs' Swift port, checked against zbugs' TS queries and mutators on # the samples in drift-samples.ts (the vectors job checks the record is # current). diff --git a/swift/scripts/api-breaks.sh b/swift/scripts/api-breaks.sh new file mode 100755 index 0000000000..4854b561d2 --- /dev/null +++ b/swift/scripts/api-breaks.sh @@ -0,0 +1,107 @@ +#!/bin/sh +# Fails when the public API breaks compared with the last release, unless the +# break is declared: a pending changeset, or the version being released, bumps +# zero-swift enough for it (a minor below 1.0, a major from 1.0). +# +# scripts/api-breaks.sh [] default: the mirror's newest tag +# +# The digester (`swift package diagnose-api-breaking-changes`) needs the +# package at the root of a git repository, so this runs it in a scratch +# repository shaped like the SwiftPM mirror (wevm/zero-swift): the released tag, +# with this checkout's swift/ committed on top. Its API dumps also hold +# `package` declarations, which apps can't use, so they're removed from both +# sides before comparing. On Linux the Apple-only API (ZeroUI, most of +# ZeroVirtual) isn't built, so swift-macos.yml runs this too. +set -eu +cd "$(dirname "$0")/.." +swift=$(pwd) +root=$(cd .. && pwd) +mirror=https://github.com/wevm/zero-swift.git +products="Zero ZeroUI ZeroVirtual ZeroTesting" + +baseline=${1:-$(git ls-remote --tags --refs "$mirror" | sed 's|.*refs/tags/||' | + sort -V | tail -1)} + +work=$(mktemp -d) +dumps=$(mktemp -d) +trap 'rm -rf "$work" "$dumps"' EXIT + +git -C "$work" init -q +git -C "$work" fetch -q --depth 1 "$mirror" "refs/tags/$baseline:refs/tags/$baseline" +git -C "$work" checkout -q "$baseline" +git -C "$work" rm -rq . +# This checkout's swift/, with uncommitted changes, as the release would have it. +git ls-files -z --cached --others --exclude-standard | + tar --null -cf - -T - | tar -xf - -C "$work" +git -C "$work" add -A +git -C "$work" -c user.name=api-breaks -c user.email=api-breaks@localhost \ + commit -qm current --allow-empty + +digester=$(command -v swift-api-digester || xcrun --find swift-api-digester 2>/dev/null || + echo "$(dirname "$(readlink -f "$(command -v swift)")")/swift-api-digester") + +cd "$work" +# Dumps each side's API, keeps only its public declarations, and compares +# them. Stripping `package` declarations from both sides matters: one that +# was public and became `package` reads as removed. +for side in old:$baseline new:HEAD; do + name=${side%%:*} rev=${side#*:} + swift package diagnose-api-breaking-changes "$rev" --products $products \ + --baseline-dir "$dumps/$name" >"$dumps/$name.log" 2>&1 || true + if ! ls "$dumps/$name"/*/*.json >/dev/null 2>&1; then + echo "Couldn't dump the API at $rev:" >&2 + cat "$dumps/$name.log" >&2 + exit 1 + fi + for dump in "$dumps/$name"/*/*.json; do + jq 'walk(if type == "object" and has("children") + then .children |= map(select(.isInternal != true)) else . end)' \ + "$dump" >"$dumps/$name-$(basename "$dump")" + done +done +breaks= +for old in "$dumps"/old-*.json; do + module=$(basename "$old" .json) module=${module#old-} + new="$dumps/new-$module.json" + if [ ! -e "$new" ]; then + breaks="$breaks$module: module has been removed +" + continue + fi + "$digester" -diagnose-sdk -print-module -input-paths "$old" -input-paths "$new" \ + -o "$dumps/$module.txt" + breaks="$breaks$(grep -v -e '^/\*' -e '^$' "$dumps/$module.txt" || true) +" +done +breaks=$(printf '%s' "$breaks" | sed '/^$/d') +if [ -z "$breaks" ]; then + echo "No public API breaks since $baseline." + exit 0 +fi + +echo "Public API breaks since $baseline:" +printf '%s\n' "$breaks" | sed 's/^/ /' +case $baseline in + 0.*) needed=minor levels='minor|major' ;; + *) needed=major levels=major ;; +esac +# A pending changeset that declares the break. +if grep -lqE "^['\"]zero-swift['\"]: *($levels)\$" "$root"/.changeset/*.md 2>/dev/null; then + echo "Allowed: a pending changeset bumps zero-swift by a $needed or more." + exit 0 +fi +# The version pull request, or main after it merged: the changesets are gone, +# and the version being released declares the break. +version=$(cat "$swift/VERSION") +old_major=${baseline%%.*} old_minor=${baseline#*.} old_minor=${old_minor%%.*} +new_major=${version%%.*} new_minor=${version#*.} new_minor=${new_minor%%.*} +if [ "$new_major" -gt "$old_major" ] || + { [ "$needed" = minor ] && [ "$new_major" -eq "$old_major" ] && + [ "$new_minor" -gt "$old_minor" ]; }; then + echo "Allowed: $version is a $needed or more after $baseline." + exit 0 +fi +echo "These break apps that build against zero-swift $baseline. Keep the old" \ + "API (deprecate it), or add a changeset that bumps zero-swift by a $needed" \ + "and tells apps what to change." >&2 +exit 1