[Pitch] Product-Builder plugin capability for SwiftPM

Hi all,

I’d like to pitch a new SwiftPM plugin capability for constructing the final
artifacts of a package product.

The motivating examples are embedded firmware images, application bundles,
installable archives, container images, and other products that need to be
assembled from already-compiled Swift targets and resources. The proposal is
intentionally generic and does not teach SwiftPM about any particular artifact
format.

I'm finishing a working prototype and example packages, including firmware-shaped
ELF/BIN/UF2 outputs and an iOS application archive/IPA. I will provide the public implementation
links soon.

Package Manager Product Builders

  • Proposal: SE-NNNN
  • Authors: TBD
  • Review Manager: TBD
  • Status: Pitch
  • Implementation: Prototype available; public link TBD

Introduction

This proposal introduces a product-builder plugin capability. A product builder
provides declarative commands that turn an already-compiled target closure and
its resources into the final artifacts of a named package product. Those
commands are scheduled by Swift Build as part of the ordinary incremental build
graph.

The intended experience is a typed product declaration. In this example,
.picoUF2 is a helper defined later in the same manifest:

// swift-tools-version: 6.x

import PackageDescription

let package = Package(
    name: "PicoFirmware",
    products: [
        .picoUF2(
            name: "Firmware",
            target: "FirmwareCore",
            board: .pico2
        ),
    ],
    targets: [
        .target(
            name: "FirmwareCore",
            resources: [
                .copy("board.json"),
            ]
        ),
    ]
)

The helper determines which builder implements .picoUF2, provides a typed
configuration model, and lowers that configuration to a generic
artifact-product representation.

The lower-level .artifact API in this proposal is the mechanism on which typed
definitions can be built. It is not the intended center of the user experience
and does not attempt to make opaque, stringly typed products desirable on their
own.

There are three distinct perspectives in this model:

  • A consuming package author defines and uses a typed manifest helper such as
    .picoUF2.
  • A platform-support package author implements the product-builder plugin and
    its tools and establishes the configuration schema consumed by the builder.
  • SwiftPM provides the low-level product and plugin APIs and performs semantic
    planning; Swift Build schedules and executes the declared work.

The proposed solution first shows what package authors write. The detailed
design then specifies the changes to SwiftPM and the execution contract with
Swift Build.

Motivation

SwiftPM currently understands a small set of final product forms, principally
libraries, executables, and plugins. These cover products whose assembly SwiftPM
knows how to perform directly, but not products requiring platform- or
domain-specific finalization.

Examples include:

  • An embedded firmware product that links a Swift archive against a board SDK
    and emits .elf, .bin, and .uf2 files.
  • An Android application that packages compiled code and resources into an
    .apk or .aab.
  • An Apple application that combines compiled code, resource bundles, metadata,
    entitlements, and icons into an .app, .xcarchive, or .ipa.
  • A Windows application packaged as .msix.
  • A WebAssembly application bundle containing a module and static assets.
  • A locally generated container image or another domain-specific deployable
    archive.

SwiftPM can build the Swift portions of these products, but it cannot describe
their final artifact as a package product or delegate construction of that
artifact to an extension.

Existing workarounds

A package author can use a script, an external build system, or a command plugin
to build the Swift target and post-process its output. Those approaches are
useful, but the result is not a normal SwiftPM product:

  • swift build --product Firmware cannot mean “produce the firmware files.”
  • SwiftPM does not know the names or locations of the final outputs.
  • The post-processing operation is not automatically part of the same
    incremental build graph.
  • Target compilation, resource processing, and finalization must be sequenced
    manually.
  • IDEs and other SwiftPM clients cannot identify the generated artifacts as the
    outputs of the product.
  • Cleaning, diagnostics, and missing-output rebuilding require additional
    orchestration.

A build-tool plugin is also not a natural fit. Build-tool plugins are attached
to targets and primarily produce inputs, such as generated source and resource
files, that SwiftPM consumes while compiling the target. They do not define how
a named product is completed after its target closure has been compiled.

A command plugin can imperatively request a build and post-process a package on
demand. A product-builder plugin instead declares how a named product is
completed, allowing its dependencies, commands, and final outputs to be visible
to the build system.

