diff --git a/.github/actions/select-xcode/action.yml b/.github/actions/select-xcode/action.yml new file mode 100644 index 0000000..e978417 --- /dev/null +++ b/.github/actions/select-xcode/action.yml @@ -0,0 +1,18 @@ +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 }} + # 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/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/scripts/api-check.sh b/.github/scripts/api-check.sh new file mode 100755 index 0000000..82fd8b1 --- /dev/null +++ b/.github/scripts/api-check.sh @@ -0,0 +1,110 @@ +#!/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 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. +# 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 + +scripts=$(cd "$(dirname "$0")" && pwd) +summary_file=${GITHUB_STEP_SUMMARY:-/dev/null} +base_short=$(git rev-parse --short HEAD^1) + +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_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_raised "$base_floor" "$head_floor"; then + breaks+=("iOS deployment target raised from ${base_floor} to ${head_floor}") +fi + +tools_version() { + 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) +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_raised "$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 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" +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' "$log") +rm -f "$log" + +verdict=$(sed -nE '/breaking changes? detected/,$p' <<< "$report") +if [ -z "$verdict" ]; then + verdict=$(tail -n 40 <<< "$report") +fi + +{ + 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 '```' + 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 + counted=$(grep 'API breakage:' <<< "$report" \ + | 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 + echo "Only new DashIcon cases were reported; not counted as breaking." + fi +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..7ff5958 --- /dev/null +++ b/.github/scripts/ios-floor.sh @@ -0,0 +1,18 @@ +#!/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] [label for messages] +set -euo pipefail + +manifest=${1:-Package.swift} +label=${2:-$manifest} + +# 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 ${label}, found ${count}" >&2 + exit 1 +fi + +sed -E 's/.*\.v([0-9]+)(_([0-9]+))?\)/\1.\3/; s/\.$/.0/' <<< "$matches" diff --git a/.github/scripts/pick-simulator.sh b/.github/scripts/pick-simulator.sh new file mode 100755 index 0000000..d93d939 --- /dev/null +++ b/.github/scripts/pick-simulator.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Prints the UDID of an available iPhone simulator on the newest iOS runtime the +# 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) + +# 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 "$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)) + | . as $rt + | [$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 + | 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 + xcrun simctl list devices available >&2 + exit 1 +fi + +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/workflows/api-check.yml b/.github/workflows/api-check.yml new file mode 100644 index 0000000..7ba3845 --- /dev/null +++ b/.github/workflows/api-check.yml @@ -0,0 +1,56 @@ +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 +# 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: + - opened + - edited + - synchronize + - reopened + +permissions: + contents: read + pull-requests: read + +concurrency: + group: api-check-${{ github.ref }} + # 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: + 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' + runs-on: macos-26 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The PR merge commit and its first parent, the base it was merged onto. + fetch-depth: 2 + persist-credentials: false + + - uses: ./.github/actions/select-xcode + + # 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 }} + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + 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 new file mode 100644 index 0000000..1a03887 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,95 @@ +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 + +permissions: + contents: read + +concurrency: + # 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' }} + +jobs: + build: + name: "Build: iOS device + macOS" + runs-on: macos-26 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: ./.github/actions/select-xcode + + # 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' \ + -configuration Release \ + CODE_SIGNING_ALLOWED=NO \ + -quiet build + + # The documented local build; also compiles the macOS/AppKit branches. + - name: Build for macOS + run: swift build + + test: + name: Unit tests (iOS Simulator) + runs-on: macos-26 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - 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=${udid}" \ + -collect-test-diagnostics never \ + CODE_SIGNING_ALLOWED=NO \ + test + + actionlint: + name: Lint CI config (actionlint, shellcheck) + 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. Dependabot cannot see this pin: bump it by hand. + - name: Install actionlint + run: | + 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 + + # actionlint only checks inline run: blocks. + - name: Run shellcheck on scripts + run: shellcheck .github/scripts/*.sh diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml index f1f480a..7350c4f 100644 --- a/.github/workflows/semantic-pull-request.yml +++ b/.github/workflows/semantic-pull-request.yml @@ -7,12 +7,16 @@ 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 runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@v5 + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: