refactor: Replace shell script with Swift CLI for artifact bundling

This commit is contained in:
Shibo Lyu 2025-10-05 15:39:43 +08:00
parent dca866c3a9
commit 5a3c64f599
7 changed files with 386 additions and 107 deletions

33
Package.resolved Normal file
View file

@ -0,0 +1,33 @@
{
"originHash" : "8e39da950f9cbe4c8126b1fabf0d3d6945ab547f7564fd7bb0921701f3269c14",
"pins" : [
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser.git",
"state" : {
"revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3",
"version" : "1.6.1"
}
},
{
"identity" : "swift-asn1",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "f70225981241859eb4aa1a18a75531d26637c8cc",
"version" : "1.4.0"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc",
"version" : "3.15.1"
}
}
],
"version" : 3
}

View file

@ -6,9 +6,21 @@ let package = Package(
name: "SwiftTailwind",
platforms: [.macOS(.v12)],
products: [
.plugin(name: "TailwindCSS", targets: ["TailwindCSS"])
.plugin(name: "TailwindCSS", targets: ["TailwindCSS"]),
.executable(name: "TailwindCSSCLIArtifactBundler", targets: ["TailwindCSSCLIArtifactBundler"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"),
],
targets: [
.executableTarget(
name: "TailwindCSSCLIArtifactBundler",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Crypto", package: "swift-crypto"),
]
),
.plugin(name: "TailwindCSS", capability: .buildTool(), dependencies: ["TailwindCSSCLI"]),
.binaryTarget(
name: "TailwindCSSCLI",

View file

@ -1,83 +0,0 @@
#!/bin/bash
set -euo pipefail
# Check if TAILWINDCSS_VERSION environment variable is set
if [[ -z "${TAILWINDCSS_VERSION:-}" ]]; then
echo "Error: TAILWINDCSS_VERSION environment variable is not set"
echo "Usage: TAILWINDCSS_VERSION=v3.4.0 $0"
exit 1
fi
VERSION="$TAILWINDCSS_VERSION"
echo "Building artifact bundle for TailwindCSS version: $VERSION"
# Get the directory containing this script to find the template
TEMPLATE_FILE="$PWD/Scripts/info.template.json"
# Check if template file exists
if [[ ! -f "$TEMPLATE_FILE" ]]; then
echo "Error: Template file not found at $TEMPLATE_FILE"
exit 1
fi
# Create working directory
WORK_DIR="/tmp/tailwindcss.artifactbundle"
echo "Creating working directory: $WORK_DIR"
rm -rf "$WORK_DIR"
mkdir -p "$WORK_DIR"
cd "$WORK_DIR"
# GitHub release base URL
BASE_URL="https://github.com/tailwindlabs/tailwindcss/releases/download/$VERSION"
# Download and place binaries
download_binary() {
local binary_name="$1"
local target_path="$2"
local target_dir
target_dir=$(dirname "$target_path")
echo "Downloading $binary_name..."
mkdir -p "$target_dir"
if curl -L "$BASE_URL/$binary_name" > "$target_path"; then
chmod +x "$target_path"
echo "✓ Downloaded and made executable: $target_path"
else
echo "✗ Failed to download $binary_name from $BASE_URL/$binary_name"
exit 1
fi
}
# Download each binary to its target location
download_binary "tailwindcss-linux-x64" "tailwindcss-$VERSION-linux-x64/bin/tailwindcss"
download_binary "tailwindcss-macos-x64" "tailwindcss-$VERSION-macos-x64/bin/tailwindcss"
download_binary "tailwindcss-macos-arm64" "tailwindcss-$VERSION-macos-arm64/bin/tailwindcss"
# Create info.json from template, replacing %VERSION% with actual version
echo "Creating info.json from template..."
sed "s/%VERSION%/$VERSION/g" "$TEMPLATE_FILE" > "info.json"
echo "✓ Created info.json with version $VERSION"
# Create ZIP file
ZIP_FILE="/tmp/tailwindcss.artifactbundle.zip"
echo "Creating ZIP file: $ZIP_FILE"
# Remove existing ZIP file if it exists
rm -f "$ZIP_FILE"
# Create ZIP with the artifact bundle as the only child in root
cd /tmp
zip -r "tailwindcss.artifactbundle.zip" "tailwindcss.artifactbundle"
echo "✓ Created ZIP file: $ZIP_FILE"
# Compute checksum using Swift Package Manager
echo "Computing checksum..."
CHECKSUM=$(swift package compute-checksum "$ZIP_FILE")
echo ""
echo "=== BUILD COMPLETE ==="
echo "ZIP file path: $ZIP_FILE"
echo "Checksum: $CHECKSUM"

View file

@ -1,23 +0,0 @@
{
"schemaVersion": "1.0",
"artifacts": {
"tailwindcss": {
"version": "%VERSION%",
"type": "executable",
"variants": [
{
"path": "tailwindcss-%VERSION%-macos-x64/bin/tailwindcss",
"supportedTriples": ["x86_64-apple-macosx"]
},
{
"path": "tailwindcss-%VERSION%-macos-arm64/bin/tailwindcss",
"supportedTriples": ["arm64-apple-macosx"]
},
{
"path": "tailwindcss-%VERSION%-linux-x64/bin/tailwindcss",
"supportedTriples": ["x86_64-unknown-linux-gnu"]
}
]
}
}
}

View file

@ -0,0 +1,255 @@
import Crypto
import Foundation
class ArtifactBundleBuilder {
private let version: String
private let workDir: String
private let outputDir: String
private let fileManager = FileManager.default
private let binaryConfigurations: [BinaryConfiguration] = [
BinaryConfiguration(binaryName: "tailwindcss-linux-x64", triple: "x86_64-unknown-linux-gnu"),
BinaryConfiguration(binaryName: "tailwindcss-macos-x64", triple: "x86_64-apple-darwin"),
BinaryConfiguration(binaryName: "tailwindcss-macos-arm64", triple: "aarch64-apple-darwin"),
]
init(version: String, workDir: String, outputDir: String) {
self.version = version
self.workDir = workDir
self.outputDir = outputDir
}
func buildArtifactBundles() throws {
try setupWorkingDirectory()
var bundleInfos: [BundleInfo] = []
print("Creating individual bundles...")
for config in binaryConfigurations {
let bundleInfo = try createBundle(for: config)
bundleInfos.append(bundleInfo)
}
try generateArtifactBundleIndex(bundleInfos: bundleInfos)
print("=== BUILD COMPLETE ===")
print("All bundles created successfully:")
print("")
print("Generated artifact bundle index: \(outputDir)/tailwindcss.artifactbundleindex")
print("")
let indexChecksum = try computeChecksum(
filePath: "\(outputDir)/tailwindcss.artifactbundleindex", usingSHA256Directly: true)
print("Index checksum: \(indexChecksum)")
print("")
for bundleInfo in bundleInfos {
print("Bundle: \(bundleInfo.fileName)")
print(" Checksum: \(bundleInfo.checksum)")
print(" Triple: \(bundleInfo.triple)")
print("")
}
}
private func setupWorkingDirectory() throws {
print("Creating working directory: \(workDir)")
if fileManager.fileExists(atPath: workDir) {
try fileManager.removeItem(atPath: workDir)
}
try fileManager.createDirectory(atPath: workDir, withIntermediateDirectories: true)
}
private func createBundle(for config: BinaryConfiguration) throws -> BundleInfo {
let bundleDirName = "tailwindcss-\(version)-\(config.triple).artifactbundle"
let bundleDir = "\(workDir)/\(bundleDirName)"
let binaryPath = "bin/tailwindcss"
print("Creating bundle for \(config.triple)...")
// Create bundle directory structure
let binDir = "\(bundleDir)/bin"
try fileManager.createDirectory(atPath: binDir, withIntermediateDirectories: true)
// Download binary
print(" Downloading \(config.binaryName)...")
let binaryURL =
"https://github.com/tailwindlabs/tailwindcss/releases/download/\(version)/\(config.binaryName)"
let binaryDestination = "\(bundleDir)/\(binaryPath)"
try downloadFile(from: binaryURL, to: binaryDestination)
try makeExecutable(path: binaryDestination)
print(" ✓ Downloaded and made executable: \(binaryDestination)")
// Create info.json
print(" Creating info.json...")
try createInfoJSON(bundleDir: bundleDir, binaryPath: binaryPath, triple: config.triple)
print(
" ✓ Created info.json with version \(version), path \(binaryPath), triple \(config.triple)")
// Create ZIP file
let zipFileName = "\(bundleDirName).zip"
let zipPath = "\(outputDir)/\(zipFileName)"
print(" Creating ZIP file: \(zipPath)")
try createZipFile(bundleDir: bundleDir, zipPath: zipPath)
// Compute checksum
print(" Computing checksum...")
let checksum = try computeChecksum(filePath: zipPath)
print(" ✓ Bundle created: \(zipPath)")
print(" ✓ Checksum: \(checksum)")
print("")
return BundleInfo(fileName: zipFileName, checksum: checksum, triple: config.triple)
}
private func downloadFile(from urlString: String, to destination: String) throws {
guard let url = URL(string: urlString) else {
throw ArtifactBundleError.invalidURL(urlString)
}
let data = try Data(contentsOf: url)
try data.write(to: URL(fileURLWithPath: destination))
}
private func makeExecutable(path: String) throws {
let attributes = [FileAttributeKey.posixPermissions: 0o755]
try fileManager.setAttributes(attributes, ofItemAtPath: path)
}
private func createInfoJSON(bundleDir: String, binaryPath: String, triple: String) throws {
let artifact = Artifact(
version: version,
type: "executable",
variants: [
ArtifactVariant(path: binaryPath, supportedTriples: [triple])
]
)
let info = ArtifactBundleInfo(
schemaVersion: "1.0",
artifacts: ["tailwindcss": artifact]
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let jsonData = try encoder.encode(info)
let infoPath = "\(bundleDir)/info.json"
try jsonData.write(to: URL(fileURLWithPath: infoPath))
}
private func createZipFile(bundleDir: String, zipPath: String) throws {
// Remove existing ZIP file if it exists
if fileManager.fileExists(atPath: zipPath) {
try fileManager.removeItem(atPath: zipPath)
}
let bundleDirURL = URL(fileURLWithPath: bundleDir)
let workDirURL = bundleDirURL.deletingLastPathComponent()
let bundleName = bundleDirURL.lastPathComponent
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
process.arguments = ["-r", zipPath, bundleName]
process.currentDirectoryURL = workDirURL
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
throw ArtifactBundleError.zipCreationFailed
}
}
/// Computes the SHA256 checksum of a file.
///
/// If `usingSHA256Directly` is true, it uses Swift Crypto's SHA256 implementation.
/// This is to workaround https://github.com/swiftlang/swift-package-manager/issues/9219.
private func computeChecksum(
filePath: String,
usingSHA256Directly: Bool = false
) throws -> String {
if usingSHA256Directly {
// Use swift-crypto's SHA256 implementation
let fileURL = URL(fileURLWithPath: filePath)
let data = try Data(contentsOf: fileURL)
let hash = SHA256.hash(data: data)
return hash.compactMap { String(format: "%02x", $0) }.joined()
} else {
// Use swift package compute-checksum command
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/swift")
process.arguments = ["package", "compute-checksum", filePath]
let pipe = Pipe()
process.standardOutput = pipe
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
throw ArtifactBundleError.checksumComputationFailed
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(
in: .whitespacesAndNewlines)
guard let checksum = output, !checksum.isEmpty else {
throw ArtifactBundleError.checksumComputationFailed
}
return checksum
}
}
private func generateArtifactBundleIndex(bundleInfos: [BundleInfo]) throws {
print("Generating tailwindcss.artifactbundleindex...")
// Create output directory if it doesn't exist
if !fileManager.fileExists(atPath: outputDir) {
try fileManager.createDirectory(atPath: outputDir, withIntermediateDirectories: true)
}
let bundles = bundleInfos.map { bundleInfo in
Bundle(
fileName: bundleInfo.fileName,
checksum: bundleInfo.checksum,
supportedTriples: [bundleInfo.triple]
)
}
let index = ArtifactBundleIndex(
schemaVersion: "1.0",
bundles: bundles
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let jsonData = try encoder.encode(index)
let indexPath = "\(outputDir)/tailwindcss.artifactbundleindex"
try jsonData.write(to: URL(fileURLWithPath: indexPath))
}
}
enum ArtifactBundleError: Error, LocalizedError {
case invalidURL(String)
case zipCreationFailed
case checksumComputationFailed
var errorDescription: String? {
switch self {
case .invalidURL(let url):
return "Invalid URL: \(url)"
case .zipCreationFailed:
return "Failed to create ZIP file"
case .checksumComputationFailed:
return "Failed to compute checksum"
}
}
}

View file

@ -0,0 +1,53 @@
import Foundation
// MARK: - Artifact Bundle Info Models
/// Represents the info.json file structure for an artifact bundle
struct ArtifactBundleInfo: Codable {
let schemaVersion: String
let artifacts: [String: Artifact]
enum CodingKeys: String, CodingKey {
case schemaVersion, artifacts
}
}
struct Artifact: Codable {
let version: String
let type: String
let variants: [ArtifactVariant]
}
struct ArtifactVariant: Codable {
let path: String
let supportedTriples: [String]
}
// MARK: - Artifact Bundle Index Models
/// Represents the .artifactbundleindex file structure
struct ArtifactBundleIndex: Codable {
let schemaVersion: String
let bundles: [Bundle]
}
struct Bundle: Codable {
let fileName: String
let checksum: String
let supportedTriples: [String]
}
// MARK: - Internal Data Models
/// Configuration for a binary platform
struct BinaryConfiguration {
let binaryName: String
let triple: String
}
/// Information about a created bundle
struct BundleInfo {
let fileName: String
let checksum: String
let triple: String
}

View file

@ -0,0 +1,32 @@
import ArgumentParser
import Foundation
@main
struct TailwindCSSCLIArtifactBundler: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "TailwindCSSCLIArtifactBundler",
abstract: "Build TailwindCSS CLI artifact bundles for Swift Package Manager",
version: "1.0.0"
)
@Option(name: .shortAndLong, help: "TailwindCSS version to build (e.g., v4.1.14)")
var version: String
@Option(name: .shortAndLong, help: "Working directory for temporary files")
var workDir: String = "/tmp/tailwindcss-bundles"
@Option(name: .shortAndLong, help: "Output directory for the artifact bundle index")
var outputDir: String = "."
func run() throws {
print("Building artifact bundles for TailwindCSS version: \(version)")
let bundler = ArtifactBundleBuilder(
version: version,
workDir: workDir,
outputDir: outputDir
)
try bundler.buildArtifactBundles()
}
}