Skip to main content

Swift

Swift is the first non-JavaScript language module. doctor and fix detect a Swift repo from Package.swift and layer Swift-specific checks on top of the language-agnostic ones (CI, CodeQL, Dependabot, GitHub repo settings).

The standard here is the one swift-common actually runs — SwiftLint for lint and formatting, Periphery for dead code.

Scaffolding a new package

setup --preset swift-library scaffolds a SwiftPM package end to end (manifest, sources, tests, configs, CI). This page is the config + check reference; the Swift guide covers the project lifecycle.

Checks

CheckStatus when absentFix target
Package.swiftmissing— (run swift package init)
SwiftLintmissingswiftlint
Peripheryoptional-missingperiphery
swift-formatoptional-missingswift-format
Swift .gitignoremissingswift-gitignore
Swift testsmissing— (manifest edit, or swift-ci)
DocCoptional-missingdocc
Release automationoptional-missingswift-release
Git hooksoptional-missingswift-git-hooks
Pre-push hookoptional-missingswift-git-hooks

Package.swift is checked for two things SwiftPM will not infer: a // swift-tools-version: comment (without it the manifest doesn't parse) and an explicit platforms: clause (without it SwiftPM assumes its oldest supported deployment target, which rejects modern APIs at build time). There's no fixer — rewriting someone's manifest isn't safe, so doctor reports and you edit.

Swift tests has two halves: the manifest must declare a .testTarget(, and some pipeline (.github/workflows/* or .gitlab-ci.yml) must actually run swift test. A green pipeline over a package with no test target proves nothing. There's no single fix target because the first half is a manifest edit; fix swift-ci covers the second.

Git hooks and Pre-push hook are language-agnostic checks (they run on a JS repo too, against .husky/); the Swift module only supplies the shape — .githooks/ and swift test.

Git hooks

Husky is an npm package, so a SwiftPM repo can't use it without dragging node into a toolchain that otherwise has none. The node-free equivalent is a committed hooks directory:

npx @rtorcato/repo-tooling fix swift-git-hooks

That writes two executable hooks and points git at them:

HookRuns
.githooks/pre-commitswiftlint --fix then swiftlint lint
.githooks/pre-pushswift build, swift test, swiftlint lint --strict — the same gate as CI

The hooks run the tools directly rather than through a verify indirection. SwiftPM has no scripts field, and a Makefile target would be a third place to keep the CI commands in sync (they already live in .github/workflows/ci.yml and .swiftlint.yml).

core.hooksPath is per-clone local git config, not a committed file, so doctor never reports its absence as drift — a fresh CI checkout isn't broken. Each clone needs it once:

git config core.hooksPath .githooks

There's no commit-msg hook: commitlint is an npm package and needs node on PATH. The Commitlint check still runs (Conventional Commits is language-agnostic) and stays optional-missing on a Swift repo unless you opt in.

Configs

npx @rtorcato/repo-tooling fix swiftlint # .swiftlint.yml
npx @rtorcato/repo-tooling fix periphery # .periphery.yml
npx @rtorcato/repo-tooling fix swift-format # .swift-format (optional)
npx @rtorcato/repo-tooling fix swift-gitignore

swiftlint, swift-format and periphery are also available via copy:

npx @rtorcato/repo-tooling copy swiftlint

SwiftLint

Formatting is SwiftLint's job by default — swiftlint --fix in a pre-commit hook, swiftlint lint --strict in CI. SwiftFormat (the Nick Lockwood one) is deliberately not part of the standard; a second rewriting formatter would fight the first. Apple's swift-format is available as an opt-in slot — see swift-format below.

disabled_rules:
- weak_delegate
- cyclomatic_complexity
- force_unwrapping
- function_body_length
- type_name
- line_length
- identifier_name
- trailing_whitespace

excluded:
- .build
- .swiftpm
- DerivedData

file_length:
warning: 500
error: 1200

nesting:
type_level:
warning: 3
error: 6

Periphery

retain_public: true keeps a library's public API from being reported as unused — for a SwiftPM package the public surface is the product.

retain_public: true

Periphery is best run as an informational CI job (continue-on-error: true) until a codebase is clean, then promoted to blocking.

swift-format

Apple's swift-format — shipped with the toolchain since Swift 6 as swift format — is the formatter slot, the Biome/Prettier equivalent. It's optional because SwiftLint's --fix already formats: a repo runs one or the other, and the check only reports what it finds.

npx @rtorcato/repo-tooling fix swift-format # .swift-format
{
"version": 1,
"lineLength": 120,
"indentation": { "spaces": 4 },
"respectsExistingLineBreaks": true,
"lineBreakBeforeEachArgument": false,
"prioritizeKeepingFunctionOutputTogether": true
}

This is not a second lint gate — SwiftLint stays the linter either way, and nothing in the generated CI runs swift format lint unless you add it.

DocC

The Swift equivalent of the TypeDoc check. Two halves have to line up: a .docc catalogue under Sources/<Target>/ holds the prose, and swift-docc-plugin in Package.swift is what makes swift package generate-documentation exist. Either alone is drift — docs nobody can build, or a build command with nothing to say.

npx @rtorcato/repo-tooling fix docc # Sources/<Target>/<Target>.docc/<Target>.md

The fixer writes the catalogue into the library product's target and stops there; adding the plugin dependency is a Package.swift edit, and this module doesn't rewrite manifests. It prints the line to paste:

.package(url: "https://github.com/apple/swift-docc-plugin", from: "1.4.0")

Re-running it never overwrites an existing landing page — the catalogue is prose someone wrote, and the check stays in drift until the manifest half lands, so a re-run is the normal case rather than the exception.

.gitignore

The swift-gitignore fixer appends the Swift build artefacts rather than replacing the file, so project-specific entries survive. It adds only what's absent:

.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

.build and DerivedData are the ones that matter — a single stray commit of either adds hundreds of megabytes to the repo's history.

CI

npx @rtorcato/repo-tooling fix swift-ci # .github/workflows/ci.yml
npx @rtorcato/repo-tooling fix swift-codeql # .github/workflows/codeql.yml
npx @rtorcato/repo-tooling fix swift-gitlab-ci # .gitlab-ci.yml

The workflow is derived from Package.swift — there's no config object to fill in.

JobRunnerWhat it does
build-testmacos-latestswift build + swift test, with a SwiftPM cache keyed on Package.resolved
lintmacos-latestswiftlint lint --strict
dead-codemacos-latestperiphery scan --strict, continue-on-error
platformsmacos-latestxcodebuild per declared platform

The platforms matrix is emitted only when the manifest declares both a platforms: clause and a library product (the product name becomes the xcodebuild scheme). A server-side or CLI package with neither gets build-test + lint + dead-code and nothing else — xcodebuild against a package with no deployment targets has nothing to build.

dead-code is emitted unconditionally and always as continue-on-error. An established codebase almost always has unused declarations on day one, and a permanently red job trains people to ignore CI; drop the flag once the repo is clean.

CodeQL uses language: swift rather than the JS matrix.

Releases

npx @rtorcato/repo-tooling fix swift-release # .github/workflows/release.yml

SwiftPM has no registry publish step — a release is a semver git tag that consumers resolve with .package(url:from:) — so the workflow fires on the tag rather than on a merge:

TriggerWhat runs
push of 1.2.3 or v1.2.3swift build, swift test, then gh release create --generate-notes --verify-tag

The build/test gate runs before the release is cut because a tag is effectively permanent: SwiftPM caches resolved tags, so re-pointing a bad one doesn't reliably reach consumers who already resolved it. gh is preinstalled on GitHub runners, which is one fewer third-party action pin to track.

semantic-release is deliberately not accepted as evidence for this check — its pipeline is npm end to end, and a Swift repo running it publishes the wrong thing. The lockfile's semanticRelease field is the release-automation flag either way: set it to false and doctor records the check as intentionally declined.

GitLab

GitLab runs Swift in the official Linux image (swift:6.0), which has no Xcode — so .gitlab-ci.yml covers the Linux-portable half only, swift build then swift test. No SwiftLint, no platform matrix.

Scaffolding

setup --preset swift-library writes all of the above plus Package.swift, a Sources//Tests/ pair that builds and tests green, and the CI workflows. See the Swift guide for the full file list and the JS-vs-Swift comparison.

What isn't covered yet

  • README badges. The README badges check runs on a Swift repo, but there's no fixer: fix badges derives every badge URL from a package.json name + repository, which a SwiftPM repo hasn't got. doctor reports; you add the badges by hand.
  • A swift-docc-plugin fixer. fix docc writes the catalogue but not the manifest dependency it needs — rewriting someone's Package.swift isn't safe, so the check reports drift and prints the line to add.