Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/actions/select-xcode/action.yml
Original file line number Diff line number Diff line change
@@ -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_<version>.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"'
8 changes: 8 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
commit-message:
prefix: ci
110 changes: 110 additions & 0 deletions .github/scripts/api-check.sh
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions .github/scripts/ios-floor.sh
Original file line number Diff line number Diff line change
@@ -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"
35 changes: 35 additions & 0 deletions .github/scripts/pick-simulator.sh
Original file line number Diff line number Diff line change
@@ -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%% *}"
9 changes: 9 additions & 0 deletions .github/scripts/select-xcode.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Selects /Applications/Xcode_<version>.app and prints the toolchain versions.
# Usage: select-xcode.sh <version>
set -euo pipefail

version=${1:?usage: select-xcode.sh <version>}
sudo xcode-select -s "/Applications/Xcode_${version}.app"
xcodebuild -version
swift --version
56 changes: 56 additions & 0 deletions .github/workflows/api-check.yml
Original file line number Diff line number Diff line change
@@ -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
95 changes: 95 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 5 additions & 1 deletion .github/workflows/semantic-pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading