2 Commits

Author SHA1 Message Date
T. R. Bernstein
541b9d68a0 Use Shwift library instead of Subprocess
Some checks failed
Docs / docs (push) Has been cancelled
Docs / deploy (push) Has been cancelled
Shwift has a concise API, which makes writing shell code nice and easy.
This is an opinionated decision.
2026-03-20 21:44:05 +01:00
T. R. Bernstein
55f3ca2f7b Temporary fix of SwiftPM Bug using task.sh
Some checks failed
Docs / docs (push) Has been cancelled
Docs / deploy (push) Has been cancelled
SwiftPM has currently a bug, that products or targets of dependencies
are taken into consideration when resolving names, regardless if they're
used or not by the root package. This stops Swift PM from working on packages,
that declare this package as a dependency and define their own TaskCLI target,
as they collide with the definitions of this package. This is resolved, by
prefixing TaskCLI with the package name.
The product collision - which causes swift run - to run this package's task
executable is resolved, by adding that product only temporarily during task
execution using task.sh.

See https://github.com/swiftlang/swift-package-manager/issues/8482
2026-03-20 21:10:01 +01:00
11 changed files with 149 additions and 111 deletions

View File

@@ -30,7 +30,7 @@ jobs:
- name: Generate Docs - name: Generate Docs
run: | run: |
swift package add-dependency --from 1.4.0 "https://github.com/apple/swift-docc-plugin.git" swift package add-dependency --from 1.4.0 "https://github.com/apple/swift-docc-plugin.git"
for target in Inotify TaskCLI; do for target in Inotify InotifyTaskCLI; do
lower="${target,,}" lower="${target,,}"
mkdir -p "./public/$lower" mkdir -p "./public/$lower"
swift package --allow-writing-to-directory "./public/$lower" \ swift package --allow-writing-to-directory "./public/$lower" \
@@ -44,7 +44,7 @@ jobs:
cp ./.github/workflows/index.tpl.html public/index.html cp ./.github/workflows/index.tpl.html public/index.html
sed -i -e 's/{{project.name}}/Swift Inotify/g' public/index.html sed -i -e 's/{{project.name}}/Swift Inotify/g' public/index.html
sed -i -e 's/{{project.tagline}}/🗂️ Monitor filesystem events on Linux using modern Swift concurrency/g' public/index.html sed -i -e 's/{{project.tagline}}/🗂️ Monitor filesystem events on Linux using modern Swift concurrency/g' public/index.html
sed -i -e 's|{{project.links}}|<li><a href="inotify/documentation/inotify/">Inotify</a>: The actual library.</li><li><a href="taskcli/documentation/taskcli/">TaskCLI</a>: The project build command.</li>|g' public/index.html sed -i -e 's|{{project.links}}|<li><a href="inotify/documentation/inotify/">Inotify</a>: The actual library.</li><li><a href="inotifytaskcli/documentation/inotifytaskcli/">TaskCLI</a>: The project build command.</li>|g' public/index.html
- name: Upload artifact - name: Upload artifact
uses: actions/upload-pages-artifact@v4 uses: actions/upload-pages-artifact@v4
with: with:

View File