Proposed solution

The declarations in this section are authored by packages using the new SwiftPM
APIs. They are not built-in knowledge of the UF2 format.

Later in the same manifest, the package author defines the typed helper:

import Foundation
import PackageDescription

public enum PicoBoard: String, Codable {
    case pico2
    case pico2W
}

private struct PicoUF2Configuration: Codable {
    var schemaVersion = 1
    var board: PicoBoard
}

public extension Product {
    static func picoUF2(
        name: String,
        target: String,
        board: PicoBoard
    ) -> Product {
        let data = try! JSONEncoder().encode(
            PicoUF2Configuration(board: board)
        )

        return .artifact(
            name: name,
            typeIdentifier: "pkg:swift/github.com/example/RP2350Support",
            targets: [target],
            builderPlugin: .pluginItem(
                name: "RP2350Builder",
                package: "RP2350Support"
            ),
            arguments: [String(decoding: data, as: UTF8.self)]
        )
    }
}

The support package vends the product-builder plugin and any host-side tools it
uses:

let package = Package(
    name: "RP2350Support",
    products: [
        .plugin(
            name: "RP2350Builder",
            targets: ["RP2350Builder"]
        ),
    ],
    targets: [
        .executableTarget(name: "FirmwareFinalizer"),
        .plugin(
            name: "RP2350Builder",
            capability: .productBuilder(),
            dependencies: ["FirmwareFinalizer"]
        ),
    ]
)

The plugin turns the typed definition’s serialized configuration and SwiftPM’s
predicted build inputs into a declarative plan:

import Foundation
import PackagePlugin

@main
struct RP2350Builder: ProductBuilderPlugin {
    func createBuildPlan(
        context: PluginContext,
        input: ProductBuilderInput
    ) async throws -> ProductBuilderPlan {
        guard input.typeIdentifier
            == "pkg:swift/github.com/example/RP2350Support"
        else {
            throw BuilderError.unsupportedProductType
        }

        let finalizer = try context.tool(named: "FirmwareFinalizer")
        let elf = input.outputDirectoryURL
            .appendingPathComponent("\(input.product.name).elf")
        let bin = input.outputDirectoryURL
            .appendingPathComponent("\(input.product.name).bin")
        let uf2 = input.outputDirectoryURL
            .appendingPathComponent("\(input.product.name).uf2")

        var arguments = [
            "--archive", input.aggregateStaticLibraryURL.path,
            "--target", input.targetTriple,
            "--configuration", input.buildConfiguration,
            "--elf", elf.path,
            "--bin", bin.path,
            "--uf2", uf2.path,
        ]
        for resource in input.resourceURLs {
            arguments += ["--resource", resource.path]
        }
        arguments += input.arguments

        return ProductBuilderPlan(
            commands: [
                .buildCommand(
                    displayName: "Finalizing \(input.product.name)",
                    executable: finalizer.url,
                    arguments: arguments,
                    inputFiles: [
                        input.aggregateStaticLibraryURL,
                    ] + input.resourceURLs,
                    outputFiles: [elf, bin, uf2]
                ),
            ],
            outputFiles: [elf, bin, uf2]
        )
    }
}

A normal product build then produces the declared artifacts:

$ swift build --product Firmware
Building for debugging...
Finalizing Firmware
Build complete!

SwiftPM controls the output location and incorporates the builder’s declared
output files into product completion.

Detailed design

Low-level manifest API

The following API is added to PackageDescription:

public struct ProductBuilderPluginReference: ExpressibleByStringLiteral {
    /// Refers to a plugin target or product in the package that declares
    /// the artifact product.
    public init(stringLiteral value: String)

    /// Refers to a plugin product vended by a package dependency.
    public static func pluginItem(
        name: String,
        package: String
    ) -> ProductBuilderPluginReference
}

extension Product {
    /// Defines a product whose final artifacts are produced by a
    /// package plugin.
    public static func artifact(
        name: String,
        typeIdentifier: String,
        targets: [String],
        builderPlugin: ProductBuilderPluginReference,
        arguments: [String] = []
    ) -> Product
}

