All Articles
CategorySwift
Reading Time
20 min read
Published
2026-01-15
Word Count
1,601words

Grab a coffee — this one is a deep dive!

Swift Package Plugins Guide: Automate Your Build Process

Summary

Automate code generation, linting, documentation, and custom build steps with the Swift Package Manager plugin system. Learn about Command and Build Tool plugin types in detail.

  • SPM Package Plugins come in two types: Build Tool Plugins (automatic, on every build) and Command Plugins (manually triggered).
  • Build Tool Plugins return either buildCommand (incremental) or prebuildCommand (every time).
  • Plugins run inside a sandbox; Command Plugins cannot write files by default and can be extended with permission.
  • The SPM plugin system uses Apple's internal llbuild infrastructure, which is why plugins integrate perfectly with Xcode.
Swift Package Plugins Guide: Automate Your Build Process

Swift Package Manager is no longer just a dependency management tool — it's a full-fledged build automation platform. Package Plugins, introduced with Swift 5.6, let you add custom steps to your build process, generate code, and automate repetitive tasks in your project. If you're still running manual linting, code generation, or documentation scripts, this guide will change your life.

Note: All examples in this guide have been tested with Swift 5.9+ and Xcode 15+.

Table of Contents


1. What Are Package Plugins?

Swift Package Plugins are tools written in Swift that let you hook into the SPM build process. There are two main types:

  1. Build Tool Plugins: Run automatically during compilation (code generation, asset processing)
  2. Command Plugins: Triggered manually (linting, formatting, documentation)

Both types run inside a sandbox and have limited access to your system.

Why Should You Use Plugins?

  • Eliminates repetition: The scripts you used to run before every build are now automatic
  • Platform independent: macOS, Linux — works the same way everywhere
  • Type-safe: Plugins are written in Swift, giving you compile-time safety
  • Shareable: You can distribute plugins as a package
swift
1// Package.swift - Plugin definition
2// swift-tools-version: 5.9
3import PackageDescription
4 
5let package = Package(
6 name: "MyApp",
7 products: [
8 .library(name: "MyApp", targets: ["MyApp"])
9 ],
10 targets: [
11 // Main target
12 .target(
13 name: "MyApp",
14 plugins: [
15 .plugin(name: "CodeGenPlugin")
16 ]
17 ),
18 // Plugin target
19 .plugin(
20 name: "CodeGenPlugin",
21 capability: .buildTool()
22 )
23 ]
24)

2. Detailed Comparison of Plugin Types

Feature
Build Tool Plugin
Command Plugin
Runtime
Automatic on every build
Manual trigger
Sandbox
Full sandbox
Restricted (expands with permission)
File Writing
Build directory only
Project directory (with permission)
Use Case
Code generation, asset processing
Linting, formatting, deploy
Performance Impact
Can extend build time
Doesn't affect build time
Xcode Support
Full integration
From the right-click menu
CI/CD
Runs automatically
Via the swift package command

3. Creating a Build Tool Plugin

Build Tool Plugins run before every compilation and are typically used to generate source code.

Basic Structure

swift
1// Plugins/CodeGenPlugin/CodeGenPlugin.swift
2import PackagePlugin
3import Foundation
4 
5@main
6struct CodeGenPlugin: BuildToolPlugin {
7 func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
8 // Make sure it's a source target
9 guard let sourceTarget = target as? SourceModuleTarget else {
10 return []
11 }
12 
13 // Find input files (with .codegen.json extension)
14 let inputFiles = sourceTarget.sourceFiles.filter { file in
15 file.path.extension == "json" && file.path.stem.hasSuffix(".codegen")
16 }
17 
18 // Create a build command for each file
19 return inputFiles.map { inputFile in
20 let outputName = inputFile.path.stem
21 .replacingOccurrences(of: ".codegen", with: "")
22 let outputPath = context.pluginWorkDirectory
23 .appending("Generated_\(outputName).swift")
24 
25 return .buildCommand(
26 displayName: "CodeGen: \(outputName)",
27 executable: try! context.tool(named: "codegen-tool").path,
28 arguments: [
29 "--input", inputFile.path.string,
30 "--output", outputPath.string
31 ],
32 inputFiles: [inputFile.path],
33 outputFiles: [outputPath]
34 )
35 }
36 }
37}

Pre-Build vs Build Command

Build Tool Plugins can return two types of commands:

  • buildCommand: Runs when input files change (incremental)
  • prebuildCommand: Runs before every build (every time)
swift
1// Prebuild Command example - runs on every build
2func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
3 let outputDir = context.pluginWorkDirectory.appending("Generated")
4 
5 return [
6 .prebuildCommand(
7 displayName: "Generate Build Info",
8 executable: try context.tool(named: "build-info-generator").path,
9 arguments: ["--output-dir", outputDir.string],
10 outputFilesDirectory: outputDir
11 )
12 ]
13}

Easter Egg

You found a hidden gem!

There's a hidden detail in this section. Want to uncover it?


