Add generate-docs command to build task
Some checks failed
Docs / docs (push) Has been cancelled
Docs / deploy (push) Has been cancelled

The Swift Docc has to run in a Linux container to be able to build the
documentation, as it needs access to the inotify.h header files.
This commit is contained in:
T. R. Bernstein
2026-03-12 18:10:31 +01:00
parent 2812ddf210
commit a942b15483
4 changed files with 196 additions and 2 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
.build
public

View File

@@ -4,6 +4,6 @@ import ArgumentParser
struct Command: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Project tasks of Astzweig's Swift Inotify project.",
subcommands: [TestCommand.self]
subcommands: [TestCommand.self, GenerateDocsCommand.self]
)
}

View File

@@ -0,0 +1,178 @@
import ArgumentParser
import AsyncAlgorithms
import Foundation
import Logging
import Noora
import Subprocess
struct GenerateDocsCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "generate-docs",
abstract: "Generate DocC documentation inside a Linux container.",
aliases: ["gd"],
)
@OptionGroup var global: GlobalOptions
private static let doccPluginURL = "https://github.com/apple/swift-docc-plugin.git"
private static let doccPluginMinVersion = "1.4.0"
private static let targets = ["Inotify", "TaskCLI"]
private static let hostingBasePath = "swift-inotify"
private static let skipItems: Set<String> = [".git", ".build", ".swiftpm", "public"]
// MARK: - Run
func run() async throws {
let noora = Noora()
let logger = global.makeLogger(labeled: "swift-inotify.cli.task.generate-docs")
let fileManager = FileManager.default
let projectDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath)
noora.info("Generating DocC documentation on Linux.")
logger.debug("Current directory", metadata: ["current-directory": "\(projectDirectory.path(percentEncoded: false))"])
let tempDirectory = try copyProject(from: projectDirectory)
logger.info("Copied project to temporary directory.", metadata: ["path": "\(tempDirectory.path(percentEncoded: false))"])
defer {
try? fileManager.removeItem(at: tempDirectory)
logger.info("Cleaned up temporary directory.")
}
try await injectDoccPluginDependency(in: tempDirectory, logger: logger)
let script = Self.makeRunScript()
logger.debug("Container script", metadata: ["script": "\(script)"])
let dockerResult = try await Subprocess.run(
.name("docker"),
arguments: [
"run", "--rm",
"-v", "\(tempDirectory.path(percentEncoded: false)):/code",
"--platform", "linux/arm64",
"-w", "/code",
"swift:latest",
"/bin/bash", "-c", script,
],
preferredBufferSize: 10,
) { 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.")
return
}
try copyResults(from: tempDirectory, to: projectDirectory)
noora.success(
.alert("Documentation generated successfully.",
takeaways: Self.targets.map {
"./public/\($0.lowercased())/"
}
)
)
}
private static func generateDocsCommand() -> String {
Self.targets.map {
"swift package generate-documentation --target \($0)"
}.joined(separator: " && ")
}
private static func transformDocsForStaticHostingCommand() -> String {
Self.targets.map { target in
let lowercased = target.lowercased()
return "docc process-archive transform-for-static-hosting" +
" $OUTPUTS/\(target).doccarchive" +
" --output-path ./public/\(lowercased)" +
" --hosting-base-path \(Self.hostingBasePath)/\(lowercased)"
}.joined(separator: " && ")
}
private static func outputPathsForDocTargets() -> String {
Self.targets.map { "./public/\($0.lowercased())" }.joined(separator: " ")
}
private static func makeRunScript() -> String {
let generateDocs = Self.generateDocsCommand()
let transformDocs = Self.transformDocsForStaticHostingCommand()
let outputDirs = Self.outputPathsForDocTargets()
return "set -euo pipefail && \(generateDocs)" +
" && OUTPUTS=.build/plugins/Swift-DocC/outputs" +
" && mkdir -p \(outputDirs)" +
" && \(transformDocs)"
}
// MARK: - Project Copy
private func copyProject(from source: URL) throws -> URL {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appending(path: "swift-inotify-docs-\(UUID().uuidString)")
try fileManager.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
let contents = try fileManager.contentsOfDirectory(at: source, includingPropertiesForKeys: nil)
for item in contents {
guard !Self.skipItems.contains(item.lastPathComponent) else { continue }
try fileManager.copyItem(at: item, to: tempDirectory.appending(path: item.lastPathComponent))
}
return tempDirectory
}
private func copyResults(from tempDirectory: URL, to projectDirectory: URL) throws {
let fileManager = FileManager.default
let source = tempDirectory.appending(path: "public")
let destination = projectDirectory.appending(path: "public")
if fileManager.fileExists(atPath: destination.path(percentEncoded: false)) {
try fileManager.removeItem(at: destination)
}
try fileManager.copyItem(at: source, to: destination)
}
// MARK: - Dependency Injection
private func injectDoccPluginDependency(in directory: URL, logger: Logger) async throws {
let packageSwiftURL = directory.appending(path: "Package.swift")
let contents = try String(contentsOf: packageSwiftURL, encoding: .utf8)
guard !contents.contains("swift-docc-plugin") else {
logger.info("swift-docc-plugin dependency already present.")
return
}
let result = try await Subprocess.run(
.name("swift"),
arguments: [
"package", "--package-path", directory.path(percentEncoded: false),
"add-dependency", Self.doccPluginURL,
"--from", Self.doccPluginMinVersion,
],
) { _ in }
guard result.terminationStatus.isSuccess else {
throw GenerateDocsError.dependencyInjectionFailed
}
logger.info("Injected swift-docc-plugin dependency.")
}
}
enum GenerateDocsError: Error, CustomStringConvertible {
case dependencyInjectionFailed
var description: String {
switch self {
case .dependencyInjectionFailed:
"Failed to add swift-docc-plugin dependency to Package.swift."
}
}
}

View File

@@ -4,7 +4,7 @@ The build tool for the Swift Inotify project.
## Overview
`TaskCLI` is a small command-line executable (exposed as `task` in `Package.swift`) that automates project-level workflows. Its primary purpose is running the integration test suite inside a Linux Docker container, so you can validate the 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.
### Running the Tests
@@ -19,6 +19,16 @@ This launches a `swift:latest` Docker container with the repository mounted at `
The container is started with `--security-opt systempaths=unconfined` so that the limit tests can write to `/proc/sys/fs/inotify/*`.
### Generating Documentation
```bash
swift run task generate-docs
```
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.
The working tree is never modified — all changes happen in the temporary copy, which is cleaned up automatically.
### Verbosity
Pass one or more `-v` flags to increase log output:
@@ -40,7 +50,12 @@ Docker must be installed and running on the host machine. The container uses the
- ``Command``
- ``TestCommand``
- ``GenerateDocsCommand``
### Configuration
- ``GlobalOptions``
### Errors
- ``GenerateDocsError``