Coming off the back of creating our domain objects and data layer, things are going well and it’s tempting to power on to the next feature. But if you’re like me, you just committed the changes from last time directly to the main branch. That’s manageable for simple, early changes – but it becomes increasingly risky as the project grows and the development team expands.
Once you commit a change, it becomes part of the shared timeline of your project. What if you forgot to check that your changes compile? What if you added some configuration that doesn’t actually work in the production environment? These problems break the shared timeline, turning the deployment pipeline red and potentially blocking other team members from deploying their own changes.
The primary aim of CI is to catch as many of these problems as possible before they affect other people’s productivity. But continuous integration can also enforce a quality bar on your codebase – consistent formatting, no unused imports, adequate test coverage. These checks won’t directly prevent bugs, but they reduce their likelihood by keeping the codebase consistent and standardised. My rule of thumb: asking people politely to run a script before committing will never work consistently. People forget, even with the best intentions. Enforce it as part of the commit process.
So this post is about setting up an initial set of continuous integration steps. While you can technically introduce these at any point, setting them up early avoids the pain of massive codebase-wide changes when you introduce quality checks to an established project.
In this post, we’ll set up a GitHub Actions pipeline that does six things:
- Keeps dependencies up to date with Dependabot
- Builds the project and runs all unit tests
- Lints the code with SwiftLint
- Detects unused imports with SwiftLint’s analyzer
- Enforces consistent formatting with swift-format
- Prevents code coverage regressions
Each of these catches a different class of problem, and together they give us confidence that the main branch stays clean as the project grows.
Dependabot
Let’s start with the simplest piece. Dependabot monitors your dependencies for new versions and security vulnerabilities, and opens pull requests to update them. GitHub supports Swift Package Manager as a package ecosystem, so setup is just a configuration file.
Create
.github/dependabot.ymlthat configures Dependabot to check for Swift package updates and GitHub Actions updates on a weekly schedule.
The configuration is minimal:
version: 2updates: - package-ecosystem: "swift" directory: "/" schedule: interval: "weekly" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly"
The swift ecosystem monitors your Package.swift and Package.resolved files, opening PRs when new versions of your dependencies are available. The github-actions ecosystem does the same for the action versions used in your workflow files – equally important, since pinned action versions can fall behind on security patches.
One thing to be aware of: Swift package dependencies involve two files with different roles. Package.swift declares the version requirements – the range of versions your package is willing to accept (e.g., .package(url: "...", from: "2.0.0") means “any version from 2.0.0 up to but not including 3.0.0”). Package.resolved records the exact versions that were actually resolved the last time you ran swift package resolve – the pins. When SPM resolves dependencies, it picks the latest version that satisfies all the requirements and writes the result to the resolved file.
Dependabot can propose changes to either file. Sometimes it updates just the pins in Package.resolved (e.g., moving from 2.1.3 to 2.1.4 within your existing requirement). But it may also propose bumping the version requirement in Package.swift itself (e.g., changing from: "2.0.0" to from: "2.1.0"). The first kind of update is usually safe – it’s a patch or minor version within the range you already accept.
The second kind is worth understanding. The version range in Package.swift expresses which versions of a dependency your package considers compatible – essentially, “I can use any version in this range without breaking changes.” For a library that other packages depend on, this range matters a lot: if your library declares from: "2.0.0", a consumer can use any 2.x version they like, and SPM can find a version that satisfies everyone in the dependency graph. Narrowing that range to from: "2.1.0" forces all consumers to use at least 2.1.0, which can cause resolution conflicts. For an executable like task-cluster, this distinction is less critical since nothing else depends on your version requirements – but it’s a good habit to review these PRs carefully regardless.
Build and Test
The core of any CI pipeline: does the code compile, and do the tests pass? For a Swift server project targeting Linux, we run this in a Swift Docker container rather than on a macOS runner. Linux runners are cheaper, faster to provision, and match the environment where the service will actually run in production.
Create a GitHub Actions workflow that builds and tests the project using a Swift Docker container on Ubuntu. Use the official
swift:6.2Docker image. Build in release mode with strict concurrency checking, then run the tests.
This adds a file in .github/workflows with a build and test step that looks like this:
BuildAndTest: runs-on: ubuntu-latest container: image: swift:6.2 steps: - uses: actions/checkout@v6 - name: Build run: swift build -c release -Xswiftc -strict-concurrency=complete - name: Run tests run: swift test
A couple of things worth calling out:
-Xswiftc -strict-concurrency=complete enables the full Swift 6 concurrency checking even if your package manifest hasn’t opted into strict mode yet. This catches data race issues that would otherwise only surface at runtime. It’s a good practice to have CI enforce this even if you’re not ready to turn it on in your package manifest permanently – it prevents new concurrency violations from creeping in.
Building in release mode (-c release) verifies that the code compiles with optimisations enabled. Debug and release builds can behave differently – some code paths are only exercised under optimisation, and some compiler warnings only appear in release mode. Testing both is ideal, but if you’re only going to do one, release is the more valuable check. The tests themselves run in debug mode (the swift test default), which is fine – you want debuggable test failures.
SwiftLint
SwiftLint enforces style and convention rules on your Swift code. It catches things like force unwraps, overly long functions, trailing whitespace, and dozens of other patterns that make code harder to maintain.
On GitHub Actions, the easiest way to run SwiftLint on Linux is with the community-maintained action, which wraps the official Docker image:
SwiftLint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: SwiftLint uses: norio-nomura/action-swiftlint@3.2.1
This runs swiftlint lint against your source files using the rules configured in your .swiftlint.yml. If you don’t have a configuration file, SwiftLint uses sensible defaults – but you’ll almost certainly want to customise the rule set for your project.
A basic .swiftlint.yml might look like:
excluded: - .build - Package.swift
This keeps SwiftLint from linting generated code in the build directory and the package manifest itself. Although for example this is the configuration file I typically use:
disabled_rules: - trailing_whitespace - void_returnexcluded: - .build - Package.swiftline_length: 150function_body_length: warning: 50 error: 75
The SwiftLint GitHub repository explains how to install and run this locally so you can check for violations before committing.
Unused Import Detection
This is where things get more interesting. Standard swiftlint lint performs syntactic analysis – it works on the source text without compiling it. But detecting unused imports requires semantic analysis: the tool needs to know which symbols are actually used and which modules provide them. That requires a full compilation.
SwiftLint’s analyze mode does exactly this. You give it a compiler log from a build, and it runs a separate set of analyzer rules against the fully type-checked AST. The unused_import rule is the most valuable of these – it flags imports that aren’t needed, keeping your dependency graph honest.
The trick is capturing the compiler log. We can combine this with the test step by piping the output through tee. Replace the existing build and test step with the following:
BuildTestAndAnalyze: runs-on: ubuntu-latest container: image: swift:6.2 steps: - name: Install dependencies run: apt-get update && apt-get install -y curl unzip - uses: actions/checkout@v6 - name: Build run: swift build -c release -Xswiftc -strict-concurrency=complete - name: Run tests run: swift test -v 2>&1 | tee build.log - name: Install SwiftLint run: | curl -sL https://github.com/realm/SwiftLint/releases/download/0.63.2/swiftlint_linux_amd64.zip -o swiftlint.zip unzip -o swiftlint.zip -d /usr/local/bin chmod +x /usr/local/bin/swiftlint - name: Analyze unused imports run: swiftlint analyze --strict --quiet --compiler-log-path build.log
The key steps:
swift test -v 2>&1 | tee build.logruns the tests in verbose mode, which includes the full compiler invocations in the output. Theteecommand writes the output to both stdout (so you can see it in the CI log) and tobuild.log(so SwiftLint can parse it).- SwiftLint is installed from the official release binary – the Swift Docker image doesn’t include it.
swiftlint analyze --compiler-log-path build.logreads the compiler invocations from the log and runs the analyzer rules.
You’ll need to enable the analyzer rules in your .swiftlint.yml:
analyzer_rules: - unused_importunused_import: always_keep_imports: - Foundation
The --strict flag means any violation fails the build. The --quiet flag suppresses non-error output so the CI log stays clean.
Note the exclusion of Foundation – Swift’s standard library – from this check. Unfortunately there are inconsistencies across platforms for when Foundation needs to be imported. At the moment it is just easier to be less strict on this check here.
Why bother with unused imports? Because they’re not just cosmetic. An unused import is a false dependency signal. It makes it harder to understand what a file actually depends on, it can slow down incremental builds, and it can mask architectural problems – if a model file imports a networking framework it doesn’t use, that’s a hint that something was refactored without cleaning up.
Swift Format
Formatting arguments are a waste of engineering time. Pick a style, automate it, and never think about it again.
swift-format is Apple’s official Swift formatter. Since Swift 6, it’s bundled with the toolchain – no separate installation needed. In CI, we run the formatter and check if it would change anything. If it would, the build fails:
SwiftFormat: runs-on: ubuntu-latest container: image: swift:6.2 steps: - uses: actions/checkout@v6 - name: Run swift-format run: swift-format format --recursive --in-place . - name: Check for formatting differences run: | git config --global --add safe.directory "$GITHUB_WORKSPACE" git diff --exit-code
This is a neat pattern. Rather than running a lint-style check that reports violations, we run the formatter for real (with --in-place) and then ask git whether anything changed. If git diff --exit-code finds any differences, the step fails. The diff output in the CI log shows exactly what needs to change, which is more useful than an abstract violation report.
The safe.directory config line is needed because the checkout directory is owned by a different user inside the container, and git would otherwise refuse to run diff.
You can customise the formatting rules by adding a .swift-format JSON file to your project root. Generate the defaults with swift-format dump-configuration and adjust from there. But honestly, the defaults are reasonable – the value of a formatter comes from consistency, not from any particular style choice.
swift-format comes bundled as part of the Swift toolchain and so doesn’t need a separate installation and the above command can be run locally to fix any violations.
swift-format format --recursive --in-place .
Code Coverage
Tests are only as useful as the code they exercise. Code coverage metrics aren’t a goal in themselves – 100% coverage doesn’t mean your code is correct – but preventing regressions is valuable. If coverage goes down on a PR, it means new code was added without corresponding tests, which is worth flagging.
Swift’s test runner can generate both coverage data and a structured test report natively. The --enable-code-coverage flag instruments the test binary to record which lines were exercised, and --xunit-output writes test results in JUnit XML format – a standard that most CI reporting tools understand:
swift test --enable-code-coverage --xunit-output test-results.xml
This produces two artifacts: coverage data in the build directory (you can find its path with swift test --show-codecov-path) and a test-results.xml file containing the pass/fail status of every test.
For CI integration, we want to do two things: upload the coverage data to a service that tracks it over time, and publish the test report so pass/fail results are visible on the PR. Codecov can handle both. Their codecov-action@v5 supports separate report types – coverage for line coverage data and test_results for JUnit XML test reports. This gives us coverage tracking, PR comments with coverage deltas, and a test analytics dashboard with historical trends and flaky test detection, all from a single service.
Coverage: runs-on: ubuntu-latest container: image: swift:6.2 steps: - name: Install dependencies run: apt-get update && apt-get install -y curl - uses: actions/checkout@v6 - name: Run tests with coverage run: swift test --enable-code-coverage --xunit-output test-results.xml - name: Convert to LCOV run: | BIN_PATH=$(swift build --show-bin-path) PROFDATA=$(find .build -name 'default.profdata' -path '*/codecov/*' | head -1) XCTEST=$(find "$BIN_PATH" -name '*.xctest' -type f | head -1) llvm-cov export \ -format=lcov \ -instr-profile="$PROFDATA" \ "$XCTEST" \ -ignore-filename-regex=".build|Tests" \ > coverage.lcov - name: Upload coverage uses: codecov/codecov-action@v5 if: always() with: files: coverage.lcov fail_ci_if_error: true env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - name: Upload test results uses: codecov/codecov-action@v5 if: always() with: files: test-results.xml report_type: test_results fail_ci_if_error: true env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
A few things to note:
Two separate upload steps. Codecov requires coverage and test results to be uploaded as separate invocations with different report_type values. The coverage upload uses the default report type; the test results upload specifies report_type: test_results explicitly.
The if: always() on both upload steps is important. Without it, GitHub Actions skips subsequent steps when a previous step fails – which means neither the coverage nor the test report would be uploaded when tests fail, exactly when you most want to see them.
The LCOV conversion step uses llvm-cov to convert Swift’s native coverage format into LCOV, which Codecov understands. The -ignore-filename-regex flag excludes the build directory and test files from the coverage report, so you’re only measuring coverage of your production code.
The Codecov token needs to be configured as a repository secret (Settings → Secrets and variables → Actions). You get the token from your Codecov account after linking the repository.
Codecov’s default behaviour is to fail the PR check if coverage drops relative to the base branch, which is exactly what we want. The test results upload feeds into Codecov’s Test Analytics dashboard, which tracks test pass rates, run times, and flaky tests across your commit history. You can fine-tune coverage thresholds in a codecov.yml configuration file – setting minimum coverage, ignoring certain paths, or requiring coverage on new lines only.
If you’d rather keep things self-contained without an external service, swift-test-codecov is a CLI tool that reads the JSON coverage output and can enforce a minimum threshold directly in your workflow:
swift-test-codecov .build/debug/codecov/task-cluster.json --minimum 80
This exits non-zero if coverage falls below 80%, failing the CI step.
Preventing API Breakage
If your package exposes public API that other packages depend on, accidentally removing or renaming a public type or method is a breaking change. Swift Package Manager has a built-in tool for detecting this: swift package diagnose-api-breaking-changes.
You give it a git reference (a tag, branch, or commit) as the baseline, and it compares the current public API surface against that baseline:
swift package diagnose-api-breaking-changes main
Under the hood, it builds both versions, generates .swiftinterface files for each library product’s modules, and uses swift-api-digester to diff them. If any public symbols have been removed, renamed, or had their signatures changed, it reports them and exits non-zero.
In CI:
APIBreakage: runs-on: ubuntu-latest container: image: swift:6.2-noble steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Check for API breaking changes run: | git config --global --add safe.directory "$GITHUB_WORKSPACE" swift package diagnose-api-breaking-changes origin/main
The fetch-depth: 0 is important – the tool needs access to the baseline commit’s source, so a shallow checkout won’t work.
A few caveats to keep in mind:
- This only detects syntactic API changes – removed types, renamed methods, changed signatures. It can’t detect behavioural breaking changes (a method that now returns different results for the same input).
- It only examines modules vended by library products in your Package.swift. Executable targets and test targets are not checked.
- For task-cluster specifically, this step becomes relevant once we start publishing library targets that other services consume. For a standalone executable, API breakage detection is less critical – but it’s good to have the pattern ready for when the project grows.
Putting It All Together
The jobs run in parallel by default in GitHub Actions, so the full pipeline looks like five independent checks that all must pass before a PR can merge:
name: CIon: push: branches: [ main ] pull_request: branches: [ main ]jobs: BuildTestAndAnalyze: # Build, test, and check for unused imports ... SwiftLint: # Style and convention checks ... SwiftFormat: # Formatting consistency ... Coverage: # Test coverage reporting ... APIBreakage: # Public API surface check ...
Each job catches a different category of problem:
| Job | What it catches |
|---|---|
| BuildTestAndAnalyze | Compilation errors, test failures, unused imports |
| SwiftLint | Style violations, antipatterns, complexity |
| SwiftFormat | Inconsistent formatting |
| Coverage | Untested code, coverage regressions |
| APIBreakage | Accidentally removed or changed public API |
The total wall-clock time for the pipeline is roughly the duration of the slowest job (BuildTestAndAnalyze), since the others run concurrently. For a small project like task-cluster at this stage, that’s under a couple of minutes.
Let’s go and push these changes to a branch and create a pull request. Some of the checks may fail and require you to address any issues.
Protecting the Main Branch
A CI pipeline that runs but can be ignored isn’t really protecting anything. The final step before we commit our changes is to configure your GitHub repository so that these checks are required before code can be merged to main.
Branch protection rules
In your GitHub repository, go to Settings → Branches. Under “Branch protection rules,” click Add rule (or Add branch ruleset if your repository uses the newer rulesets interface).
Configure the following for the main branch:
- Require a pull request before merging. This prevents direct pushes to main – all changes must go through a PR. If you’re working solo, you can uncheck “Require approvals” while still requiring the PR workflow.
- Require status checks to pass before merging. This is the critical one. Add each of your CI job names as a required status check:
BuildTestAndAnalyze,SwiftLint,SwiftFormat,Coverage,APIBreakage. A PR cannot be merged until every required check is green. - Require branches to be up to date before merging. This ensures the PR has been rebased or merged with the latest main before it can be merged, so you’re not merging code that was only tested against a stale base.
- Make sure you enable the ruleset
Enabling Dependabot
The dependabot.yml configuration file tells Dependabot what to check, but you also need to make sure Dependabot is enabled on the repository itself.
Go to Settings → Code/Advanced security (or Settings → Code security and analysis depending on your plan). Enable:
- Dependabot alerts – notifies you when a dependency has a known security vulnerability.
- Dependabot security updates – automatically opens PRs to fix vulnerable dependencies, even outside the regular weekly schedule.
- Dependabot version updates – this is what reads your
dependabot.ymland opens PRs for new dependency versions on the schedule you configured.
Once enabled, Dependabot PRs will go through the same CI pipeline and branch protection rules as any other PR. This means a dependency update that breaks the build or fails a lint check won’t be merged automatically – you’ll see the failure and can investigate before deciding how to proceed.
What’s Next
We have a foundation: domain model, persistence layer, and now a CI pipeline that keeps things honest. In the next post, we’ll design the OpenAPI spec for the task API and wire it up to a Hummingbird server – the point where the service starts accepting HTTP requests.
Leave a comment