Add baseline-gated Periphery dead-code check to CI

Runs Periphery after the existing Xcode build in pr_build_test, reusing that
build's index store (--index-store-path + --skip-build) so it adds no second
build. Scans the Xcode project (.periphery.yml) so the Homebrew app counts as a
consumer of the SwiftPM modules — reporting unused code across the whole program,
including dead public API, which a package-only scan cannot.

Gating is baseline-driven (--strict --baseline): a PR fails only on dead code not
already in .periphery-baseline.json, so the existing tail is grandfathered and
only newly introduced dead code blocks a merge. Implicit-usage classes are
retained in config (SwiftUI previews, Codable properties, assign-only/Hashable
key structs) to keep false positives near zero.

The baseline can't be generated in the agent sandbox (needs a working xcodebuild
app build), so the CI step self-seeds: when .periphery-baseline.json is absent it
writes one and uploads it as the 'periphery-baseline' artifact without gating.
Commit that artifact to activate the gate. Periphery pinned in Mintfile (3.7.4);
usage documented in AGENTS.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Graeme Arthur
2026-07-23 21:57:03 +10:00
co-authored by Claude Opus 4.8
parent 3f2d5fb25b
commit f8c2b6c9a5
4 changed files with 80 additions and 1 deletions
+54 -1
View File
@@ -16,6 +16,9 @@ on:
- "Package.resolved"
- "Tools/BrewUILint/**"
- "scripts/test"
- "Mintfile"
- ".periphery.yml"
- ".periphery-baseline.json"
- ".github/workflows/pr_build_test.yml"
pull_request:
types: [opened, reopened, synchronize]
@@ -31,6 +34,9 @@ on:
- "Package.resolved"
- "Tools/BrewUILint/**"
- "scripts/test"
- "Mintfile"
- ".periphery.yml"
- ".periphery-baseline.json"
- ".github/workflows/pr_build_test.yml"
permissions:
@@ -44,7 +50,9 @@ jobs:
build-and-test:
name: Build + unit tests
runs-on: macos-26
timeout-minutes: 20
timeout-minutes: 25
env:
MINT_PATH: ${{ github.workspace }}/mint
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -58,6 +66,7 @@ jobs:
xcodebuild test \
-project Homebrew.xcodeproj \
-scheme Brew-Unit \
-derivedDataPath DerivedData \
-destination "platform=macOS" \
-skipPackagePluginValidation \
-skipMacroValidation \
@@ -97,6 +106,50 @@ jobs:
set -o pipefail
xcrun swift test --package-path Tools/BrewUILint | tee swift-test-brewuilint.log
- name: Cache Mint packages
id: mint-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ github.workspace }}/mint
key: ${{ runner.os }}-mint-${{ hashFiles('**/Mintfile') }}
restore-keys: |
${{ runner.os }}-mint-
- name: Install Mintfile packages
if: steps.mint-cache.outputs.cache-hit != 'true'
run: mint bootstrap --mintfile Mintfile
- name: Periphery dead-code scan
run: |
set -o pipefail
# Reuse the index store produced by the xcodebuild test step (no second build).
index="DerivedData/Index.noindex/DataStore"
if [ ! -d "$index" ]; then
index="$(find DerivedData -type d -name DataStore -path '*Index*' | head -n 1)"
fi
echo "Using index store: $index"
if [ -f .periphery-baseline.json ]; then
mint run periphery scan \
--index-store-path "$index" --skip-build \
--baseline .periphery-baseline.json --strict \
--relative-results --format github-actions
else
echo "::warning::No .periphery-baseline.json committed yet — writing a seed baseline (this run does NOT gate). Download the 'periphery-baseline' artifact from this run, commit it as .periphery-baseline.json, and subsequent PRs will fail only on newly introduced dead code."
mint run periphery scan \
--index-store-path "$index" --skip-build \
--write-baseline .periphery-baseline.json \
--relative-results
fi
- name: Upload periphery seed baseline
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: periphery-baseline
path: .periphery-baseline.json
if-no-files-found: ignore
retention-days: 7
- name: Upload logs on failure
if: failure() || cancelled()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+16
View File
@@ -0,0 +1,16 @@
# Periphery — dead-code (unused declaration) analysis.
# Run in CI by .github/workflows/pr_build_test.yml; see AGENTS.md for the workflow.
#
# We scan the Xcode PROJECT (not the bare SwiftPM package) so the Homebrew app is included as a
# consumer of the SwiftPM library modules. That means `retain_public` stays FALSE: public API that
# not even the app uses is genuinely dead and should be reported. (Scanning the package alone would
# force retain_public and blind us to dead public API, while also flagging the whole app-only UI tree
# as a false positive.)
project: Homebrew.xcodeproj
schemes:
- Brew-Unit # host-app unit-test scheme — builds the app + every SPM module + unit tests
# Retain the declaration classes that are used implicitly (Periphery can't see these as "reads"):
retain_swift_ui_previews: true # `#Preview` bodies aren't indexed as usage
retain_codable_properties: true # decoded/encoded stored properties on Codable types
retain_assign_only_properties: true # e.g. Hashable/Equatable key structs read only via synthesis
+9
View File
@@ -100,6 +100,15 @@ The pre-commit hook formats and lints **staged** Swift files (SwiftFormat/SwiftL
If a test failure surfaces a real regression that's out of scope for the current turn, surface it to the user rather than silently skipping it — never paper over a red test with `.disabled` or `--filter` exclusions without flagging.
### Dead-code analysis (Periphery)
`.github/workflows/pr_build_test.yml` runs [Periphery](https://github.com/peripheryapp/periphery) (pinned in `Mintfile`) after the Xcode build, reusing that build's index store (`--index-store-path DerivedData/Index.noindex/DataStore --skip-build`) so it adds no second build. It scans the **Xcode project** (config in `.periphery.yml`) so the `Homebrew/` app counts as a consumer of the SwiftPM modules — this reports unused code across the whole program, including dead `public` API, which a package-only scan cannot.
The check is **baseline-gated**: it fails a PR only on dead code **not** already recorded in `.periphery-baseline.json` (via `--strict --baseline`). This grandfathers the existing tail so only newly introduced dead code blocks a merge.
- **Seeding / regenerating the baseline:** it can't be generated in the agent sandbox (needs a working `xcodebuild` app build). If `.periphery-baseline.json` is absent, the CI step writes one and uploads it as the `periphery-baseline` artifact without gating — download it, commit it, and the gate activates. To refresh it intentionally (after a deliberate change to the unused set), regenerate on a machine/CI where the app builds: build `Brew-Unit` with `-derivedDataPath DerivedData`, then `mint run periphery scan --index-store-path DerivedData/Index.noindex/DataStore --skip-build --write-baseline .periphery-baseline.json`.
- **Known-implicit usage is already retained** via `.periphery.yml` (`retain_swift_ui_previews`, `retain_codable_properties`, `retain_assign_only_properties`). For a genuine one-off that Periphery still can't see, annotate the declaration with `// periphery:ignore` (or `// periphery:ignore:all` for a type and its members) rather than widening the baseline.
---
## What Lives Where
+1
View File
@@ -1,2 +1,3 @@
nicklockwood/SwiftFormat@0.61.0
realm/SwiftLint@0.63.2
peripheryapp/periphery@3.7.4