@@ -1,5 +1,5 @@
{ {
"originHash" : "fd1e824e418c767633bb79b055a4e84d9c86165746bc881d5d27457ad34b0c20", "originHash" : "0cb2e87817f52021ac25ffee6b27396f6d94e9fd604ca83db7f20a10e65fe6cf",
"pins" : [ "pins" : [
{ {
"identity" : "noora", "identity" : "noora",
@@ -28,6 +28,15 @@
"version" : "4.2.1" "version" : "4.2.1"
} }
}, },
{
"identity" : "shwift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/GeorgeLyon/Shwift",
"state" : {
"revision" : "d7be04898d094ddce6140cc6a2e9a83fc994b66d",
"version" : "3.1.1"
}
},
{ {
"identity" : "swift-argument-parser", "identity" : "swift-argument-parser",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -37,15 +46,6 @@
"version" : "1.7.0" "version" : "1.7.0"
} }
}, },
{
"identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms",
"state" : {
"revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272",
"version" : "1.1.3"
}
},
{ {
"identity" : "swift-atomics", "identity" : "swift-atomics",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -82,15 +82,6 @@
"version" : "2.95.0" "version" : "2.95.0"
} }
}, },
{
"identity" : "swift-subprocess",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-subprocess.git",
"state" : {
"revision" : "ba5888ad7758cbcbe7abebac37860b1652af2d9c",
"version" : "0.3.0"
}
},
{ {
"identity" : "swift-system", "identity" : "swift-system",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

View File

@@ -8,19 +8,13 @@ let package = Package(
.library( .library(
name: "Inotify", name: "Inotify",
targets: ["Inotify"] targets: ["Inotify"]
),
.executable(
name: "task",
targets: ["TaskCLI"]
) )
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.7.0"),
.package(url: "https://github.com/apple/swift-async-algorithms", from: "1.1.3"),
.package(url: "https://github.com/apple/swift-log", from: "1.10.1"), .package(url: "https://github.com/apple/swift-log", from: "1.10.1"),
.package(url: "https://github.com/apple/swift-nio", from: "2.95.0"), .package(url: "https://github.com/apple/swift-nio", from: "2.95.0"),
.package(url: "https://github.com/apple/swift-system", from: "1.6.4"), .package(url: "https://github.com/apple/swift-system", from: "1.6.4"),
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.3.0"), .package(url: "https://github.com/GeorgeLyon/Shwift", from: "3.1.1"),
.package(url: "https://github.com/tuist/Noora", from: "0.55.1") .package(url: "https://github.com/tuist/Noora", from: "0.55.1")
], ],
targets: [ targets: [
@@ -42,15 +36,14 @@ let package = Package(
], ],
), ),
.executableTarget( .executableTarget(
name: "TaskCLI", name: "InotifyTaskCLI",
dependencies: [ dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
.product(name: "Logging", package: "swift-log"), .product(name: "Logging", package: "swift-log"),
.product(name: "_NIOFileSystem", package: "swift-nio"), .product(name: "_NIOFileSystem", package: "swift-nio"),
.product(name: "Subprocess", package: "swift-subprocess"), .product(name: "Script", package: "Shwift"),
.product(name: "Noora", package: "Noora") .product(name: "Noora", package: "Noora")
] ],
path: "Sources/TaskCLI"
) )
] ]
) )

View File

@@ -132,11 +132,14 @@ try inotify.removeWatch(wd)
## Build Tool ## Build Tool
The package ships with a `task` executable (the `TaskCLI` target) that serves as the project's build tool. It automates running tests and generating documentation inside Linux Docker containers, so you can validate everything on the correct platform even when developing on macOS. The package ships with a `task` executable (the `TaskCLI` target) that serves as the project's build tool. It automates running tests and generating documentation inside Linux Docker containers, so you can validate everything on the correct platform even when developing on macOS.
Because of a Swift Package Manager Bug in the [package dependency resolution][swiftpm-bug], the executable needs to be run using the `task.sh` shell script.
[swiftpm-bug]: https://github.com/swiftlang/swift-package-manager/issues/8482
### Tests ### Tests
```bash ```bash
swift run task test ./task.sh test
``` ```
Use `-v`, `-vv`, or `-vvv` to increase log verbosity. The command runs two passes: first all tests except `InotifyLimitTests`, then only `InotifyLimitTests` (which manipulate system-level inotify limits and need to run in isolation). Use `-v`, `-vv`, or `-vvv` to increase log verbosity. The command runs two passes: first all tests except `InotifyLimitTests`, then only `InotifyLimitTests` (which manipulate system-level inotify limits and need to run in isolation).
@@ -148,7 +151,7 @@ Docker must be installed and running on your machine.
Full API documentation is available as DocC catalogs bundled with the package. Generate them locally with: Full API documentation is available as DocC catalogs bundled with the package. Generate them locally with:
```bash ```bash
swift run task generate-docs ./task.sh generate-docs
``` ```
Then open the files in the newly created `public` folder. Then open the files in the newly created `public` folder.

View File

@@ -1,4 +1,4 @@
import ArgumentParser import Script
@main @main
struct Command: AsyncParsableCommand { struct Command: AsyncParsableCommand {

View File

@@ -3,27 +3,15 @@ import _NIOFileSystem
public struct DoccFinder { public struct DoccFinder {
static let fileManager = FileSystem.shared static let fileManager = FileSystem.shared
public static func getTargetsWithDocumentation(at paths: String...) async throws -> [String] { public static func hasDoccFolder(at path: String) async throws -> Bool {
try await Self.getTargetsWithDocumentation(at: paths) let itemPath = FilePath(path)
} var hasDoccFolder = false
static func getTargetsWithDocumentation(at paths: [String]) async throws -> [String] { try await withSubdirectories(at: itemPath) { subdirectory in
var resolved: [String] = [] guard subdirectory.description.hasSuffix(".docc") else { return }
hasDoccFolder = true
for path in paths {
let itemPath = FilePath(path)
try await withSubdirectories(at: itemPath) { targetPath in
print("Target path is", targetPath.description)
try await withSubdirectories(at: targetPath) { subdirectory in
guard subdirectory.description.hasSuffix(".docc") else { return }
guard let target = targetPath.lastComponent?.description else { return }
resolved.append(target)
}
}
} }
return hasDoccFolder
return resolved
} }
private static func withSubdirectories(at path: FilePath, body: (FilePath) async throws -> Void) async throws { private static func withSubdirectories(at path: FilePath, body: (FilePath) async throws -> Void) async throws {

View File

@@ -1,11 +1,9 @@
import ArgumentParser
import AsyncAlgorithms
import Foundation import Foundation
import Logging import Logging
import Script
import Noora import Noora
import Subprocess
struct GenerateDocumentationCommand: AsyncParsableCommand { struct GenerateDocumentationCommand: Script {
static let configuration = CommandConfiguration( static let configuration = CommandConfiguration(
commandName: "generate-documentation", commandName: "generate-documentation",
abstract: "Generate DocC documentation of all targets inside a Linux container.", abstract: "Generate DocC documentation of all targets inside a Linux container.",
@@ -25,6 +23,7 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
let logger = global.makeLogger(labeled: "swift-inotify.cli.task.generate-documentation") let logger = global.makeLogger(labeled: "swift-inotify.cli.task.generate-documentation")
let fileManager = FileManager.default let fileManager = FileManager.default
let projectDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath) let projectDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath)
let docker = try await executable(named: "docker")
let targets = try await Self.targets(for: projectDirectory) let targets = try await Self.targets(for: projectDirectory)
@@ -43,28 +42,16 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
let script = Self.makeRunScript(for: targets) let script = Self.makeRunScript(for: targets)
logger.debug("Container script", metadata: ["script": "\(script)"]) logger.debug("Container script", metadata: ["script": "\(script)"])
let dockerResult = try await Subprocess.run( do {
.name("docker"), try await docker(
arguments: [
"run", "--rm", "run", "--rm",
"-v", "\(tempDirectory.path(percentEncoded: false)):/code", "-v", "\(tempDirectory.path):/code",
"--platform", "linux/arm64", "--platform", "linux/arm64",
"-w", "/code", "-w", "/code",
"swift:latest", "swift:latest",
"/bin/bash", "-c", script, "/bin/bash", "-c", script,
], )
preferredBufferSize: 10, } catch {
) { execution, standardInput, standardOutput, standardError in
print("")
let stdout = standardOutput.lines()
let stderr = standardError.lines()
for try await line in merge(stdout, stderr) {
noora.passthrough("\(line)")
}
print("")
}
guard dockerResult.terminationStatus.isSuccess else {
noora.error("Documentation generation failed.") noora.error("Documentation generation failed.")
return return
} }
@@ -90,7 +77,7 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
("{{project.tagline}}", "🗂️ Monitor filesystem events on Linux using modern Swift concurrency"), ("{{project.tagline}}", "🗂️ Monitor filesystem events on Linux using modern Swift concurrency"),
("{{project.links}}", """ ("{{project.links}}", """
<li><a href="inotify/documentation/inotify/">Inotify</a>: The actual library.</li>\ <li><a href="inotify/documentation/inotify/">Inotify</a>: The actual library.</li>\
<li><a href="taskcli/documentation/taskcli/">TaskCLI</a>: The project build command.</li> <li><a href="inotifytaskcli/documentation/inotifytaskcli/">TaskCLI</a>: The project build command.</li>
"""), """),
] ]
@@ -106,9 +93,32 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
} }
private static func targets(for projectDirectory: URL) async throws -> [String] { private static func targets(for projectDirectory: URL) async throws -> [String] {
let sourcesDirectory = projectDirectory.appending(path: "Sources").path let packages = try await Self.packageTargets()
let testsDirectory = projectDirectory.appending(path: "Tests").path var packagesWithDoccFolder: [(name: String, path: String)] = []
return try await DoccFinder.getTargetsWithDocumentation(at: sourcesDirectory, testsDirectory) for package in packages {
guard try await DoccFinder.hasDoccFolder(at: package.path) else { continue }
packagesWithDoccFolder.append(package)
}
return packagesWithDoccFolder.map { $0.name }
}
private static func packageTargets() async throws -> [(name: String, path: String)] {
let swift = try await executable(named: "swift")
let packageDescriptionOutput = try await outputOf {
try await swift("package", "describe", "--type", "json")
}
struct PackageDescription: Codable {
let targets: [Target]
}
struct Target: Codable {
let name: String
let path: String
}
let data = Data(packageDescriptionOutput.utf8)
let package = try JSONDecoder().decode(PackageDescription.self, from: data)
return package.targets.map { ($0.name, $0.path) }
} }
private static func makeRunScript(for targets: [String]) -> String { private static func makeRunScript(for targets: [String]) -> String {
@@ -152,15 +162,13 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
// MARK: - Dependency Injection // MARK: - Dependency Injection
private func injectDoccPluginDependency(in directory: URL, logger: Logger) async throws { private func injectDoccPluginDependency(in directory: URL, logger: Logger) async throws {
let result = try await Subprocess.run( let swift = try await executable(named: "swift")
.name("swift"), do {
arguments: [ try await swift(
"package", "--package-path", directory.path(percentEncoded: false), "package", "--package-path", directory.path(percentEncoded: false),
"add-dependency", "--from", Self.doccPluginMinVersion, Self.doccPluginURL "add-dependency", "--from", Self.doccPluginMinVersion, Self.doccPluginURL
], )
) { _ in } } catch {
guard result.terminationStatus.isSuccess else {
throw GenerateDocumentationError.dependencyInjectionFailed throw GenerateDocumentationError.dependencyInjectionFailed
} }

View File

@@ -1,4 +1,4 @@
import ArgumentParser import Script
import Logging import Logging
struct GlobalOptions: ParsableArguments { struct GlobalOptions: ParsableArguments {

View File

@@ -1,4 +1,4 @@
# ``TaskCLI`` # ``InotifyTaskCLI``
The build tool for the Swift Inotify project. The build tool for the Swift Inotify project.
@@ -6,10 +6,14 @@ The build tool for the Swift Inotify project.
`TaskCLI` is a small command-line executable (exposed as `task` in `Package.swift`) that automates project-level workflows. Its primary purpose is running integration tests and generating documentation inside Linux Docker containers, so you can validate inotify-dependent code on the correct platform even when developing on macOS. `TaskCLI` is a small command-line executable (exposed as `task` in `Package.swift`) that automates project-level workflows. Its primary purpose is running integration tests and generating documentation inside Linux Docker containers, so you can validate inotify-dependent code on the correct platform even when developing on macOS.
Because of a Swift Package Manager Bug in the [package dependency resolution][swiftpm-bug], the executable needs to be run using the `task.sh` shell script.
[swiftpm-bug]: https://github.com/swiftlang/swift-package-manager/issues/8482
### Running the Tests ### Running the Tests
```bash ```bash
swift run task test ./task.sh test
``` ```
This launches a `swift:latest` Docker container with the repository mounted at `/code`, then executes two test passes: This launches a `swift:latest` Docker container with the repository mounted at `/code`, then executes two test passes:
@@ -22,7 +26,7 @@ The container is started with `--security-opt systempaths=unconfined` so that th
### Generating Documentation ### Generating Documentation
```bash ```bash
swift run task generate-documentation ./task.sh generate-documentation
``` ```
This copies the project to a temporary directory, injects the `swift-docc-plugin` dependency via `swift package add-dependency` (if absent), and runs documentation generation inside a `swift:latest` Docker container. The resulting static sites are written to `./public/inotify/` and `./public/taskcli/`, ready for deployment to GitHub Pages. This copies the project to a temporary directory, injects the `swift-docc-plugin` dependency via `swift package add-dependency` (if absent), and runs documentation generation inside a `swift:latest` Docker container. The resulting static sites are written to `./public/inotify/` and `./public/taskcli/`, ready for deployment to GitHub Pages.

View File

@@ -1,10 +1,8 @@
import ArgumentParser
import AsyncAlgorithms
import Foundation import Foundation
import Subprocess import Script
import Noora import Noora
struct TestCommand: AsyncParsableCommand { struct TestCommand: Script {
static let configuration = CommandConfiguration( static let configuration = CommandConfiguration(
commandName: "test", commandName: "test",
abstract: "Run swift test in a linux container.", abstract: "Run swift test in a linux container.",
@@ -19,26 +17,21 @@ struct TestCommand: AsyncParsableCommand {
let noora = Noora() let noora = Noora()
let logger = global.makeLogger(labeled: "swift-inotify.cli.task.test") let logger = global.makeLogger(labeled: "swift-inotify.cli.task.test")
let currentDirectory = FileManager.default.currentDirectoryPath let currentDirectory = FileManager.default.currentDirectoryPath
let docker = Executable(path: "/opt/homebrew/bin/docker")
noora.info("Running tests on Linux.") noora.info("Running tests on Linux.")
logger.debug("Current directory", metadata: ["current-directory": "\(currentDirectory)"]) logger.debug("Current directory", metadata: ["current-directory": "\(currentDirectory)"])
async let monitorResult = Subprocess.run( do {
.name("docker"), try await docker(
arguments: ["run", "-v", "\(currentDirectory):/code", "--security-opt", "systempaths=unconfined", "--platform", "linux/arm64", "-w", "/code", "swift:latest", "/bin/bash", "-c", "swift test --skip InotifyLimitTests; swift test --skip-build --filter InotifyLimitTests"], "run",
preferredBufferSize: 10, "-v", "\(currentDirectory):/code",
) { execution, standardInput, standardOutput, standardError in "--security-opt", "systempaths=unconfined",
print("") "--platform", "linux/arm64",
let stdout = standardOutput.lines() "-w", "/code", "swift:latest",
let stderr = standardError.lines() "/bin/bash", "-c", "swift test --skip InotifyLimitTests; swift test --skip-build --filter InotifyLimitTests"
for try await line in merge(stdout, stderr) { )
noora.passthrough("\(line)")
}
print("")
}
if (try await monitorResult.terminationStatus.isSuccess) {
noora.success("All tests completed successfully.") noora.success("All tests completed successfully.")
} else { } catch {
noora.error("Not all tests completed successfully.") noora.error("Not all tests completed successfully.")
} }
} }

58
task.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env zsh
# task - Run the package's TaskCLI target via a transient "task" product.
#
# Works around https://github.com/swiftlang/swift-package-manager/issues/8482
# by temporarily adding an executable product named "task" to Package.swift,
# running it with `swift run`, and restoring the original manifest afterwards.
#
# Usage: task [arguments...]
#
# The script auto-detects the package name from Package.swift and expects an
# executable target named "<PackageName>TaskCLI" to exist.
set -euo pipefail
# --- Resolve the package root (search upward for Package.swift) -----------
package_root="${PWD}"
while [[ ! -f "${package_root}/Package.swift" ]]; do
package_root="${package_root:h}" # zsh dirname
if [[ "${package_root}" == "/" ]]; then
echo "error: Could not find Package.swift in any parent directory." >&2
exit 1
fi
done
manifest="${package_root}/Package.swift"
backup="${manifest}.task-backup"
# --- Extract the package name ---------------------------------------------
package_name=$(sed -n 's/^.*name:[[:space:]]*"\([^"]*\)".*/\1/p' "${manifest}" | head -1)
if [[ -z "${package_name}" ]]; then
echo "error: Could not determine package name from ${manifest}." >&2
exit 1
fi
target_name="${package_name}TaskCLI"
# --- Cleanup trap (runs on EXIT — covers success, failure, signals) -------
function cleanup {
if [[ -f "${backup}" ]]; then
mv -f "${backup}" "${manifest}"
fi
}
trap cleanup EXIT
# --- Inject the transient "task" product ----------------------------------
cp -f "${manifest}" "${backup}"
swift package --package-path "${package_root}" \
add-product task --type executable --targets "${target_name}"
# --- Run it (forward all script arguments) --------------------------------
swift run --package-path "${package_root}" task "$@"