| name | add-build-number |
| description | Install a git-commit-count build-number system (BuildNumberGenerator + BuildNumberPlugin + AppVersion) in a Swift monorepo. Use when adding a versioning surface to a package that doesn't have one. |
Install a git-commit-count build-number system in a Swift monorepo.
What gets installed
Three pieces, all inside the Packages/ SPM:
BuildNumberGenerator (executable target). Runs git rev-list --count HEAD at compile time and writes a tiny Swift file declaring AppVersion.generatedBuildNumber: Int.
BuildNumberPlugin (SwiftPM build-tool plugin). Invokes the generator before compiling whichever target opts in. Conforms to both BuildToolPlugin (for swift build) and XcodeBuildToolPlugin (for Xcode).
AppVersion (public enum, lives in the consumer target). Exposes major, sprint, patch, build, plus computed strings (marketingVersion, fullVersion, displayVersion, uiVersionString) and a VersionComponents struct.
Result: AppVersion.fullVersion reports e.g. "0.1.0(248)" where 248 is git rev-list --count HEAD. Every commit advances the build number automatically with no manual bump.
Ask for
- Consumer target: which existing target should own
AppVersion?
- Default: a
SharedModels-style target if one exists.
- If none fits, create a dedicated
AppVersion target (Foundation layer, no deps).
- Starting marketing version: defaults to
major=0, sprint=1, patch=0 (so fullVersion starts at 0.1.0(<commitCount>)).
- Package.swift location: default
Packages/Package.swift.
Files to create
All paths relative to the package root (typically Packages/).
Sources/BuildNumberGenerator/BuildNumberGenerator.swift
import Foundation
@main
struct BuildNumberGenerator {
static func main() throws {
#if os(macOS) || os(Linux)
try runGenerator()
#else
fatalError("BuildNumberGenerator only runs on macOS or Linux")
#endif
}
}
#if os(macOS) || os(Linux)
extension BuildNumberGenerator {
static func runGenerator() throws {
guard CommandLine.arguments.count > 1 else {
print("Usage: BuildNumberGenerator <output-path>")
exit(1)
}
let outputPath = CommandLine.arguments[1]
let buildNumber = getGitCommitCount()
let content = """
// Generated by BuildNumberPlugin - DO NOT EDIT
// Build number based on git commit count
extension AppVersion {
/// Auto-generated build number from git commit count
public static let generatedBuildNumber: Int = \(buildNumber)
}
"""
try content.write(toFile: outputPath, atomically: true, encoding: .utf8)
()
}
() -> {
process ()
pipe ()
process.executableURL (fileURLWithPath: )
process.arguments [, , ]
process.standardOutput pipe
process.standardError .nullDevice
currentDir .default.currentDirectoryPath
.default.fileExists(atPath: ) {
parent (currentDir ).deletingLastPathComponent
parent currentDir { }
currentDir parent
}
process.currentDirectoryURL (fileURLWithPath: currentDir)
{
process.run()
process.waitUntilExit()
data pipe.fileHandleForReading.readDataToEndOfFile()
output (data: data, encoding: .utf8)
.trimmingCharacters(in: .whitespacesAndNewlines),
count (output) {
count
}
} {
()
}
}
}
Plugins/BuildNumberPlugin/BuildNumberPlugin.swift
import Foundation
import PackagePlugin
@main
struct BuildNumberPlugin: BuildToolPlugin {
func createBuildCommands(
context: PluginContext,
target: Target
) async throws -> [Command] {
let outputPath = context.pluginWorkDirectoryURL
.appending(path: "GeneratedBuildNumber.swift")
return try [
.buildCommand(
displayName: "Generate Build Number from Git",
executable: context.tool(named: "BuildNumberGenerator").url,
arguments: [outputPath.path()],
outputFiles: [outputPath]
),
]
}
}
#if canImport(XcodeProjectPlugin)
import XcodeProjectPlugin
extension BuildNumberPlugin: XcodeBuildToolPlugin {
func createBuildCommands(
context: XcodePluginContext,
target: XcodeTarget
) throws -> [Command] {
let outputPath = context.pluginWorkDirectoryURL
.appending(path: "GeneratedBuildNumber.swift")
return try [
.buildCommand(
displayName: "Generate Build Number from Git",
executable: context.tool(named: ).url,
arguments: [outputPath.path()],
outputFiles: [outputPath]
),
]
}
}
Sources/<ConsumerTarget>/AppVersion.swift
Replace <ConsumerTarget> with the answer to question 1. Substitute the numbers from question 2.
import Foundation
public enum AppVersion {
public static let major: Int = 0
public static let sprint: Int = 1
public static let patch: Int = 0
public static var build: Int { generatedBuildNumber }
marketingVersion: { }
buildString: { }
fullVersion: { }
displayVersion: { }
uiVersionString: { }
components: {
(major: major, sprint: sprint, patch: patch, build: build)
}
: , {
major:
sprint:
patch:
build:
(: , : , : , : ) {
.major major
.sprint sprint
.patch patch
.build build
}
isPreRelease: { major }
description: {
}
}
}
Edits to Package.swift
-
Add to products: the plugin product so other packages can use it:
.plugin(name: "BuildNumberPlugin", targets: ["BuildNumberPlugin"]),
-
Add two targets:
.executableTarget(
name: "BuildNumberGenerator",
dependencies: []
),
.plugin(
name: "BuildNumberPlugin",
capability: .buildTool(),
dependencies: ["BuildNumberGenerator"]
),
-
Attach the plugin to the consumer target:
.target(
name: "<ConsumerTarget>",
dependencies: [...existing...],
plugins: [.plugin(name: "BuildNumberPlugin")]
),
Verify
Run, from the package root:
swift build
Expected: a line in the output like Generated build number: 248. The generated file lives at .build/plugins/outputs/<...>/GeneratedBuildNumber.swift and is NOT checked in.
Then sanity-check from Swift:
print(AppVersion.fullVersion)
print(AppVersion.components.description)
Notes and caveats
- Shallow clones: the generator runs
git rev-list --count HEAD. CI checkouts that use --depth 1 will report a tiny number. Run git fetch --unshallow before building if you need accurate counts.
- No
.git available: the generator falls back to 1. This happens when building from a downloaded source tarball.
- Bumping marketing version: edit
major, sprint, patch in AppVersion.swift directly. There's no bump-version.sh; this is a deliberate choice to keep the system small.
- Wiring into the app's Info.plist
CFBundleVersion: not in scope for this skill. SwiftPM plugins cannot inject Info.plist values directly. If the app target needs CFBundleVersion set from AppVersion.build, do that as a separate Xcode pre-build script or xcconfig step.
- Don't gitignore generated files: the plugin writes to
.build/plugins/outputs/..., which is already covered by the standard SwiftPM gitignore.