From 584b9222291f427b243f72f6640de0ad52055474 Mon Sep 17 00:00:00 2001 From: romchornyi <270373998+romchornyi@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:57:47 +0300 Subject: [PATCH 1/7] ci: build, test and API-break check Nothing compiled the library on a PR, so a change that does not build, or that uses a newer-iOS API inside a component annotated for iOS 14, could be merged. Add: - Build (iOS): iOS Simulator build in Debug (compiles previews) and Release - Test: tests on an available iPhone simulator - API compatibility: diagnose-api-breaking-changes against the base branch; a break passes only when the PR title marks it with "!" - Lint workflows: actionlint 1.7.12 Pin every action by SHA (including the existing semantic PR title action) and let Dependabot keep them current. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 8 ++ .github/workflows/api-check.yml | 76 ++++++++++++++ .github/workflows/ci.yml | 106 ++++++++++++++++++++ .github/workflows/semantic-pull-request.yml | 2 +- 4 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/api-check.yml create mode 100644 .github/workflows/ci.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f8fe74b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + commit-message: + prefix: ci diff --git a/.github/workflows/api-check.yml b/.github/workflows/api-check.yml new file mode 100644 index 0000000..7857f82 --- /dev/null +++ b/.github/workflows/api-check.yml @@ -0,0 +1,76 @@ +name: API check + +# Separate from CI so that editing the PR title (adding or removing "!") re-runs only +# this check, not the build and tests. +on: + pull_request: + types: + - opened + - edited + - synchronize + - reopened + +permissions: + contents: read + +concurrency: + group: api-check-${{ github.ref }} + cancel-in-progress: true + +env: + # Swift tools 6.3 (Package.swift) needs Xcode 26.4 or newer. + XCODE_VERSION: "26.6" + +jobs: + api: + name: API compatibility + runs-on: macos-26 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Select Xcode + run: | + sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app" + swift --version + + # A breaking change to the public API is allowed only when the PR title says so + # with a conventional-commit "!" (e.g. "feat!: …", "feat(scope)!: …"). + - name: Diagnose API breaking changes + env: + BASE_REF: ${{ github.base_ref }} + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + set +e + report=$(swift package diagnose-api-breaking-changes "origin/${BASE_REF}" 2>&1) + status=$? + set -e + echo "$report" + + { + echo "## API compatibility vs \`${BASE_REF}\`" + echo '```' + echo "$report" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + if [ "$status" -eq 0 ]; then + echo "No breaking changes." + exit 0 + fi + + if ! grep -q "API breakage" <<< "$report"; then + echo "::error::diagnose-api-breaking-changes failed without reporting a breakage (see log)" + exit "$status" + fi + + if grep -Eq '^[a-z]+(\([^)]*\))?!:' <<< "$PR_TITLE"; then + echo "Breaking change is declared in the PR title." + exit 0 + fi + + echo "::error::This PR breaks the public API. Mark the title as breaking (e.g. \"feat!: …\") and describe it under \"Breaking Changes\" in the PR description." + exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..31abe44 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,106 @@ +name: CI + +on: + pull_request: + push: + branches: + - master + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + # Swift tools 6.3 (Package.swift) needs Xcode 26.4 or newer. + XCODE_VERSION: "26.6" + +jobs: + build: + name: Build (iOS) + runs-on: macos-26 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Select Xcode + run: | + sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app" + xcodebuild -version + swift --version + + # The iOS deployment target is only enforced when building for iOS, so this is + # what catches a newer-iOS API inside a component annotated for an older floor. + # Debug also compiles the #if DEBUG previews; Release is what ships. + - name: Build Debug + run: | + xcodebuild -scheme DashUIKit \ + -destination 'generic/platform=iOS Simulator' \ + -configuration Debug \ + CODE_SIGNING_ALLOWED=NO \ + -quiet build + + - name: Build Release + run: | + xcodebuild -scheme DashUIKit \ + -destination 'generic/platform=iOS Simulator' \ + -configuration Release \ + CODE_SIGNING_ALLOWED=NO \ + -quiet build + + test: + name: Test + runs-on: macos-26 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Select Xcode + run: | + sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app" + xcodebuild -version + + - name: Pick an iPhone simulator + run: | + udid=$(xcrun simctl list devices available -j | jq -r ' + [.devices | to_entries | sort_by(.key)[] + | select(.key | test("iOS")) + | .value[] | select(.name | startswith("iPhone"))] + | last | .udid // empty') + if [ -z "$udid" ]; then + echo "::error::No available iPhone simulator on this runner" + xcrun simctl list devices available + exit 1 + fi + echo "Using simulator $udid" + echo "SIMULATOR_UDID=$udid" >> "$GITHUB_ENV" + + # On the simulator, not macOS: UIKit-only files are compiled out on macOS. + - name: Run tests + run: | + xcodebuild -scheme DashUIKit \ + -destination "platform=iOS Simulator,id=${SIMULATOR_UDID}" \ + CODE_SIGNING_ALLOWED=NO \ + -quiet test + + actionlint: + name: Lint workflows + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # actionlint ships no action; its download script is pinned to the v1.7.12 commit. + - name: Run actionlint + run: | + bash <(curl -sSfL https://raw.githubusercontent.com/rhysd/actionlint/914e7df21a07ef503a81201c76d2b11c789d3fca/scripts/download-actionlint.bash) 1.7.12 + ./actionlint -color diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml index f1f480a..e29d90d 100644 --- a/.github/workflows/semantic-pull-request.yml +++ b/.github/workflows/semantic-pull-request.yml @@ -12,7 +12,7 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@v5 + - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: From c76fe22af3a64ad496765c7f52e12ac02b2216f3 Mon Sep 17 00:00:00 2001 From: romchornyi <270373998+romchornyi@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:20:27 +0300 Subject: [PATCH 2/7] ci: close the gaps found in local review - API check: build for the iOS Simulator, where the UIKit-only public API exists (on the macOS host it was compiled out and never compared), and compare against the merge commit's first parent instead of the live base tip, so a re-run after the base moves does not report new base API as removed. Body edits no longer cancel a running check. - Type-check the sources at the iOS floor from Package.swift: xcodebuild raises the deployment target to the SDK minimum and missed iOS 15 APIs. - Test: pick a simulator runtime the selected Xcode can run, keep the failure output and test count in the log, skip the 10-minute diagnostics collection on failure. Drop the Debug build the test run already does. - actionlint tarball is checksum-verified; Xcode selection lives in one composite action; master runs are grouped per commit; no workflow_dispatch; the title-check token is read-only; the "!" title match follows the conventional-commit parser. Co-Authored-By: Claude Opus 5.5 --- .github/actions/select-xcode/action.yml | 20 ++++++ .github/scripts/pick-simulator.sh | 29 ++++++++ .github/scripts/typecheck-ios-floor.sh | 39 ++++++++++ .github/workflows/api-check.yml | 39 ++++++---- .github/workflows/ci.yml | 79 +++++++++------------ .github/workflows/semantic-pull-request.yml | 4 ++ 6 files changed, 148 insertions(+), 62 deletions(-) create mode 100644 .github/actions/select-xcode/action.yml create mode 100755 .github/scripts/pick-simulator.sh create mode 100755 .github/scripts/typecheck-ios-floor.sh diff --git a/.github/actions/select-xcode/action.yml b/.github/actions/select-xcode/action.yml new file mode 100644 index 0000000..2c7d1a9 --- /dev/null +++ b/.github/actions/select-xcode/action.yml @@ -0,0 +1,20 @@ +name: Select Xcode +description: Select the Xcode that CI builds DashUIKit with. + +inputs: + version: + # Swift tools 6.3 (Package.swift) needs Xcode 26.4 or newer. + description: Xcode version, as in /Applications/Xcode_.app on the runner image. + default: "26.6" + +runs: + using: composite + steps: + - name: Select Xcode + shell: bash + env: + XCODE_VERSION: ${{ inputs.version }} + run: | + sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app" + xcodebuild -version + swift --version diff --git a/.github/scripts/pick-simulator.sh b/.github/scripts/pick-simulator.sh new file mode 100755 index 0000000..213819b --- /dev/null +++ b/.github/scripts/pick-simulator.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Prints the UDID of an available iPhone simulator on the newest iOS runtime the +# selected Xcode can run (runtime version <= the iOS Simulator SDK version). +set -euo pipefail + +sdk=$(xcrun --sdk iphonesimulator --show-sdk-version) + +runtime=$(xcrun simctl list runtimes available -j | jq -r --arg sdk "$sdk" ' + def v: split(".") | map(tonumber); + [.runtimes[] | select(.platform == "iOS" and (.version | v) <= ($sdk | v))] + | sort_by(.version | v) | last | .identifier // empty') + +if [ -z "$runtime" ]; then + echo "::error::No iOS simulator runtime <= SDK ${sdk} on this machine" >&2 + xcrun simctl list runtimes >&2 + exit 1 +fi + +udid=$(xcrun simctl list devices available -j | jq -r --arg rt "$runtime" ' + [.devices[$rt][]? | select(.name | startswith("iPhone"))] | last | .udid // empty') + +if [ -z "$udid" ]; then + echo "::error::No available iPhone simulator for ${runtime}" >&2 + xcrun simctl list devices available >&2 + exit 1 +fi + +echo "Using ${runtime} ${udid}" >&2 +echo "$udid" diff --git a/.github/scripts/typecheck-ios-floor.sh b/.github/scripts/typecheck-ios-floor.sh new file mode 100755 index 0000000..9554615 --- /dev/null +++ b/.github/scripts/typecheck-ios-floor.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Type-checks the library at the iOS floor declared in Package.swift. +# +# xcodebuild clamps the deployment target to the SDK's own minimum (iOS 15 on +# Xcode 27), so an iOS 15-only API inside an iOS 14 component builds cleanly +# there. swiftc accepts the real floor for a type-check, so run it directly, +# once as Release and once with DEBUG to cover the previews. +set -euo pipefail + +floor=$(grep -oE '\.iOS\(\.v[0-9]+' Package.swift | grep -oE '[0-9]+$' || true) +if [ -z "$floor" ]; then + echo "::error::Could not read the iOS deployment target from Package.swift" + exit 1 +fi + +# SwiftPM generates Bundle.module for the resource bundle; swiftc alone does not. +stub_dir=$(mktemp -d) +stub="$stub_dir/BundleModule.swift" +printf 'import Foundation\nextension Foundation.Bundle { static let module = Bundle.main }\n' > "$stub" +trap 'rm -rf "$stub_dir"' EXIT + +sources=() +while IFS= read -r -d '' file; do + sources+=("$file") +done < <(find Sources/DashUIKit -name '*.swift' -print0) + +for config in Release Debug; do + defines=() + if [ "$config" = Debug ]; then + defines=(-DDEBUG) + fi + echo "Type-checking ${#sources[@]} files for iOS ${floor} (${config})" + xcrun --sdk iphonesimulator swiftc -typecheck \ + -swift-version 6 \ + -target "arm64-apple-ios${floor}.0-simulator" \ + -module-name DashUIKit \ + "${defines[@]+"${defines[@]}"}" \ + "$stub" "${sources[@]}" +done diff --git a/.github/workflows/api-check.yml b/.github/workflows/api-check.yml index 7857f82..ea36c98 100644 --- a/.github/workflows/api-check.yml +++ b/.github/workflows/api-check.yml @@ -1,7 +1,9 @@ name: API check # Separate from CI so that editing the PR title (adding or removing "!") re-runs only -# this check, not the build and tests. +# this check, not the build and tests. `edited` also fires on body edits (CodeRabbit +# writes its summary there); a job-level `if` to skip those would report the skipped +# run as a success over an earlier failure, so they run in full. on: pull_request: types: @@ -15,11 +17,8 @@ permissions: concurrency: group: api-check-${{ github.ref }} - cancel-in-progress: true - -env: - # Swift tools 6.3 (Package.swift) needs Xcode 26.4 or newer. - XCODE_VERSION: "26.6" + # A body edit must not cancel a running check and leave the required check pending. + cancel-in-progress: false jobs: api: @@ -29,29 +28,38 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 + # The PR merge commit and its first parent, the base it was merged onto. + fetch-depth: 2 persist-credentials: false - - name: Select Xcode - run: | - sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app" - swift --version + - uses: ./.github/actions/select-xcode # A breaking change to the public API is allowed only when the PR title says so # with a conventional-commit "!" (e.g. "feat!: …", "feat(scope)!: …"). + # + # Built for the iOS Simulator: on the macOS host every `#if canImport(UIKit)` + # file is compiled out, so their public API would never be compared. + # The baseline is HEAD^1, not origin/: on a re-run the base may have moved + # past the merge commit under test, and its new API would read as removed. - name: Diagnose API breaking changes env: BASE_REF: ${{ github.base_ref }} PR_TITLE: ${{ github.event.pull_request.title }} run: | + sdk_path=$(xcrun --sdk iphonesimulator --show-sdk-path) + sdk_version=$(xcrun --sdk iphonesimulator --show-sdk-version) + set +e - report=$(swift package diagnose-api-breaking-changes "origin/${BASE_REF}" 2>&1) + report=$(swift package \ + --triple "arm64-apple-ios${sdk_version}-simulator" \ + --sdk "$sdk_path" \ + diagnose-api-breaking-changes HEAD^1 2>&1) status=$? set -e echo "$report" { - echo "## API compatibility vs \`${BASE_REF}\`" + echo "## API compatibility vs \`${BASE_REF}\` ($(git rev-parse --short HEAD^1))" echo '```' echo "$report" echo '```' @@ -63,11 +71,12 @@ jobs: fi if ! grep -q "API breakage" <<< "$report"; then - echo "::error::diagnose-api-breaking-changes failed without reporting a breakage (see log)" + echo "::error::diagnose-api-breaking-changes failed without reporting a breakage. The base (${BASE_REF}) or this PR may not build for the iOS Simulator; see the log." exit "$status" fi - if grep -Eq '^[a-z]+(\([^)]*\))?!:' <<< "$PR_TITLE"; then + # Same shape the semantic PR title check parses: type, optional scope, "!: ". + if grep -Eq '^[A-Za-z0-9_]+(\(.*\))?!: ' <<< "$PR_TITLE"; then echo "Breaking change is declared in the PR title." exit 0 fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31abe44..eaaf1d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,23 +1,23 @@ name: CI +# After a stacked PR is retargeted (e.g. its feat/* base merged), push a commit or +# close and reopen it: a base change fires only `edited`, which this workflow does not +# listen to, because CodeRabbit's body edits would rebuild on every one of them. on: pull_request: push: branches: - master - workflow_dispatch: permissions: contents: read concurrency: - group: ci-${{ github.ref }} + # One group per commit on master, so a burst of merges does not cancel the + # queued run of the commit in the middle. + group: ci-${{ github.event_name == 'push' && github.sha || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} -env: - # Swift tools 6.3 (Package.swift) needs Xcode 26.4 or newer. - XCODE_VERSION: "26.6" - jobs: build: name: Build (iOS) @@ -28,23 +28,9 @@ jobs: with: persist-credentials: false - - name: Select Xcode - run: | - sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app" - xcodebuild -version - swift --version - - # The iOS deployment target is only enforced when building for iOS, so this is - # what catches a newer-iOS API inside a component annotated for an older floor. - # Debug also compiles the #if DEBUG previews; Release is what ships. - - name: Build Debug - run: | - xcodebuild -scheme DashUIKit \ - -destination 'generic/platform=iOS Simulator' \ - -configuration Debug \ - CODE_SIGNING_ALLOWED=NO \ - -quiet build + - uses: ./.github/actions/select-xcode + # Release is what ships. The Debug build (previews included) happens in Test. - name: Build Release run: | xcodebuild -scheme DashUIKit \ @@ -53,6 +39,11 @@ jobs: CODE_SIGNING_ALLOWED=NO \ -quiet build + # xcodebuild raises the deployment target to the SDK's minimum, so it misses + # APIs newer than the package's own floor; this checks against the floor. + - name: Type-check at the iOS deployment target + run: .github/scripts/typecheck-ios-floor.sh + test: name: Test runs-on: macos-26 @@ -62,45 +53,39 @@ jobs: with: persist-credentials: false - - name: Select Xcode - run: | - sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app" - xcodebuild -version - - - name: Pick an iPhone simulator - run: | - udid=$(xcrun simctl list devices available -j | jq -r ' - [.devices | to_entries | sort_by(.key)[] - | select(.key | test("iOS")) - | .value[] | select(.name | startswith("iPhone"))] - | last | .udid // empty') - if [ -z "$udid" ]; then - echo "::error::No available iPhone simulator on this runner" - xcrun simctl list devices available - exit 1 - fi - echo "Using simulator $udid" - echo "SIMULATOR_UDID=$udid" >> "$GITHUB_ENV" + - uses: ./.github/actions/select-xcode # On the simulator, not macOS: UIKit-only files are compiled out on macOS. + # Without -quiet so the failure reason and the executed-test count are in the log; + # no diagnostics collection, which adds ~10 minutes to every failing run. - name: Run tests run: | + udid=$(.github/scripts/pick-simulator.sh) xcodebuild -scheme DashUIKit \ - -destination "platform=iOS Simulator,id=${SIMULATOR_UDID}" \ + -destination "platform=iOS Simulator,id=${udid}" \ + -collect-test-diagnostics never \ CODE_SIGNING_ALLOWED=NO \ - -quiet test + test actionlint: name: Lint workflows runs-on: ubuntu-latest timeout-minutes: 10 + env: + ACTIONLINT_VERSION: "1.7.12" + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - # actionlint ships no action; its download script is pinned to the v1.7.12 commit. - - name: Run actionlint + # actionlint ships no action. Dependabot cannot see this pin: bump it by hand. + - name: Install actionlint run: | - bash <(curl -sSfL https://raw.githubusercontent.com/rhysd/actionlint/914e7df21a07ef503a81201c76d2b11c789d3fca/scripts/download-actionlint.bash) 1.7.12 - ./actionlint -color + tarball="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + curl -sSfLo "$tarball" "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${tarball}" + echo "${ACTIONLINT_SHA256} ${tarball}" | sha256sum -c - + tar xzf "$tarball" actionlint + + - name: Run actionlint + run: ./actionlint -color diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml index e29d90d..879242e 100644 --- a/.github/workflows/semantic-pull-request.yml +++ b/.github/workflows/semantic-pull-request.yml @@ -7,6 +7,10 @@ on: - edited - synchronize +# pull_request_target gets a write token by default; the action only reads the PR. +permissions: + pull-requests: read + jobs: main: name: Validate PR title From 4978d0bc88f034d213be8d01ac99c63a80bf0ee7 Mon Sep 17 00:00:00 2001 From: romchornyi <270373998+romchornyi@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:36:55 +0300 Subject: [PATCH 3/7] ci: second local review pass - API check also fails on a raised iOS deployment target without "!", reads the PR title live (a re-run reuses the old payload), cancels only on a new commit, and puts just the verdict in the step summary. Logic moved to .github/scripts/api-check.sh so it is shellchecked and testable. - Floor type-check: parse major.minor from exactly one .iOS(.vNN) entry, pass the SwiftPM defines/package name, fail loudly on manifest shapes it does not model and on an empty source list, one DEBUG pass instead of two. - Build: arm64-only Release build, plus `swift build` for the macOS path. - Lint: shellcheck the scripts, which actionlint does not cover. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/api-check.sh | 81 ++++++++++++++++++++++++++ .github/scripts/ios-floor.sh | 16 +++++ .github/scripts/typecheck-ios-floor.sh | 62 +++++++++++++------- .github/workflows/api-check.yml | 62 +++++--------------- .github/workflows/ci.yml | 10 ++++ 5 files changed, 162 insertions(+), 69 deletions(-) create mode 100755 .github/scripts/api-check.sh create mode 100755 .github/scripts/ios-floor.sh diff --git a/.github/scripts/api-check.sh b/.github/scripts/api-check.sh new file mode 100755 index 0000000..fb7fd79 --- /dev/null +++ b/.github/scripts/api-check.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Fails on an undeclared breaking change: HEAD is compared with HEAD^1 (on a +# pull_request run, the merge commit and the base it was merged onto). A break is +# declared with a conventional-commit "!" in the PR title ("feat!: …"). +# +# Checked: the iOS deployment target in Package.swift, and the public API as +# diagnose-api-breaking-changes sees it when built for the iOS Simulator (on the +# macOS host every `#if canImport(UIKit)` file is compiled out). Not checked: a +# raised @available on a single declaration; the digester ignores availability. +# +# Env: PR_TITLE, BASE_REF (for messages), GITHUB_STEP_SUMMARY (optional). +set -euo pipefail + +scripts=$(cd "$(dirname "$0")" && pwd) +summary_file=${GITHUB_STEP_SUMMARY:-/dev/null} +base_short=$(git rev-parse --short HEAD^1) + +base_manifest=$(mktemp) +git show HEAD^1:Package.swift > "$base_manifest" + +breaks=() + +base_floor=$("$scripts/ios-floor.sh" "$base_manifest") +head_floor=$("$scripts/ios-floor.sh" Package.swift) +rm -f "$base_manifest" +if awk -v a="$base_floor" -v b="$head_floor" 'BEGIN { split(a, x, "."); split(b, y, "."); exit !(y[1] > x[1] || (y[1] == x[1] && y[2] > x[2])) }'; then + breaks+=("iOS deployment target raised from ${base_floor} to ${head_floor}") +fi + +sdk_path=$(xcrun --sdk iphonesimulator --show-sdk-path) +sdk_version=$(xcrun --sdk iphonesimulator --show-sdk-version) + +set +e +report=$(swift package \ + --triple "arm64-apple-ios${sdk_version}-simulator" \ + --sdk "$sdk_path" \ + diagnose-api-breaking-changes HEAD^1 2>&1) +status=$? +set -e +report=$(perl -pe 's/\e\[[0-9;]*m//g' <<< "$report") +echo "$report" + +verdict=$(sed -n '/breaking changes detected/,$p' <<< "$report") +if [ -z "$verdict" ]; then + verdict=$(tail -n 40 <<< "$report") +fi + +{ + echo "## API compatibility vs \`${BASE_REF:-base}\` (${base_short})" + echo + echo "iOS deployment target: ${base_floor} → ${head_floor}" + echo '```' + echo "$verdict" + echo '```' +} >> "$summary_file" + +if [ "$status" -ne 0 ]; then + if ! grep -q "API breakage" <<< "$report"; then + echo "::error::diagnose-api-breaking-changes failed without reporting a breakage. The base (${BASE_REF:-HEAD^1}) or this PR may not build for the iOS Simulator; see the log." + exit "$status" + fi + breaks+=("public API changed, see the report above") +fi + +if [ "${#breaks[@]}" -eq 0 ]; then + echo "No breaking changes." + exit 0 +fi + +for item in "${breaks[@]}"; do + echo "Breaking: ${item}" +done + +# Same shape the semantic PR title check parses: type, optional scope, "!: ". +if grep -Eq '^[A-Za-z0-9_]+(\(.*\))?!: ' <<< "${PR_TITLE:-}"; then + echo "Breaking change is declared in the PR title." + exit 0 +fi + +echo "::error::This PR makes a breaking change. Mark the title as breaking (e.g. \"feat!: …\") and describe it under \"Breaking Changes\" in the PR description." +exit 1 diff --git a/.github/scripts/ios-floor.sh b/.github/scripts/ios-floor.sh new file mode 100755 index 0000000..a36ea05 --- /dev/null +++ b/.github/scripts/ios-floor.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Prints the iOS deployment target a Package.swift declares, as major.minor: +# `.iOS(.v14)` -> 14.0, `.iOS(.v17_4)` -> 17.4. +# Usage: ios-floor.sh [path/to/Package.swift] +set -euo pipefail + +manifest=${1:-Package.swift} + +matches=$(grep -vE '^[[:space:]]*//' "$manifest" | grep -oE '\.iOS\(\.v[0-9]+(_[0-9]+)?\)' || true) +count=$(printf '%s' "$matches" | grep -c . || true) +if [ "$count" -ne 1 ]; then + echo "::error::Expected exactly one .iOS(.vNN) platform in ${manifest}, found ${count}" >&2 + exit 1 +fi + +sed -E 's/.*\.v([0-9]+)(_([0-9]+))?\)/\1.\3/; s/\.$/.0/' <<< "$matches" diff --git a/.github/scripts/typecheck-ios-floor.sh b/.github/scripts/typecheck-ios-floor.sh index 9554615..52ddb6d 100755 --- a/.github/scripts/typecheck-ios-floor.sh +++ b/.github/scripts/typecheck-ios-floor.sh @@ -1,39 +1,59 @@ #!/usr/bin/env bash # Type-checks the library at the iOS floor declared in Package.swift. # -# xcodebuild clamps the deployment target to the SDK's own minimum (iOS 15 on -# Xcode 27), so an iOS 15-only API inside an iOS 14 component builds cleanly -# there. swiftc accepts the real floor for a type-check, so run it directly, -# once as Release and once with DEBUG to cover the previews. +# xcodebuild raises the deployment target to the SDK's own minimum (iOS 15 on +# current Xcodes), so an API newer than the package's floor can build cleanly +# there. swiftc accepts the real floor for a type-check, so run it directly. +# +# The flags mirror what SwiftPM passes for this manifest. Package.swift shapes +# they do not model fail here loudly instead of checking the wrong thing. +# Xcode-generated asset symbols (ColorResource, ImageResource) do not exist here; +# the library resolves assets by name (Color.dash, DashIcon), not through them. set -euo pipefail -floor=$(grep -oE '\.iOS\(\.v[0-9]+' Package.swift | grep -oE '[0-9]+$' || true) -if [ -z "$floor" ]; then - echo "::error::Could not read the iOS deployment target from Package.swift" +scripts=$(cd "$(dirname "$0")" && pwd) + +manifest_body=$(grep -vE '^[[:space:]]*//' Package.swift) +for construct in '.package(' 'swiftSettings' 'swiftLanguageModes' 'exclude:' 'path:' ' sources:'; do + if grep -qF -- "$construct" <<< "$manifest_body"; then + echo "::error::Package.swift uses '${construct# }', which typecheck-ios-floor.sh does not model; update the script" >&2 + exit 1 + fi +done +targets=$(grep -cE '\.target\(' <<< "$manifest_body" || true) +if [ "$targets" -ne 1 ]; then + echo "::error::typecheck-ios-floor.sh expects one library target in Package.swift, found ${targets}" >&2 exit 1 fi +floor=$("$scripts/ios-floor.sh" Package.swift) + # SwiftPM generates Bundle.module for the resource bundle; swiftc alone does not. +# No EXIT trap for cleanup: on bash 3.2 it turns an unbound-variable abort into exit 0. stub_dir=$(mktemp -d) stub="$stub_dir/BundleModule.swift" printf 'import Foundation\nextension Foundation.Bundle { static let module = Bundle.main }\n' > "$stub" -trap 'rm -rf "$stub_dir"' EXIT sources=() while IFS= read -r -d '' file; do sources+=("$file") done < <(find Sources/DashUIKit -name '*.swift' -print0) +if [ "${#sources[@]}" -eq 0 ]; then + echo "::error::No Swift sources found under Sources/DashUIKit" >&2 + exit 1 +fi -for config in Release Debug; do - defines=() - if [ "$config" = Debug ]; then - defines=(-DDEBUG) - fi - echo "Type-checking ${#sources[@]} files for iOS ${floor} (${config})" - xcrun --sdk iphonesimulator swiftc -typecheck \ - -swift-version 6 \ - -target "arm64-apple-ios${floor}.0-simulator" \ - -module-name DashUIKit \ - "${defines[@]+"${defines[@]}"}" \ - "$stub" "${sources[@]}" -done +# DEBUG on: the previews are type-checked too, and no source has a release-only branch. +echo "Type-checking ${#sources[@]} files for iOS ${floor}" +xcrun --sdk iphonesimulator swiftc -typecheck \ + -swift-version 6 \ + -target "arm64-apple-ios${floor}-simulator" \ + -module-name DashUIKit \ + -package-name DashUIKit \ + -parse-as-library \ + -DSWIFT_PACKAGE \ + -DSWIFT_MODULE_RESOURCE_BUNDLE_AVAILABLE \ + -DDEBUG \ + "$stub" "${sources[@]}" + +rm -rf "$stub_dir" diff --git a/.github/workflows/api-check.yml b/.github/workflows/api-check.yml index ea36c98..6db4981 100644 --- a/.github/workflows/api-check.yml +++ b/.github/workflows/api-check.yml @@ -14,11 +14,13 @@ on: permissions: contents: read + pull-requests: read concurrency: group: api-check-${{ github.ref }} - # A body edit must not cancel a running check and leave the required check pending. - cancel-in-progress: false + # A new commit replaces the run for the old one; a body edit must not cancel a + # running check and leave the required check pending. + cancel-in-progress: ${{ github.event.action == 'synchronize' }} jobs: api: @@ -34,52 +36,16 @@ jobs: - uses: ./.github/actions/select-xcode - # A breaking change to the public API is allowed only when the PR title says so - # with a conventional-commit "!" (e.g. "feat!: …", "feat(scope)!: …"). - # - # Built for the iOS Simulator: on the macOS host every `#if canImport(UIKit)` - # file is compiled out, so their public API would never be compared. - # The baseline is HEAD^1, not origin/: on a re-run the base may have moved - # past the merge commit under test, and its new API would read as removed. - - name: Diagnose API breaking changes + # Allowed only when the PR title declares it with "!" (see the script header). + # The title is read live: a re-run reuses the original event payload, whose + # title may have changed since. + - name: Diagnose breaking changes env: BASE_REF: ${{ github.base_ref }} - PR_TITLE: ${{ github.event.pull_request.title }} + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} run: | - sdk_path=$(xcrun --sdk iphonesimulator --show-sdk-path) - sdk_version=$(xcrun --sdk iphonesimulator --show-sdk-version) - - set +e - report=$(swift package \ - --triple "arm64-apple-ios${sdk_version}-simulator" \ - --sdk "$sdk_path" \ - diagnose-api-breaking-changes HEAD^1 2>&1) - status=$? - set -e - echo "$report" - - { - echo "## API compatibility vs \`${BASE_REF}\` ($(git rev-parse --short HEAD^1))" - echo '```' - echo "$report" - echo '```' - } >> "$GITHUB_STEP_SUMMARY" - - if [ "$status" -eq 0 ]; then - echo "No breaking changes." - exit 0 - fi - - if ! grep -q "API breakage" <<< "$report"; then - echo "::error::diagnose-api-breaking-changes failed without reporting a breakage. The base (${BASE_REF}) or this PR may not build for the iOS Simulator; see the log." - exit "$status" - fi - - # Same shape the semantic PR title check parses: type, optional scope, "!: ". - if grep -Eq '^[A-Za-z0-9_]+(\(.*\))?!: ' <<< "$PR_TITLE"; then - echo "Breaking change is declared in the PR title." - exit 0 - fi - - echo "::error::This PR breaks the public API. Mark the title as breaking (e.g. \"feat!: …\") and describe it under \"Breaking Changes\" in the PR description." - exit 1 + PR_TITLE=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq .title) + export PR_TITLE + .github/scripts/api-check.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eaaf1d4..5dbb40e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,14 +31,20 @@ jobs: - uses: ./.github/actions/select-xcode # Release is what ships. The Debug build (previews included) happens in Test. + # arm64 only: the x86_64 simulator slice adds compile time and no coverage. - name: Build Release run: | xcodebuild -scheme DashUIKit \ -destination 'generic/platform=iOS Simulator' \ -configuration Release \ + ARCHS=arm64 \ CODE_SIGNING_ALLOWED=NO \ -quiet build + # The documented local build; also compiles the macOS/AppKit branches. + - name: Build for macOS + run: swift build + # xcodebuild raises the deployment target to the SDK's minimum, so it misses # APIs newer than the package's own floor; this checks against the floor. - name: Type-check at the iOS deployment target @@ -89,3 +95,7 @@ jobs: - name: Run actionlint run: ./actionlint -color + + # actionlint only checks inline run: blocks. + - name: Run shellcheck on scripts + run: shellcheck .github/scripts/*.sh From 586b752716732e040c1f082260e55794da2144a7 Mon Sep 17 00:00:00 2001 From: romchornyi <270373998+romchornyi@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:45:53 +0300 Subject: [PATCH 4/7] ci: third local review pass - API check: new DashIcon cases are not counted as breaking (every icon import adds them); the summary verdict also matches the singular "1 breaking change detected" and drops OSC-8 hyperlinks; build at the package's own platform instead of the SDK version; no temp file. - Simulator: compare runtimes by major.minor against the SDK and pick the newest runtime that actually has an iPhone. - Type-check: guard tools version, Package@swift-*.swift and release- or device-only branches it does not cover; clean up on failure too. - ios-floor.sh ignores trailing comments and names the base manifest. - Xcode selection moved into a shellchecked script. - Title check on action-semantic-pull-request v6.1.1 (Node 24). Co-Authored-By: Claude Opus 5.5 --- .github/actions/select-xcode/action.yml | 6 ++-- .github/scripts/api-check.sh | 26 ++++++++++------ .github/scripts/ios-floor.sh | 8 +++-- .github/scripts/pick-simulator.sh | 34 ++++++++++----------- .github/scripts/select-xcode.sh | 9 ++++++ .github/scripts/typecheck-ios-floor.sh | 33 +++++++++++++++----- .github/workflows/semantic-pull-request.yml | 2 +- 7 files changed, 76 insertions(+), 42 deletions(-) create mode 100755 .github/scripts/select-xcode.sh diff --git a/.github/actions/select-xcode/action.yml b/.github/actions/select-xcode/action.yml index 2c7d1a9..e978417 100644 --- a/.github/actions/select-xcode/action.yml +++ b/.github/actions/select-xcode/action.yml @@ -14,7 +14,5 @@ runs: shell: bash env: XCODE_VERSION: ${{ inputs.version }} - run: | - sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app" - xcodebuild -version - swift --version + # The logic lives in a script so the shellcheck step covers it. + run: '"${GITHUB_ACTION_PATH}/../../scripts/select-xcode.sh" "$XCODE_VERSION"' diff --git a/.github/scripts/api-check.sh b/.github/scripts/api-check.sh index fb7fd79..008e334 100755 --- a/.github/scripts/api-check.sh +++ b/.github/scripts/api-check.sh @@ -7,6 +7,8 @@ # diagnose-api-breaking-changes sees it when built for the iOS Simulator (on the # macOS host every `#if canImport(UIKit)` file is compiled out). Not checked: a # raised @available on a single declaration; the digester ignores availability. +# A case added to a DashIcon.* enum is not counted: icon groups grow with every +# icon import, and nothing is expected to switch over them exhaustively. # # Env: PR_TITLE, BASE_REF (for messages), GITHUB_STEP_SUMMARY (optional). set -euo pipefail @@ -15,32 +17,29 @@ scripts=$(cd "$(dirname "$0")" && pwd) summary_file=${GITHUB_STEP_SUMMARY:-/dev/null} base_short=$(git rev-parse --short HEAD^1) -base_manifest=$(mktemp) -git show HEAD^1:Package.swift > "$base_manifest" - breaks=() -base_floor=$("$scripts/ios-floor.sh" "$base_manifest") +base_floor=$(git show HEAD^1:Package.swift | "$scripts/ios-floor.sh" /dev/stdin "Package.swift at HEAD^1") head_floor=$("$scripts/ios-floor.sh" Package.swift) -rm -f "$base_manifest" if awk -v a="$base_floor" -v b="$head_floor" 'BEGIN { split(a, x, "."); split(b, y, "."); exit !(y[1] > x[1] || (y[1] == x[1] && y[2] > x[2])) }'; then breaks+=("iOS deployment target raised from ${base_floor} to ${head_floor}") fi sdk_path=$(xcrun --sdk iphonesimulator --show-sdk-path) -sdk_version=$(xcrun --sdk iphonesimulator --show-sdk-version) +# No OS version in the triple: SwiftPM then builds at the package's platform. set +e report=$(swift package \ - --triple "arm64-apple-ios${sdk_version}-simulator" \ + --triple arm64-apple-ios-simulator \ --sdk "$sdk_path" \ diagnose-api-breaking-changes HEAD^1 2>&1) status=$? set -e -report=$(perl -pe 's/\e\[[0-9;]*m//g' <<< "$report") +# Strip colour codes and OSC-8 hyperlinks. +report=$(perl -pe 's/\e\[[0-9;]*m//g; s/\e\]8;;.*?\e\\//g' <<< "$report") echo "$report" -verdict=$(sed -n '/breaking changes detected/,$p' <<< "$report") +verdict=$(sed -nE '/breaking changes? detected/,$p' <<< "$report") if [ -z "$verdict" ]; then verdict=$(tail -n 40 <<< "$report") fi @@ -59,7 +58,14 @@ if [ "$status" -ne 0 ]; then echo "::error::diagnose-api-breaking-changes failed without reporting a breakage. The base (${BASE_REF:-HEAD^1}) or this PR may not build for the iOS Simulator; see the log." exit "$status" fi - breaks+=("public API changed, see the report above") + counted=$(grep 'API breakage:' <<< "$report" \ + | grep -vE 'enumelement DashIcon\.[A-Za-z0-9_.]+ has been added as a new enum case' \ + | sort -u || true) + if [ -n "$counted" ]; then + breaks+=("public API changed, see the report above") + else + echo "Only new DashIcon cases were reported; not counted as breaking." + fi fi if [ "${#breaks[@]}" -eq 0 ]; then diff --git a/.github/scripts/ios-floor.sh b/.github/scripts/ios-floor.sh index a36ea05..7ff5958 100755 --- a/.github/scripts/ios-floor.sh +++ b/.github/scripts/ios-floor.sh @@ -1,15 +1,17 @@ #!/usr/bin/env bash # Prints the iOS deployment target a Package.swift declares, as major.minor: # `.iOS(.v14)` -> 14.0, `.iOS(.v17_4)` -> 17.4. -# Usage: ios-floor.sh [path/to/Package.swift] +# Usage: ios-floor.sh [path/to/Package.swift] [label for messages] set -euo pipefail manifest=${1:-Package.swift} +label=${2:-$manifest} -matches=$(grep -vE '^[[:space:]]*//' "$manifest" | grep -oE '\.iOS\(\.v[0-9]+(_[0-9]+)?\)' || true) +# Line comments are dropped; a /* */ block with a platform in it is not supported. +matches=$(sed 's#//.*##' "$manifest" | grep -oE '\.iOS\(\.v[0-9]+(_[0-9]+)?\)' || true) count=$(printf '%s' "$matches" | grep -c . || true) if [ "$count" -ne 1 ]; then - echo "::error::Expected exactly one .iOS(.vNN) platform in ${manifest}, found ${count}" >&2 + echo "::error::Expected exactly one .iOS(.vNN) platform in ${label}, found ${count}" >&2 exit 1 fi diff --git a/.github/scripts/pick-simulator.sh b/.github/scripts/pick-simulator.sh index 213819b..e7a8019 100755 --- a/.github/scripts/pick-simulator.sh +++ b/.github/scripts/pick-simulator.sh @@ -1,29 +1,29 @@ #!/usr/bin/env bash # Prints the UDID of an available iPhone simulator on the newest iOS runtime the -# selected Xcode can run (runtime version <= the iOS Simulator SDK version). +# selected Xcode can run: runtime major.minor <= the iOS Simulator SDK version +# (a patch runtime such as 26.6.1 runs on the 26.6 SDK), and that has an iPhone. set -euo pipefail sdk=$(xcrun --sdk iphonesimulator --show-sdk-version) +runtimes=$(xcrun simctl list runtimes available -j) +devices=$(xcrun simctl list devices available -j) -runtime=$(xcrun simctl list runtimes available -j | jq -r --arg sdk "$sdk" ' - def v: split(".") | map(tonumber); - [.runtimes[] | select(.platform == "iOS" and (.version | v) <= ($sdk | v))] - | sort_by(.version | v) | last | .identifier // empty') - -if [ -z "$runtime" ]; then - echo "::error::No iOS simulator runtime <= SDK ${sdk} on this machine" >&2 - xcrun simctl list runtimes >&2 - exit 1 -fi - -udid=$(xcrun simctl list devices available -j | jq -r --arg rt "$runtime" ' - [.devices[$rt][]? | select(.name | startswith("iPhone"))] | last | .udid // empty') +udid=$(jq -rn --arg sdk "$sdk" --argjson runtimes "$runtimes" --argjson devices "$devices" ' + def mm: split(".") | map(tonumber) | .[0:2]; + [$runtimes.runtimes[] + | select(.platform == "iOS" and (.version | mm) <= ($sdk | mm)) + | . as $rt + | [$devices.devices[$rt.identifier][]? | select(.name | startswith("iPhone"))] + | select(length > 0) + | {version: ($rt.version | split(".") | map(tonumber)), runtime: $rt.identifier, udid: (last | .udid)}] + | sort_by(.version) | last + | if . == null then empty else "\(.udid) \(.runtime)" end') if [ -z "$udid" ]; then - echo "::error::No available iPhone simulator for ${runtime}" >&2 + echo "::error::No iOS runtime <= SDK ${sdk} with an available iPhone simulator" >&2 xcrun simctl list devices available >&2 exit 1 fi -echo "Using ${runtime} ${udid}" >&2 -echo "$udid" +echo "Using ${udid#* } ${udid%% *}" >&2 +echo "${udid%% *}" diff --git a/.github/scripts/select-xcode.sh b/.github/scripts/select-xcode.sh new file mode 100755 index 0000000..3eb13fd --- /dev/null +++ b/.github/scripts/select-xcode.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Selects /Applications/Xcode_.app and prints the toolchain versions. +# Usage: select-xcode.sh +set -euo pipefail + +version=${1:?usage: select-xcode.sh } +sudo xcode-select -s "/Applications/Xcode_${version}.app" +xcodebuild -version +swift --version diff --git a/.github/scripts/typecheck-ios-floor.sh b/.github/scripts/typecheck-ios-floor.sh index 52ddb6d..760176d 100755 --- a/.github/scripts/typecheck-ios-floor.sh +++ b/.github/scripts/typecheck-ios-floor.sh @@ -13,6 +13,17 @@ set -euo pipefail scripts=$(cd "$(dirname "$0")" && pwd) +if ! head -n 1 Package.swift | grep -qE 'swift-tools-version: ?6'; then + echo "::error::typecheck-ios-floor.sh passes -swift-version 6; Package.swift is not on tools version 6" >&2 + exit 1 +fi +for extra in Package@swift-*.swift; do + if [ -e "$extra" ]; then + echo "::error::${extra} is not read by typecheck-ios-floor.sh or ios-floor.sh; update them" >&2 + exit 1 + fi +done + manifest_body=$(grep -vE '^[[:space:]]*//' Package.swift) for construct in '.package(' 'swiftSettings' 'swiftLanguageModes' 'exclude:' 'path:' ' sources:'; do if grep -qF -- "$construct" <<< "$manifest_body"; then @@ -28,12 +39,6 @@ fi floor=$("$scripts/ios-floor.sh" Package.swift) -# SwiftPM generates Bundle.module for the resource bundle; swiftc alone does not. -# No EXIT trap for cleanup: on bash 3.2 it turns an unbound-variable abort into exit 0. -stub_dir=$(mktemp -d) -stub="$stub_dir/BundleModule.swift" -printf 'import Foundation\nextension Foundation.Bundle { static let module = Bundle.main }\n' > "$stub" - sources=() while IFS= read -r -d '' file; do sources+=("$file") @@ -43,8 +48,21 @@ if [ "${#sources[@]}" -eq 0 ]; then exit 1 fi +# Only the DEBUG, simulator branch is checked; code that exists only elsewhere is not. +if grep -nE '#if[[:space:]]*!DEBUG|targetEnvironment' "${sources[@]}"; then + echo "::error::Release-only or device-only branches are not covered by typecheck-ios-floor.sh; extend it" >&2 + exit 1 +fi + +# SwiftPM generates Bundle.module for the resource bundle; swiftc alone does not. +# No EXIT trap for cleanup: on bash 3.2 it turns an unbound-variable abort into exit 0. +stub_dir=$(mktemp -d) +stub="$stub_dir/BundleModule.swift" +printf 'import Foundation\nextension Foundation.Bundle { static let module = Bundle.main }\n' > "$stub" + # DEBUG on: the previews are type-checked too, and no source has a release-only branch. echo "Type-checking ${#sources[@]} files for iOS ${floor}" +status=0 xcrun --sdk iphonesimulator swiftc -typecheck \ -swift-version 6 \ -target "arm64-apple-ios${floor}-simulator" \ @@ -54,6 +72,7 @@ xcrun --sdk iphonesimulator swiftc -typecheck \ -DSWIFT_PACKAGE \ -DSWIFT_MODULE_RESOURCE_BUNDLE_AVAILABLE \ -DDEBUG \ - "$stub" "${sources[@]}" + "$stub" "${sources[@]}" || status=$? rm -rf "$stub_dir" +exit "$status" diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml index 879242e..7350c4f 100644 --- a/.github/workflows/semantic-pull-request.yml +++ b/.github/workflows/semantic-pull-request.yml @@ -16,7 +16,7 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3 + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: From b94a3187e4fbb14fe9d0409ba55fa2fe7ef442bf Mon Sep 17 00:00:00 2001 From: romchornyi <270373998+romchornyi@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:56:37 +0300 Subject: [PATCH 5/7] ci: drop the iOS 14 type-check, count a tools-version bump as breaking The swiftc type-check at iOS 14 checked a configuration no toolchain that reads this manifest can build: swift-tools-version 6.3 needs Xcode 26.4+, which raises the target to iOS 15. The only consumer is on iOS 18 and #15 moves the floor there, after which the regular build catches newer APIs. - API check: raising swift-tools-version is a break too; the digester output streams to the log while it runs. - pick-simulator.sh reads simctl JSON through files, not arguments. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/api-check.sh | 38 ++++++++++--- .github/scripts/pick-simulator.sh | 11 ++-- .github/scripts/typecheck-ios-floor.sh | 78 -------------------------- .github/workflows/ci.yml | 5 -- 4 files changed, 35 insertions(+), 97 deletions(-) delete mode 100755 .github/scripts/typecheck-ios-floor.sh diff --git a/.github/scripts/api-check.sh b/.github/scripts/api-check.sh index 008e334..4762bb5 100755 --- a/.github/scripts/api-check.sh +++ b/.github/scripts/api-check.sh @@ -3,7 +3,8 @@ # pull_request run, the merge commit and the base it was merged onto). A break is # declared with a conventional-commit "!" in the PR title ("feat!: …"). # -# Checked: the iOS deployment target in Package.swift, and the public API as +# Checked: the iOS deployment target and swift-tools-version in Package.swift +# (raising either drops consumers), and the public API as # diagnose-api-breaking-changes sees it when built for the iOS Simulator (on the # macOS host every `#if canImport(UIKit)` file is compiled out). Not checked: a # raised @available on a single declaration; the digester ignores availability. @@ -21,23 +22,42 @@ breaks=() base_floor=$(git show HEAD^1:Package.swift | "$scripts/ios-floor.sh" /dev/stdin "Package.swift at HEAD^1") head_floor=$("$scripts/ios-floor.sh" Package.swift) -if awk -v a="$base_floor" -v b="$head_floor" 'BEGIN { split(a, x, "."); split(b, y, "."); exit !(y[1] > x[1] || (y[1] == x[1] && y[2] > x[2])) }'; then +version_gt() { + awk -v a="$1" -v b="$2" 'BEGIN { split(a, x, "."); split(b, y, "."); for (i = 1; i <= 3; i++) { if (y[i] + 0 != x[i] + 0) exit !(y[i] + 0 > x[i] + 0) } exit 1 }' +} +if version_gt "$base_floor" "$head_floor"; then breaks+=("iOS deployment target raised from ${base_floor} to ${head_floor}") fi +tools_version() { + head -n 1 | grep -oE 'swift-tools-version: ?[0-9.]+' | grep -oE '[0-9.]+$' || true +} +base_tools=$(git show HEAD^1:Package.swift | tools_version) +head_tools=$(tools_version < Package.swift) +if [ -z "$base_tools" ] || [ -z "$head_tools" ]; then + echo "::error::Could not read swift-tools-version from Package.swift (base '${base_tools}', head '${head_tools}')" + exit 1 +fi +if version_gt "$base_tools" "$head_tools"; then + breaks+=("swift-tools-version raised from ${base_tools} to ${head_tools}") +fi + sdk_path=$(xcrun --sdk iphonesimulator --show-sdk-path) -# No OS version in the triple: SwiftPM then builds at the package's platform. +# No OS version in the triple: SwiftPM picks the deployment target (the package +# floor, raised to the SDK's minimum where the floor is older). +# Streamed through tee so a hung build still leaves a log. +log=$(mktemp) set +e -report=$(swift package \ +swift package \ --triple arm64-apple-ios-simulator \ --sdk "$sdk_path" \ - diagnose-api-breaking-changes HEAD^1 2>&1) -status=$? + diagnose-api-breaking-changes HEAD^1 2>&1 | tee "$log" +status=${PIPESTATUS[0]} set -e # Strip colour codes and OSC-8 hyperlinks. -report=$(perl -pe 's/\e\[[0-9;]*m//g; s/\e\]8;;.*?\e\\//g' <<< "$report") -echo "$report" +report=$(perl -pe 's/\e\[[0-9;]*m//g; s/\e\]8;;.*?\e\\//g' "$log") +rm -f "$log" verdict=$(sed -nE '/breaking changes? detected/,$p' <<< "$report") if [ -z "$verdict" ]; then @@ -47,7 +67,7 @@ fi { echo "## API compatibility vs \`${BASE_REF:-base}\` (${base_short})" echo - echo "iOS deployment target: ${base_floor} → ${head_floor}" + echo "iOS deployment target: ${base_floor} → ${head_floor}, swift-tools-version: ${base_tools} → ${head_tools}" echo '```' echo "$verdict" echo '```' diff --git a/.github/scripts/pick-simulator.sh b/.github/scripts/pick-simulator.sh index e7a8019..ca35ed3 100755 --- a/.github/scripts/pick-simulator.sh +++ b/.github/scripts/pick-simulator.sh @@ -5,15 +5,16 @@ set -euo pipefail sdk=$(xcrun --sdk iphonesimulator --show-sdk-version) -runtimes=$(xcrun simctl list runtimes available -j) -devices=$(xcrun simctl list devices available -j) -udid=$(jq -rn --arg sdk "$sdk" --argjson runtimes "$runtimes" --argjson devices "$devices" ' +# Through files, not --argjson: the device list can outgrow the argument limit. +udid=$(jq -rn --arg sdk "$sdk" \ + --slurpfile runtimes <(xcrun simctl list runtimes available -j) \ + --slurpfile devices <(xcrun simctl list devices available -j) ' def mm: split(".") | map(tonumber) | .[0:2]; - [$runtimes.runtimes[] + [$runtimes[0].runtimes[] | select(.platform == "iOS" and (.version | mm) <= ($sdk | mm)) | . as $rt - | [$devices.devices[$rt.identifier][]? | select(.name | startswith("iPhone"))] + | [$devices[0].devices[$rt.identifier][]? | select(.name | startswith("iPhone"))] | select(length > 0) | {version: ($rt.version | split(".") | map(tonumber)), runtime: $rt.identifier, udid: (last | .udid)}] | sort_by(.version) | last diff --git a/.github/scripts/typecheck-ios-floor.sh b/.github/scripts/typecheck-ios-floor.sh deleted file mode 100755 index 760176d..0000000 --- a/.github/scripts/typecheck-ios-floor.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env bash -# Type-checks the library at the iOS floor declared in Package.swift. -# -# xcodebuild raises the deployment target to the SDK's own minimum (iOS 15 on -# current Xcodes), so an API newer than the package's floor can build cleanly -# there. swiftc accepts the real floor for a type-check, so run it directly. -# -# The flags mirror what SwiftPM passes for this manifest. Package.swift shapes -# they do not model fail here loudly instead of checking the wrong thing. -# Xcode-generated asset symbols (ColorResource, ImageResource) do not exist here; -# the library resolves assets by name (Color.dash, DashIcon), not through them. -set -euo pipefail - -scripts=$(cd "$(dirname "$0")" && pwd) - -if ! head -n 1 Package.swift | grep -qE 'swift-tools-version: ?6'; then - echo "::error::typecheck-ios-floor.sh passes -swift-version 6; Package.swift is not on tools version 6" >&2 - exit 1 -fi -for extra in Package@swift-*.swift; do - if [ -e "$extra" ]; then - echo "::error::${extra} is not read by typecheck-ios-floor.sh or ios-floor.sh; update them" >&2 - exit 1 - fi -done - -manifest_body=$(grep -vE '^[[:space:]]*//' Package.swift) -for construct in '.package(' 'swiftSettings' 'swiftLanguageModes' 'exclude:' 'path:' ' sources:'; do - if grep -qF -- "$construct" <<< "$manifest_body"; then - echo "::error::Package.swift uses '${construct# }', which typecheck-ios-floor.sh does not model; update the script" >&2 - exit 1 - fi -done -targets=$(grep -cE '\.target\(' <<< "$manifest_body" || true) -if [ "$targets" -ne 1 ]; then - echo "::error::typecheck-ios-floor.sh expects one library target in Package.swift, found ${targets}" >&2 - exit 1 -fi - -floor=$("$scripts/ios-floor.sh" Package.swift) - -sources=() -while IFS= read -r -d '' file; do - sources+=("$file") -done < <(find Sources/DashUIKit -name '*.swift' -print0) -if [ "${#sources[@]}" -eq 0 ]; then - echo "::error::No Swift sources found under Sources/DashUIKit" >&2 - exit 1 -fi - -# Only the DEBUG, simulator branch is checked; code that exists only elsewhere is not. -if grep -nE '#if[[:space:]]*!DEBUG|targetEnvironment' "${sources[@]}"; then - echo "::error::Release-only or device-only branches are not covered by typecheck-ios-floor.sh; extend it" >&2 - exit 1 -fi - -# SwiftPM generates Bundle.module for the resource bundle; swiftc alone does not. -# No EXIT trap for cleanup: on bash 3.2 it turns an unbound-variable abort into exit 0. -stub_dir=$(mktemp -d) -stub="$stub_dir/BundleModule.swift" -printf 'import Foundation\nextension Foundation.Bundle { static let module = Bundle.main }\n' > "$stub" - -# DEBUG on: the previews are type-checked too, and no source has a release-only branch. -echo "Type-checking ${#sources[@]} files for iOS ${floor}" -status=0 -xcrun --sdk iphonesimulator swiftc -typecheck \ - -swift-version 6 \ - -target "arm64-apple-ios${floor}-simulator" \ - -module-name DashUIKit \ - -package-name DashUIKit \ - -parse-as-library \ - -DSWIFT_PACKAGE \ - -DSWIFT_MODULE_RESOURCE_BUNDLE_AVAILABLE \ - -DDEBUG \ - "$stub" "${sources[@]}" || status=$? - -rm -rf "$stub_dir" -exit "$status" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5dbb40e..09126a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,11 +45,6 @@ jobs: - name: Build for macOS run: swift build - # xcodebuild raises the deployment target to the SDK's minimum, so it misses - # APIs newer than the package's own floor; this checks against the floor. - - name: Type-check at the iOS deployment target - run: .github/scripts/typecheck-ios-floor.sh - test: name: Test runs-on: macos-26 From 83099125efc1be3f06eb41185c23fc20f165d6c2 Mon Sep 17 00:00:00 2001 From: romchornyi <270373998+romchornyi@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:05:11 +0300 Subject: [PATCH 6/7] ci: fifth local review pass - Build the Release configuration for devices, the slice that ships. - API check: skip closed PRs (`edited` fires there too), keep GH_TOKEN away from the PR build, accept any whitespace/case in the swift-tools-version header, clearer version_raised helper. - pick-simulator.sh: simctl failures surface before jq. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/api-check.sh | 12 ++++++------ .github/scripts/pick-simulator.sh | 9 +++++++-- .github/workflows/api-check.yml | 5 +++++ .github/workflows/ci.yml | 7 +++---- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/.github/scripts/api-check.sh b/.github/scripts/api-check.sh index 4762bb5..363ef90 100755 --- a/.github/scripts/api-check.sh +++ b/.github/scripts/api-check.sh @@ -22,15 +22,16 @@ breaks=() base_floor=$(git show HEAD^1:Package.swift | "$scripts/ios-floor.sh" /dev/stdin "Package.swift at HEAD^1") head_floor=$("$scripts/ios-floor.sh" Package.swift) -version_gt() { +# version_raised OLD NEW: true when NEW is higher than OLD. +version_raised() { awk -v a="$1" -v b="$2" 'BEGIN { split(a, x, "."); split(b, y, "."); for (i = 1; i <= 3; i++) { if (y[i] + 0 != x[i] + 0) exit !(y[i] + 0 > x[i] + 0) } exit 1 }' } -if version_gt "$base_floor" "$head_floor"; then +if version_raised "$base_floor" "$head_floor"; then breaks+=("iOS deployment target raised from ${base_floor} to ${head_floor}") fi tools_version() { - head -n 1 | grep -oE 'swift-tools-version: ?[0-9.]+' | grep -oE '[0-9.]+$' || true + head -n 1 | grep -ioE 'swift-tools-version:[[:space:]]*[0-9.]+' | grep -oE '[0-9.]+$' || true } base_tools=$(git show HEAD^1:Package.swift | tools_version) head_tools=$(tools_version < Package.swift) @@ -38,7 +39,7 @@ if [ -z "$base_tools" ] || [ -z "$head_tools" ]; then echo "::error::Could not read swift-tools-version from Package.swift (base '${base_tools}', head '${head_tools}')" exit 1 fi -if version_gt "$base_tools" "$head_tools"; then +if version_raised "$base_tools" "$head_tools"; then breaks+=("swift-tools-version raised from ${base_tools} to ${head_tools}") fi @@ -79,8 +80,7 @@ if [ "$status" -ne 0 ]; then exit "$status" fi counted=$(grep 'API breakage:' <<< "$report" \ - | grep -vE 'enumelement DashIcon\.[A-Za-z0-9_.]+ has been added as a new enum case' \ - | sort -u || true) + | grep -vE 'enumelement DashIcon\.[A-Za-z0-9_.]+ has been added as a new enum case' || true) if [ -n "$counted" ]; then breaks+=("public API changed, see the report above") else diff --git a/.github/scripts/pick-simulator.sh b/.github/scripts/pick-simulator.sh index ca35ed3..d93d939 100755 --- a/.github/scripts/pick-simulator.sh +++ b/.github/scripts/pick-simulator.sh @@ -7,9 +7,13 @@ set -euo pipefail sdk=$(xcrun --sdk iphonesimulator --show-sdk-version) # Through files, not --argjson: the device list can outgrow the argument limit. +work=$(mktemp -d) +xcrun simctl list runtimes available -j > "$work/runtimes.json" +xcrun simctl list devices available -j > "$work/devices.json" + udid=$(jq -rn --arg sdk "$sdk" \ - --slurpfile runtimes <(xcrun simctl list runtimes available -j) \ - --slurpfile devices <(xcrun simctl list devices available -j) ' + --slurpfile runtimes "$work/runtimes.json" \ + --slurpfile devices "$work/devices.json" ' def mm: split(".") | map(tonumber) | .[0:2]; [$runtimes[0].runtimes[] | select(.platform == "iOS" and (.version | mm) <= ($sdk | mm)) @@ -19,6 +23,7 @@ udid=$(jq -rn --arg sdk "$sdk" \ | {version: ($rt.version | split(".") | map(tonumber)), runtime: $rt.identifier, udid: (last | .udid)}] | sort_by(.version) | last | if . == null then empty else "\(.udid) \(.runtime)" end') +rm -rf "$work" if [ -z "$udid" ]; then echo "::error::No iOS runtime <= SDK ${sdk} with an available iPhone simulator" >&2 diff --git a/.github/workflows/api-check.yml b/.github/workflows/api-check.yml index 6db4981..4745add 100644 --- a/.github/workflows/api-check.yml +++ b/.github/workflows/api-check.yml @@ -25,6 +25,9 @@ concurrency: jobs: api: name: API compatibility + # `edited` also fires on closed PRs, whose merge ref is gone or stale. Nothing + # earlier on a closed PR can be hidden by this skip. + if: github.event.pull_request.state == 'open' runs-on: macos-26 timeout-minutes: 30 steps: @@ -48,4 +51,6 @@ jobs: run: | PR_TITLE=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq .title) export PR_TITLE + # The build below runs PR code (manifest, macros); it needs no token. + unset GH_TOKEN .github/scripts/api-check.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09126a1..5fd2a97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,14 +30,13 @@ jobs: - uses: ./.github/actions/select-xcode - # Release is what ships. The Debug build (previews included) happens in Test. - # arm64 only: the x86_64 simulator slice adds compile time and no coverage. + # Release for devices is what ships. The Debug simulator build (previews + # included) happens in Test. - name: Build Release run: | xcodebuild -scheme DashUIKit \ - -destination 'generic/platform=iOS Simulator' \ + -destination 'generic/platform=iOS' \ -configuration Release \ - ARCHS=arm64 \ CODE_SIGNING_ALLOWED=NO \ -quiet build From 6469cc878d85447d72396b086bf5dc22ef069424 Mon Sep 17 00:00:00 2001 From: romchornyi <270373998+romchornyi@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:09:46 +0300 Subject: [PATCH 7/7] ci: clearer check names, swiftbuild for the API check - Job names say what they check: "Build: iOS device + macOS", "Unit tests (iOS Simulator)", "Lint CI config (actionlint, shellcheck)", and "Breaking changes / Public API, iOS floor, tools version". - The API check failed on the runner (Swift 6.3) with "Unable to parse empty data": the native build system, the default there, cannot feed the digester for an iOS cross-build. Select swiftbuild explicitly. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/api-check.sh | 5 ++++- .github/workflows/api-check.yml | 4 ++-- .github/workflows/ci.yml | 6 +++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/scripts/api-check.sh b/.github/scripts/api-check.sh index 363ef90..82fd8b1 100755 --- a/.github/scripts/api-check.sh +++ b/.github/scripts/api-check.sh @@ -48,9 +48,12 @@ sdk_path=$(xcrun --sdk iphonesimulator --show-sdk-path) # No OS version in the triple: SwiftPM picks the deployment target (the package # floor, raised to the SDK's minimum where the floor is older). # Streamed through tee so a hung build still leaves a log. +# swiftbuild explicitly: with the native build system (the default on Swift 6.3) +# an iOS cross-build makes the digester fail with "Unable to parse empty data". log=$(mktemp) set +e swift package \ + --build-system swiftbuild \ --triple arm64-apple-ios-simulator \ --sdk "$sdk_path" \ diagnose-api-breaking-changes HEAD^1 2>&1 | tee "$log" @@ -66,7 +69,7 @@ if [ -z "$verdict" ]; then fi { - echo "## API compatibility vs \`${BASE_REF:-base}\` (${base_short})" + echo "## Breaking changes vs \`${BASE_REF:-base}\` (${base_short})" echo echo "iOS deployment target: ${base_floor} → ${head_floor}, swift-tools-version: ${base_tools} → ${head_tools}" echo '```' diff --git a/.github/workflows/api-check.yml b/.github/workflows/api-check.yml index 4745add..7ba3845 100644 --- a/.github/workflows/api-check.yml +++ b/.github/workflows/api-check.yml @@ -1,4 +1,4 @@ -name: API check +name: Breaking changes # Separate from CI so that editing the PR title (adding or removing "!") re-runs only # this check, not the build and tests. `edited` also fires on body edits (CodeRabbit @@ -24,7 +24,7 @@ concurrency: jobs: api: - name: API compatibility + name: Public API, iOS floor, tools version # `edited` also fires on closed PRs, whose merge ref is gone or stale. Nothing # earlier on a closed PR can be hidden by this skip. if: github.event.pull_request.state == 'open' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fd2a97..1a03887 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ concurrency: jobs: build: - name: Build (iOS) + name: "Build: iOS device + macOS" runs-on: macos-26 timeout-minutes: 30 steps: @@ -45,7 +45,7 @@ jobs: run: swift build test: - name: Test + name: Unit tests (iOS Simulator) runs-on: macos-26 timeout-minutes: 30 steps: @@ -68,7 +68,7 @@ jobs: test actionlint: - name: Lint workflows + name: Lint CI config (actionlint, shellcheck) runs-on: ubuntu-latest timeout-minutes: 10 env: