From 505689326f72c069da8587d0650f1b3075186ab7 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 26 Aug 2026 18:23:35 -0700 Subject: [PATCH 1/6] Raise the SwiftPM package minimum macOS to 14 The published SwiftPM manifest is generated from this template, so the platform floor a consumer sees is set here, not in the main branch's Package.swift. Raising it to macOS 14 is what lets the MLX backend be offered through the package. MLX requires a macOS 14 deployment target and fails to build below it, so the package that ships MLX must declare at least that. iOS is already at 17, which is above MLX's iOS floor, so only macOS moves. This drops support for macOS 12 and 13 for the whole package, including consumers that only use XNNPACK or Core ML. macOS 14 shipped in September 2023. Test Plan: Materialized the template the way the release job does, substituting the version and checksum placeholders, and confirmed `swift package dump-package` parses it and reports the macOS platform as 14.0. --- Package.swift.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift.template b/Package.swift.template index e83c9384651..d5ac0141117 100644 --- a/Package.swift.template +++ b/Package.swift.template @@ -138,7 +138,7 @@ let package = Package( name: "executorch", platforms: [ .iOS(.v17), - .macOS(.v12), + .macOS(.v14), ], products: packageProducts, targets: packageTargets From 70da29f3797884c65d5d792574ccfc40feb56b63 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 26 Aug 2026 21:22:50 -0700 Subject: [PATCH 2/6] Add the MLX backend product to the SwiftPM package template Companion to the change that adds MLX to the Apple frameworks. The published SwiftPM manifest is generated from this template, so the backend_mlx product and its resource bundle have to be declared here too, or the released package would be missing them. Adds the backend_mlx product with its Metal, Foundation and QuartzCore framework links, and a shared backend_mlx_resources target that carries the per-platform Metal kernel libraries. The release job commits those libraries to this branch beside the manifest. Each is included only when present so the manifest still resolves before the release job has produced them. --- .Package.swift/backend_mlx/dummy.swift | 0 .Package.swift/backend_mlx_debug/dummy.swift | 0 .../backend_mlx_resources/dummy.swift | 0 Package.swift.template | 37 +++++++++++++++++++ 4 files changed, 37 insertions(+) create mode 100644 .Package.swift/backend_mlx/dummy.swift create mode 100644 .Package.swift/backend_mlx_debug/dummy.swift create mode 100644 .Package.swift/backend_mlx_resources/dummy.swift diff --git a/.Package.swift/backend_mlx/dummy.swift b/.Package.swift/backend_mlx/dummy.swift new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.Package.swift/backend_mlx_debug/dummy.swift b/.Package.swift/backend_mlx_debug/dummy.swift new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.Package.swift/backend_mlx_resources/dummy.swift b/.Package.swift/backend_mlx_resources/dummy.swift new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Package.swift.template b/Package.swift.template index d5ac0141117..2888b5578f7 100644 --- a/Package.swift.template +++ b/Package.swift.template @@ -8,6 +8,7 @@ */ import PackageDescription +import Foundation let version = "__VERSION__" let url = "https://ossci-ios.s3.amazonaws.com/executorch/" @@ -46,6 +47,15 @@ let products = deliverables([ "sqlite3", ], ], + "backend_mlx": [ + "sha256": "__SHA256_backend_mlx__", + "sha256" + debug_suffix: "__SHA256_backend_mlx_debug__", + "frameworks": [ + "Metal", + "Foundation", + "QuartzCore", + ], + ], "backend_xnnpack": [ "sha256": "__SHA256_backend_xnnpack__", "sha256" + debug_suffix: "__SHA256_backend_xnnpack_debug__", @@ -134,6 +144,33 @@ for (key, value) in products { packageTargets.append(target) } +// The MLX Metal kernel libraries, one per platform slice, shipped as a single +// resource bundle both MLX products share. Kept out of the generic loop above so +// there is one bundle (executorch_backend_mlx_resources.bundle) rather than a +// separate debug copy, and so the release and debug delegates resolve the same +// name. Each slice's MLX binary asks for its own mlx-.metallib. The files +// are committed to this branch by the release job and gitignored elsewhere, so +// each is included only when present. +let mlxMetallibSlices = ["mlx-ios", "mlx-ios-simulator", "mlx-macos"] +let mlxResourcesDir = ".Package.swift/backend_mlx_resources" +if products.keys.contains("backend_mlx") { + packageTargets.append(.target( + name: "backend_mlx_resources", + path: mlxResourcesDir, + resources: mlxMetallibSlices.compactMap { slice in + FileManager.default.fileExists(atPath: "\(mlxResourcesDir)/\(slice).metallib") + ? .copy("\(slice).metallib") : nil + } + )) + for suffix in ["", debug_suffix] { + if let index = packageTargets.firstIndex(where: { + $0.name == "backend_mlx\(suffix)\(dependencies_suffix)" + }) { + packageTargets[index].dependencies.append(.target(name: "backend_mlx_resources")) + } + } +} + let package = Package( name: "executorch", platforms: [ From de500c0d41a32e1ba8bcb48863f1723632ad6f4d Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Fri, 28 Aug 2026 09:32:06 -0700 Subject: [PATCH 3/6] Ship the MLX metallibs to consumers of the published package The resource list was gated on FileManager.fileExists with a path relative to the process working directory. SwiftPM evaluates a dependency's manifest with the consumer's directory as the cwd, so every slice looked absent, the list came out empty, and no bundle was produced at all. The build stayed green and the MLX delegate faulted later at Metal device init, with nothing pointing at the cause. Measured from a consumer package: 0 of 3 metallibs shipped before, 3 of 3 after, in executorch_backend_mlx_resources.bundle, which is the name the delegate is compiled to look for. An in-place build and swift package dump-package both reported the resources correctly either way, which is why this was not caught. The release job commits all three files before publishing, so declare them unconditionally rather than probing for them. Foundation is no longer needed. --- Package.swift.template | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/Package.swift.template b/Package.swift.template index 2888b5578f7..fca503efd28 100644 --- a/Package.swift.template +++ b/Package.swift.template @@ -8,7 +8,6 @@ */ import PackageDescription -import Foundation let version = "__VERSION__" let url = "https://ossci-ios.s3.amazonaws.com/executorch/" @@ -148,19 +147,24 @@ for (key, value) in products { // resource bundle both MLX products share. Kept out of the generic loop above so // there is one bundle (executorch_backend_mlx_resources.bundle) rather than a // separate debug copy, and so the release and debug delegates resolve the same -// name. Each slice's MLX binary asks for its own mlx-.metallib. The files -// are committed to this branch by the release job and gitignored elsewhere, so -// each is included only when present. +// name. Each slice's MLX binary asks for its own mlx-.metallib. +// +// The release job commits all three files to this branch before publishing, so +// they are declared unconditionally: a slice that failed to arrive is then +// reported as "Invalid Resource ... File not found" at resolve time, rather than +// silently producing a bundle with no kernels that the delegate would only fault +// on at Metal device init inside a consumer's app. +// Do not gate these on FileManager.fileExists: a relative path is resolved +// against the process working directory, and when this package is a dependency +// that is the consumer's root, so every slice would appear missing and the +// bundle would ship empty. let mlxMetallibSlices = ["mlx-ios", "mlx-ios-simulator", "mlx-macos"] let mlxResourcesDir = ".Package.swift/backend_mlx_resources" if products.keys.contains("backend_mlx") { packageTargets.append(.target( name: "backend_mlx_resources", path: mlxResourcesDir, - resources: mlxMetallibSlices.compactMap { slice in - FileManager.default.fileExists(atPath: "\(mlxResourcesDir)/\(slice).metallib") - ? .copy("\(slice).metallib") : nil - } + resources: mlxMetallibSlices.map { .copy("\($0).metallib") } )) for suffix in ["", debug_suffix] { if let index = packageTargets.firstIndex(where: { From 0e56f2b8078701b6673b0cbe817010701286ddf0 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Fri, 28 Aug 2026 10:14:56 -0700 Subject: [PATCH 4/6] Say when a missing metallib is actually reported The comment said a missing slice is reported at resolve time. Measured: swift package resolve emits nothing at all, and the "Invalid Resource ... File not found" warning appears when a consumer builds. It is also only a warning, so the build still succeeds; the change makes the failure visible, not fatal. --- Package.swift.template | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Package.swift.template b/Package.swift.template index fca503efd28..e21a78e80e9 100644 --- a/Package.swift.template +++ b/Package.swift.template @@ -150,10 +150,11 @@ for (key, value) in products { // name. Each slice's MLX binary asks for its own mlx-.metallib. // // The release job commits all three files to this branch before publishing, so -// they are declared unconditionally: a slice that failed to arrive is then -// reported as "Invalid Resource ... File not found" at resolve time, rather than -// silently producing a bundle with no kernels that the delegate would only fault -// on at Metal device init inside a consumer's app. +// they are declared unconditionally: a slice that failed to arrive then shows up +// as an "Invalid Resource ... File not found" warning when a consumer builds, +// rather than silently producing a bundle with no kernels that the delegate would +// only fault on at Metal device init. The warning is not an error and the build +// still succeeds, so this makes the failure visible rather than fatal. // Do not gate these on FileManager.fileExists: a relative path is resolved // against the process working directory, and when this package is a dependency // that is the consumer's root, so every slice would appear missing and the From 2a3daa54346c38c3e09798eeb1a12dc883bdb711 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Fri, 28 Aug 2026 10:21:04 -0700 Subject: [PATCH 5/6] Inline the single-use resources path Every other target in this manifest inlines its path, and this local was referenced once. --- Package.swift.template | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Package.swift.template b/Package.swift.template index e21a78e80e9..3446202f5e6 100644 --- a/Package.swift.template +++ b/Package.swift.template @@ -160,11 +160,10 @@ for (key, value) in products { // that is the consumer's root, so every slice would appear missing and the // bundle would ship empty. let mlxMetallibSlices = ["mlx-ios", "mlx-ios-simulator", "mlx-macos"] -let mlxResourcesDir = ".Package.swift/backend_mlx_resources" if products.keys.contains("backend_mlx") { packageTargets.append(.target( name: "backend_mlx_resources", - path: mlxResourcesDir, + path: ".Package.swift/backend_mlx_resources", resources: mlxMetallibSlices.map { .copy("\($0).metallib") } )) for suffix in ["", debug_suffix] { From cf3a7d490f973ab181b8177710b774a9683efc4b Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Fri, 28 Aug 2026 10:22:51 -0700 Subject: [PATCH 6/6] Do not tell the reader the sibling manifest is wrong The comment said not to gate on FileManager.fileExists, but the main-side manifest does exactly that and is correct, because it anchors the path to #filePath. Name the real pitfall, a bare relative path, and point at the anchoring instead of forbidding the API. --- Package.swift.template | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Package.swift.template b/Package.swift.template index 3446202f5e6..1ff5bf3c5d6 100644 --- a/Package.swift.template +++ b/Package.swift.template @@ -150,15 +150,15 @@ for (key, value) in products { // name. Each slice's MLX binary asks for its own mlx-.metallib. // // The release job commits all three files to this branch before publishing, so -// they are declared unconditionally: a slice that failed to arrive then shows up -// as an "Invalid Resource ... File not found" warning when a consumer builds, -// rather than silently producing a bundle with no kernels that the delegate would -// only fault on at Metal device init. The warning is not an error and the build -// still succeeds, so this makes the failure visible rather than fatal. -// Do not gate these on FileManager.fileExists: a relative path is resolved -// against the process working directory, and when this package is a dependency -// that is the consumer's root, so every slice would appear missing and the -// bundle would ship empty. +// there is no need to probe for them: they are declared unconditionally. A slice +// that failed to arrive then shows up as an "Invalid Resource ... File not found" +// warning when a consumer builds. That is a warning and the build still succeeds, +// so it makes the absence visible rather than fatal; the release job is the place +// to assert the files arrived. +// If a probe is ever added here, anchor it to this file's own directory the way +// the main-side manifest does, with URL(fileURLWithPath: #filePath). A bare +// relative path is resolved against the process working directory, which for a +// dependency is the consumer's root, so every slice would appear missing. let mlxMetallibSlices = ["mlx-ios", "mlx-ios-simulator", "mlx-macos"] if products.keys.contains("backend_mlx") { packageTargets.append(.target(