An artifact product must contain at least one target.

typeIdentifier is a canonical Package URL
(purl) identifying the package that defines the product behavior.
For a source-control Swift package, it uses the pkg:swift type, the source host
and owner as its namespace, and the repository name as its package name. SwiftPM
passes the value to the builder without otherwise interpreting the product
behavior it identifies.

arguments are preserved exactly and passed to the builder. SwiftPM does not
interpret them. A typed definition and builder should establish a versioned
Codable schema rather than relying on unstructured flags.

Arguments are package configuration, not secret storage. They may appear in
manifest serialization, diagnostics, build descriptions, and cache keys.

The name artifact describes the distinguishing property of this low-level
product: its final artifact is defined externally. custom does not explain
what is customized, plugin is confusing because a plugin is itself a valid
product, and product is tautological at the call site. The declaration still
represents one product when its builder emits several related outputs, such as
ELF, BIN, and UF2 presentations of one firmware image.

Builder plugin declaration

A new plugin capability is added:

extension Target.PluginCapability {
    /// Declares that the plugin builds the final artifacts of an
    /// artifact product.
    public static func productBuilder() -> Target.PluginCapability
}

A product-builder plugin may depend on source-built executable targets or
binary executable tools in the same way as existing build-tool plugins. These
tools are built for, or selected for, the build host.

A string literal names a plugin target or plugin product in the package
declaring the artifact product:

builderPlugin: "LocalProductBuilder"

The structured form names a plugin product vended by a package dependency:

builderPlugin: .pluginItem(
    name: "RP2350Builder",
    package: "RP2350Support"
)

For the structured form:

  • The package must be a direct dependency of the declaring package.
  • name must identify a plugin product vended by that package.
  • The resolved plugin target must have the .productBuilder() capability.
  • Missing and ambiguous package or plugin references are diagnosed during
    package graph construction.

Plugin API

The following API is added to PackagePlugin:

public protocol ProductBuilderPlugin: Plugin {
    func createBuildPlan(
        context: PluginContext,
        input: ProductBuilderInput
    ) async throws -> ProductBuilderPlan
}

The product-specific input is:

public struct ProductBuilderInput {
    /// The artifact product and its target graph.
    public let product: Product

    /// The Package URL identifying the package that defines this
    /// product behavior.
    public let typeIdentifier: String

    /// A static archive containing the selected targets and their
    /// supported static source-target dependency closure.
    public let aggregateStaticLibraryURL: URL

    /// Exhaustive destination URLs of copied and processed resource files.
    public let resourceURLs: [URL]

    /// Opaque configuration supplied by the product declaration.
    public let arguments: [String]

    /// The only directory in which builder commands may create
    /// intermediate or final outputs.
    public let outputDirectoryURL: URL

    /// The destination build configuration, such as "debug" or
    /// "release".
    public let buildConfiguration: String

    /// The destination target triple.
    public let targetTriple: String
}

The plugin returns:

public struct ProductBuilderPlan {
    /// Explicit-input, explicit-output commands incorporated into
    /// the build graph.
    public let commands: [Command]

    /// The exhaustive file outputs required to complete the product.
    public let outputFiles: [URL]

    public init(
        commands: [Command],
        outputFiles: [URL] = []
    )
}

PluginContext continues to provide the resolved package graph, plugin work
directory, and access to declared tools.

SwiftPM validates that every command declares exhaustive file outputs and that
each output has one producer. A command may not declare the same file as both an
input and an output.

For a container artifact, a builder lists every required leaf in outputFiles.
For example, an application builder might declare Example.app/Example,
Example.app/Info.plist, and Example.app/Assets.car. The builder creates the
parent directories; Swift Build does not assign separate build semantics to the
container root.

Planning in SwiftPM and execution in Swift Build