4. Creating a Command Plugin

Command Plugins are triggered manually and have broader permissions over the project.

swift
1// Plugins/LintPlugin/LintPlugin.swift
2import PackagePlugin
3 
4@main
5struct LintPlugin: CommandPlugin {
6 func performCommand(
7 context: PluginContext,
8 arguments: [String]
9 ) async throws {
10 // Find the SwiftLint tool
11 let swiftLint = try context.tool(named: "swiftlint")
12 
13 // Determine the targets
14 var argExtractor = ArgumentExtractor(arguments)
15 let targetNames = argExtractor.extractOption(named: "target")
16 
17 let targets: [Target]
18 if targetNames.isEmpty {
19 targets = context.package.targets
20 } else {
21 targets = try context.package.targets(named: targetNames)
22 }
23 
24 // Run lint for each target
25 for target in targets {
26 guard let sourceTarget = target as? SourceModuleTarget else {
27 continue
28 }
29 
30 let process = Process()
31 process.executableURL = URL(fileURLWithPath: swiftLint.path.string)
32 process.arguments = [
33 "lint",
34 "--path", sourceTarget.directory.string,
35 "--reporter", "emoji"
36 ]
37 
38 try process.run()
39 process.waitUntilExit()
40 
41 if process.terminationStatus != 0 {
42 Diagnostics.warning("SwiftLint found warnings for \(target.name)")
43 }
44 }
45 }
46}

5. Real-World Examples

Example 1: Mock Generator Plugin

Automatic mock generation for your test files:

swift
1// Plugins/MockGenPlugin/MockGenPlugin.swift
2import PackagePlugin
3 
4@main
5struct MockGenPlugin: BuildToolPlugin {
6 func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
7 guard let target = target as? SourceModuleTarget else { return [] }
8 
9 // Find protocol files
10 let protocolFiles = target.sourceFiles.filter { file in
11 file.path.extension == "swift"
12 }
13 
14 let outputDir = context.pluginWorkDirectory.appending("Mocks")
15 
16 return [
17 .prebuildCommand(
18 displayName: "Generate Mocks for \(target.name)",
19 executable: try context.tool(named: "mockgen").path,
20 arguments: [
21 "--sources", target.directory.string,
22 "--output", outputDir.string,
23 "--testable-import", target.name
24 ],
25 outputFilesDirectory: outputDir
26 )
27 ]
28 }
29}

Example 2: Asset Catalog Validator

Validate your assets during the build:

swift
1struct AssetValidatorPlugin: BuildToolPlugin {
2 func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
3 guard let target = target as? SourceModuleTarget else { return [] }
4 
5 // Find .xcassets directories
6 let assetCatalogs = target.sourceFiles.filter { file in
7 file.path.extension == "xcassets"
8 }
9 
10 guard !assetCatalogs.isEmpty else { return [] }
11 
12 let reportPath = context.pluginWorkDirectory.appending("asset-report.txt")
13 
14 return [
15 .prebuildCommand(
16 displayName: "Validate Assets",
17 executable: try context.tool(named: "asset-validator").path,
18 arguments: assetCatalogs.map { item in item.path.string } +
19 ["--output", reportPath.string],
20 outputFilesDirectory: context.pluginWorkDirectory
21 )
22 ]
23 }
24}

6. Plugin Security Model

SPM plugins run inside a sandbox. Understanding this security layer is critical.

Sandbox Rules

Permission
Build Tool
Command (default)
Command (extended)
Reading sources
Target directory only
Target directory only
Entire project
Writing files
Plugin work dir
None
Project directory
Network access
None
None
None
Running processes
Declared tools
Declared tools
Declared tools
Environment variables
Restricted
Restricted
Restricted

7. CI/CD Integration

Integrating your plugins into a CI/CD pipeline is straightforward:

bash
1# GitHub Actions - using a Command plugin
2swift package plugin --allow-writing-to-package-directory lint-code
3swift package plugin --allow-writing-to-package-directory format-code --target MyApp
4 
5# Build tool plugins run automatically
6swift build
7swift test

GOLDEN TIP

The most valuable insight in this article

This tip holds the article's most important takeaway.

Reader Reward

Congratulations! Since you read this post all the way through, I have something special for you:

Conclusion and Recommendations

Swift Package Plugins are the most effective way to modernize your build process. Ditch manual scripts, automate repetitive tasks, and boost your team's productivity. Generate code with Build Tool Plugins, and standardize linting and formatting with Command Plugins. Don't forget to publish your plugins as separate packages to share them with the community.

Tags

#Swift#SPM#plugins#build tools#automation#DevOps
Muhittin Çamdalı

Muhittin Çamdalı

Lead Mobile Engineer

Lead Mobile Engineer with 12+ years of experience. Expert in iOS, Android and cross-platform architectures with Swift, SwiftUI, Kotlin and Flutter. I build performant, user-friendly mobile apps.

iOS Development News

Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.

We respect your privacy. You can unsubscribe at any time.

Share