This feature deliberately separates semantic package planning from build-system
scheduling:

  1. SwiftPM resolves the artifact product, target closure, and builder plugin.
  2. SwiftPM prepares the plugin and predicts the paths of its host-side tools,
    aggregate archive, resources, and owned output directory.
  3. SwiftPM invokes createBuildPlan(context:input:) while constructing the
    build description.
  4. SwiftPM validates the returned plan.
  5. SwiftPM makes the supported compiled target closure available as an implicit,
    private aggregate archive and adds the returned commands as product-completion
    work.
  6. Swift Build orders those commands after the archive, resource files, and
    builder tools; fingerprints their inputs and command signatures; and decides
    whether they need to run.
  7. The artifact product is complete when its declared output files are complete.

The callback runs during planning, before compilation. The archive and resource
URLs are predictions of where those inputs will exist during execution. The
callback must not inspect them. Its declared commands run only after the
archive, resources, and host tools exist.

SwiftPM semantic graph and plugin planning
                       |
                       v
       Swift Build configured target graph

Swift/C/C++ objects ──> implicit private archive ──┐
                                                   ├──> finalizer tasks
processed resource files ──────────────────────────┤          |
host-side builder tools ───────────────────────────┘          v
                                                      declared outputs

This is not a native LLBuild extension. Artifact products are diagnosed as
unsupported if the native backend is explicitly selected. The prototype also
does not lower them through the Xcode build-system adapter. Swift Build is the
execution model for this feature.

Version-one input and linkage contract

The first version provides one aggregate static archive plus processed
resources:

  • Swift, C, and C++ source targets in the selected target closure contribute
    compiled objects to the archive.
  • C-family headers remain compilation inputs. They are not separate inputs to
    the finalizer because the finalizer consumes compiled output, not source API.
  • Copied and processed resources are provided as an exhaustive list of their
    predicted destination file URLs.
  • The target graph remains available through input.product, allowing a typed
    builder to inspect semantic target relationships and names.

SwiftPM does not expose or replay its ordinary executable or library linker
invocation through this API. Both its native and Swift Build backends derive
those link settings internally, and treating them as a portable product-builder
contract would be misleading.

The builder therefore owns final linkage. It selects the linker driver,
platform SDK, runtime and standard-library inputs, linker flags, packaging
tools, and any post-link processing required by its format.

An aggregate archive cannot faithfully represent every dependency. Version one
diagnoses a selected closure containing:

  • a system-library target;
  • a dynamic-library product;
  • a binary library target;
  • an explicit linked library or framework; or
  • target-provided linker flags.

Supporting these cases later requires a richer, typed description of linkable
inputs and settings. Silently dropping them would produce artifacts that build
successfully but are incomplete.

Open design choice: target closures or product inputs

The API above names source targets. SwiftPM compiles their supported dependency
closure and creates an implicit, private aggregate archive solely as input to
the builder. The archive is documented build behavior, but it is not a
separately named, selectable, or vended package product.

This is the proposed default because the package author names only the artifact
they intend to vend. Requiring a separate static-library product would add a
second public product name and declaration for what is often an implementation
detail. It would also make ordinary consumers choose an intermediate linkage
form even when the platform-support author needs to own the final link.

An alternative is for .artifact to depend on one or more explicitly declared
products. That makes the intermediate assembly visible and allows a builder to
consume an ordinary static library, dynamic library, or executable. In
particular, an executable input would let SwiftPM own final linkage while the
builder performs only bundling or packaging. The cost is additional manifest
boilerplate and, under SwiftPM's current model, vending intermediate products
that may otherwise be private.

The proposal seeks feedback on whether product inputs should replace target
inputs, be an advanced alternative in the first version, or remain a future
extension. A related advanced API could expose predicted object-file URLs and
structured per-target link metadata. Those objects would be actual declared
command inputs, not opaque metadata; the aggregate archive remains the simpler
default contract.

Incremental builds

Swift Build includes the following in a product-builder task signature:

  • command line;
  • environment;
  • working directory;
  • input file paths;
  • output file paths; and
  • each path’s category.

Inputs and outputs are exhaustive file paths. Every file required for a
container such as Example.app must be declared as an input or output of the
appropriate command.

Consequently, an identical second build is a null build. The producer reruns
when source-derived archive contents, a resource file, typed configuration,
opaque arguments, the builder tool, or the command signature changes. Deleting
any declared output file also reruns its producer.

This first version does not model an output tree whose membership can be
discovered only after execution. A builder must predict its required leaf files
while planning or package dynamic contents into a declared file artifact.

Ambient state is not automatically tracked. A builder that depends on a file
must declare it as an input.

Code signing (out of scope)

This proposal does not define code-signing policy, credentials, provisioning,
or platform-specific signing APIs. A builder may invoke a signing tool as an
implementation detail of producing its artifact.

In-place mutation of a declared input is not supported. If a signing tool, such
as codesign, operates in place, the builder command must first copy the input
to a distinct output path and then sign that copy. The build graph therefore
sees an unsigned input and a separate signed output rather than one path
declared as both input and output.

Credentials do not belong in manifest arguments. Changes to ambient signing
state, such as a keychain identity, are outside the incremental guarantees of
this proposal.

Source compatibility

The proposal is additive and experimental. Existing package manifests and
plugins are unaffected. Artifact products require an explicit tools-version
feature opt-in while the API is being developed.

Security

Product builders follow the existing plugin trust and sandbox model. Planning
code receives only predicted paths and package metadata. Builder commands can
write only within their owned output area when sandboxing is active.

Generated artifacts may be executable, installable, or signed. Package authors
and clients should apply the same trust decisions to product-builder plugins and
their tools that they apply to build-tool and command plugins.

Alternatives considered

Teach SwiftPM every product format

Built-in support provides the strongest conventions for widely used product
types, but it cannot scale to every embedded board, platform bundle, installer,
or organization-specific format. Product builders do not prevent common formats
from later becoming built in.

Use a command plugin

A command plugin is appropriate for an explicitly invoked workflow. It does not
make the generated result a selected package product or naturally expose its
outputs and incremental dependencies to all SwiftPM clients.

Attach finalization to a target

Target plugins are well suited to generating compilation inputs. Final
packaging is often a property of how several targets are assembled and vended.
Attaching it only to one source target conflates module construction with output
presentation.

Name the primitive .custom

This emphasizes extensibility but not what is extended. It also makes the
untyped mechanism appear to be the user-facing feature. .artifact more
directly communicates that the declaration supplies an externally defined final
artifact.

Name the primitive .plugin

A plugin is already a valid SwiftPM product and target capability. The spelling
would be ambiguous about whether the result is a plugin or is built by one.

Name the primitive .product

Inside products: [...], this spelling provides no semantic information.
.artifact names the relevant distinction: an externally defined final
artifact.

Future directions

Package-provided typed manifest APIs

This proposal defines .picoUF2 in the consuming package’s manifest. A future
proposal could allow a dependency to vend typed manifest APIs so that support
packages can distribute helpers for their product builders. That requires a
separate design for dependency-resolution order, trust, host compatibility,
diagnostics, and manifest-cache invalidation.

Richer linkable inputs

A future API could describe system libraries, binary libraries, framework
search paths, SDK inputs, runtime objects, and structured linker settings. This
would permit builders to reproduce or intentionally transform more complex
link closures without scraping backend command lines.

Multiple destinations

Version one plans a product builder for one destination and configuration at a
time. Products that combine outputs from several destinations, such as
XCFrameworks, require coordination of multiple builds and matching their
platform, architecture, toolchain, and configuration settings.

Dependencies between produced artifacts

Some packaging pipelines may want one product builder to consume another
product’s declared output. This requires rules for product-level dependency
cycles, configuration matching, artifact identity, and whether distribution
outputs are valid compilation inputs.

More built-in typed products

Experience from product builders may identify formats with sufficiently stable
semantics to deserve first-class PackageDescription APIs. The generic mechanism
can serve as an experimentation path without committing SwiftPM itself to every
format.

5 Likes

Requiring more than a text editor and something that can build a Dockerfile/Containerfile is a horrible contributor experience.

Thanks for looking into this, this is a great idea in general!

And it's something we need to get rid of horrible hacks (and asking users to disable the sandbox) for swift-java builds, and I could imagine similar needs for e.g. python and building wheels there which wrap Swift code similar as we build Java jars which wrap Swift code.

I'd like to get my hands on the prototype because it's hard to really get a feel of these things without trying them out -- things get pretty messy with external builds.

I have some use-cases in mind which I'm not sure how we'd serve with this. For example, what if I have multiple build steps and I need to "build java" BEFORE I "build the rest of swift stuff" because there's a chain of events there:

  • analyze Swift sources
  • generate Java sources,
  • COMPILE JAVA sources (how would I trigger this plugin here, so I don't have to ask developers to disable sandbox), resulting in a jar/classpath,
  • then analyze the compiled java classes in order emit more Swift sources,
  • now the user defined Swift can compile.

It's an use-case tracked in Multi language builds: Complex multi-step source generators · Issue #10291 · swiftlang/swift-package-manager · GitHub and one of the major reasons we're forced into disabling the sandbox when developers need "callback" between languages functionality.

So I'm wondering if we need some notion of phases or "runs before Y" or something like that?
Or alternatively, if we can trigger those build steps programatically hm...

These get pretty messy, so I'd love to give it a spin with the prototype and see which direction might be most natural.

2 Likes

I'm actually looking at how to make things like this part of the build-system scheduling. I am learning a lot from my external package/builder work. You can see the current progress here, [WIP] External Packages by dschaefer2 · Pull Request #10198 · swiftlang/swift-package-manager · GitHub . Once I get the build system side of it working I'll write that up.

4 Likes

In general, if you're producing artifacts as the result of a build, it really should be done by the build system. There's a lot of advantages by having it schedule tasks and maximize parallelism.

I think we're only a concept or two away to letting plugins produce the output of targets/products. I'd much rather we pursue that.

2 Likes

Hello all,

Here is a working prototype, I've opened 3 PRs on my own fork to hopefully make pieces easier to read. This implementation has been generated with Codex, no intention to request to merge this as-is. If the pitch makes progress then I'll start moving this to a formal implementation.

Here is the main implementation:

Test coverage:

iOS xcarchive example:

Thanks! I just posted the prototype, hope it helps clarifying the scope of the proposal.

Your example also helps clarify the boundary of what I’m proposing. This first version is specifically a product finalizer: SwiftPM builds the selected target closure into an aggregate archive, processes its resources, and then Swift Build runs the product-builder commands to produce the final artifact. It can express multiple post-compilation steps, but it cannot currently insert work back into the compilation of those targets.

So the Java callback workflow is a broader problem than this proposal currently solves. The generated Swift is an input to compilation, whereas a product builder runs after that compilation has completed.

I think your use case is adjacent and may point toward a more general capability for plugins to provide target inputs or entire target implementations. I’d be very interested to see whether the product-finalization prototype is a useful building block for that, or whether the two mechanisms should remain separate.

Thanks, Doug. I think we may be closer here than the wording in the proposal makes apparent.

In the prototype, SwiftPM resolves the semantic package graph and invokes the plugin’s planning callback while generating PIF, but it does not execute the builder commands. Those commands are serialized as Swift Build custom tasks on an aggregate finalizer target. That target depends on the compiled archive, resource-processing targets, and host tools, so Swift Build owns their scheduling, incremental execution, missing-output detection, and product completion.

One of the main Swift Build changes I needed was teaching aggregate targets to schedule their custom tasks and include those tasks in target completion. That appears to overlap directly with the work in swift-build#1493.

I think the more interesting distinction is whether ProductBuilderPlugin should exist as a specialized product-finalization capability, or whether this should instead fall out of a general mechanism where plugins provide the outputs—or possibly the complete implementation—of targets and products.

The constrained model currently gives the builder a private aggregate archive by default, which makes the common finalization case straightforward:

.artifact(targets: ["FirmwareCore"], builderPlugin: ...)

A more general custom-target model could potentially subsume that, with the archive being one convenient default input rather than the underlying abstraction. It could also better accommodate external builds that produce a library directly, without first compiling SwiftPM targets into an archive. I agree that the destination should be first-class build-system tasks; the main design question seems to be whether product finalization is its own API or one specialization of plugin-provided targets/products.

Hope this helps clarifying the scope!