diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 752b17b..fb30ad6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,5 +12,7 @@ jobs: - uses: swift-actions/setup-swift@v2 with: swift-version: "5.10" + - name: Prepare test build + run: swift build - name: Run tests - run: make test + run: ./Tests/integration_tests.sh .build/debug/progressline diff --git a/.sake.yml b/.sake.yml new file mode 100644 index 0000000..eed7a08 --- /dev/null +++ b/.sake.yml @@ -0,0 +1 @@ +case_converting_strategy: toSnakeCase diff --git a/Makefile b/Makefile deleted file mode 100644 index 48741d6..0000000 --- a/Makefile +++ /dev/null @@ -1,102 +0,0 @@ -MAKEFLAGS += --no-print-directory -EXECUTABLE_NAME := progressline -SWIFT_VERSION := 6.0 -ROOT_PATH := $(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))) -DOCKER_RUN := docker run --rm --volume $(ROOT_PATH):/workdir --workdir /workdir -ZIP := zip -j -BUILD_FLAGS = --disable-sandbox --configuration release --triple $(TRIPLE) -ifeq ($(TRIPLE), aarch64-unknown-linux-gnu) - BUILD_FLAGS := $(BUILD_FLAGS) --static-swift-stdlib - SWIFT := $(DOCKER_RUN) --platform linux/arm64 swift:$(SWIFT_VERSION) swift - STRIP := $(DOCKER_RUN) --platform linux/arm64 swift:$(SWIFT_VERSION) strip -s -else ifeq ($(TRIPLE), x86_64-unknown-linux-gnu) - BUILD_FLAGS := $(BUILD_FLAGS) --static-swift-stdlib - SWIFT := $(DOCKER_RUN) --platform linux/amd64 swift:$(SWIFT_VERSION) swift - STRIP := $(DOCKER_RUN) --platform linux/amd64 swift:$(SWIFT_VERSION) strip -s -else - SWIFT := swift - STRIP := strip -rSTx -endif -EXECUTABLE_PATH = $(shell swift build $(BUILD_FLAGS) --show-bin-path)/$(EXECUTABLE_NAME) -EXECUTABLE_ARCHIVE_PATH = .build/artifacts/$(EXECUTABLE_NAME)-$(TRIPLE).zip - -clean: - @rm -rf .build 2> /dev/null || true -.PHONY: clean - -TEST_BUILD := .build/debug/progressline -TEST_BUILD_SRCS := $(wildcard Sources/*.swift Package.swift) -$(TEST_BUILD): $(TEST_BUILD_SRCS) - @swift build --configuration debug - -test: $(TEST_BUILD) - @./Tests/integration_tests.sh $(TEST_BUILD) -.PHONY: test - -long-running-command: - @rm -rf .build/apple && swift build -c release --arch x86_64 --arch arm64 2>&1 -.PHONY: long-running-command - -generate_release_notes: - @git cliff --latest --strip=all --tag $(VERSION) --output .build/artifacts/release_notes.md -.PHONY: generate_release_notes - -prepare_release_artifacts: \ -prepare_release_artifacts_linux_arm64 \ -prepare_release_artifacts_linux_x86_64 \ -prepare_release_artifacts_macos_arm64 \ -prepare_release_artifacts_macos_x86_64 -.PHONY: prepare_release_artifacts - -prepare_release_artifacts_linux_arm64: - @$(MAKE) prepare_release_artifacts_for_triple TRIPLE=aarch64-unknown-linux-gnu -.PHONY: prepare_release_artifacts_linux_arm64 - -prepare_release_artifacts_linux_x86_64: - @$(MAKE) prepare_release_artifacts_for_triple TRIPLE=x86_64-unknown-linux-gnu -.PHONY: prepare_release_artifacts_linux_x86_64 - -prepare_release_artifacts_macos_arm64: - @$(MAKE) prepare_release_artifacts_for_triple TRIPLE=arm64-apple-macosx -.PHONY: prepare_release_artifacts_macos_arm64 - -prepare_release_artifacts_macos_x86_64: - @$(MAKE) prepare_release_artifacts_for_triple TRIPLE=x86_64-apple-macosx -.PHONY: prepare_release_artifacts_macos_x86_64 - -define relpath -$(shell \ - base="$(1)"; \ - abs="$(2)"; \ - common_part="$$base"; \ - back=""; \ - while [ "$${abs#$$common_part}" = "$$abs" ]; do \ - common_part=$$(dirname "$$common_part"); \ - if [ -z "$$back" ]; then \ - back=".."; \ - else \ - back="../$$back"; \ - fi; \ - done; \ - if [ "$$common_part" = "/" ]; then \ - rel="$$back$${abs#/}"; \ - else \ - forward="$${abs#$$common_part/}"; \ - rel="$$back$$forward"; \ - fi; \ - echo "$$rel" \ -) -endef - -# use relative path for use with docker container -RELATIVE_EXECUTABLE_PATH = $(call relpath,$(ROOT_PATH),$(EXECUTABLE_PATH)) -GREEN := \033[0;32m -NC := \033[0m - -prepare_release_artifacts_for_triple: - $(SWIFT) build $(BUILD_FLAGS) - $(STRIP) $(RELATIVE_EXECUTABLE_PATH) - @echo "$(GREEN)Built $(EXECUTABLE_PATH)$(NC)" - zip -j $(EXECUTABLE_ARCHIVE_PATH) $(EXECUTABLE_PATH) - @echo "$(GREEN)Archived $(EXECUTABLE_ARCHIVE_PATH)$(NC)" -.PHONY: prepare_release_artifacts_for_triple diff --git a/SakeApp/.gitignore b/SakeApp/.gitignore new file mode 100644 index 0000000..0d5ef5e --- /dev/null +++ b/SakeApp/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/.index-build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc \ No newline at end of file diff --git a/SakeApp/BrewCommands.swift b/SakeApp/BrewCommands.swift new file mode 100644 index 0000000..1723742 --- /dev/null +++ b/SakeApp/BrewCommands.swift @@ -0,0 +1,29 @@ +import Sake +import SwiftShell + +@CommandGroup +struct BrewCommands { + static var ensureGhInstalled: Command { + Command( + description: "Ensure gh is installed", + skipIf: { _ in + run("which", "gh").succeeded + }, + run: { _ in + try runAndPrint("brew", "install", "gh") + } + ) + } + + static var ensureGitCliffInstalled: Command { + Command( + description: "Ensure git-cliff is installed", + skipIf: { _ in + run("which", "git-cliff").succeeded + }, + run: { _ in + try runAndPrint("brew", "install", "git-cliff") + } + ) + } +} diff --git a/SakeApp/Package.resolved b/SakeApp/Package.resolved new file mode 100644 index 0000000..39a1597 --- /dev/null +++ b/SakeApp/Package.resolved @@ -0,0 +1,51 @@ +{ + "originHash" : "564ae29a93959e0a64ff9ad1a401e5db179007f3ecef20571f852606328631f6", + "pins" : [ + { + "identity" : "sake", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kattouf/Sake", + "state" : { + "revision" : "f2c91c8ecb4f67f0c565b081deb7d180761a21d7", + "version" : "0.2.2" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "41982a3656a71c768319979febd796c6fd111d5c", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax", + "state" : { + "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", + "version" : "509.1.1" + } + }, + { + "identity" : "swiftshell", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kareman/SwiftShell", + "state" : { + "revision" : "99680b2efc7c7dbcace1da0b3979d266f02e213c", + "version" : "5.1.0" + } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams.git", + "state" : { + "revision" : "3036ba9d69cf1fd04d433527bc339dc0dc75433d", + "version" : "5.1.3" + } + } + ], + "version" : 3 +} diff --git a/SakeApp/Package.swift b/SakeApp/Package.swift new file mode 100644 index 0000000..a4e35e1 --- /dev/null +++ b/SakeApp/Package.swift @@ -0,0 +1,29 @@ +// swift-tools-version: 5.10 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import CompilerPluginSupport +import PackageDescription + +let package = Package( + name: "SakeApp", + platforms: [.macOS(.v10_15)], // Required by SwiftSyntax for the macro feature in Sake + products: [ + .executable(name: "SakeApp", targets: ["SakeApp"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"), + .package(url: "https://github.com/kattouf/Sake", from: "0.1.0"), + .package(url: "https://github.com/kareman/SwiftShell", from: "5.1.0"), + ], + targets: [ + .executableTarget( + name: "SakeApp", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + "Sake", + "SwiftShell", + ], + path: "." + ), + ] +) diff --git a/SakeApp/ReleaseCommands.swift b/SakeApp/ReleaseCommands.swift new file mode 100644 index 0000000..db64608 --- /dev/null +++ b/SakeApp/ReleaseCommands.swift @@ -0,0 +1,345 @@ +import ArgumentParser +import Foundation +import CryptoKit +import Sake +import SwiftShell + +@CommandGroup +struct ReleaseCommands { + private struct BuildTarget { + enum Arch { + case x86 + case arm + } + enum OS { + case macos + case linux + } + + let arch: Arch + let os: OS + + var triple: String { + switch (arch, os) { + case (.x86, .macos): "x86_64-apple-macosx" + case (.arm, .macos): "arm64-apple-macosx" + case (.x86, .linux): "x86_64-unknown-linux-gnu" + case (.arm, .linux): "aarch64-unknown-linux-gnu" + } + } + } + + private enum Constants { + static let buildArtifactsDirectory = ".build/artifacts" + static let swiftVersion = "6.0" + static let buildTargets: [BuildTarget] = [ + .init(arch: .arm, os: .macos), + .init(arch: .x86, os: .macos), + .init(arch: .x86, os: .linux), + .init(arch: .arm, os: .linux), + ] + static let executableName = "progressline" + } + + private struct ReleaseArguments: ParsableArguments { + @Argument(help: "Version number") + var version: String + + func validate() throws { + guard version.range(of: #"^\d+\.\d+\.\d+$"#, options: .regularExpression) != nil else { + throw ValidationError("Invalid version number. Should be in the format 'x.y.z'") + } + } + } + + public static var brewRelease: Command { + Command( + description: "Brew to Homebrew", + run: { context in + let arguments = try ReleaseArguments.parse(context.arguments) + try arguments.validate() + + let version = arguments.version + try runAndPrint("brew", "bump-formula-pr", "--version=\(version)", "progressline") + } + ) + } + + public static var githubRelease: Command { + Command( + description: "Release to GitHub", + dependencies: [ + bumpVersion, + cleanReleaseArtifacts, + buildReleaseArtifacts, + calculateBuildArtifactsSha256, + createAndPushTag, + generateReleaseNotes, + draftReleaseWithArtifacts, + ] + ) + } + + static var bumpVersion: Command { + Command( + description: "Bump version", + skipIf: { context in + let arguments = try ReleaseArguments.parse(context.arguments) + try arguments.validate() + + let version = arguments.version + let versionFilePath = "Sources/Version.swift" + let currentVersion = try String(contentsOfFile: versionFilePath) + .split(separator: "\"")[1] + if currentVersion == version { + print("Version is already \(version). Skipping...") + return true + } else { + return false + } + }, + run: { context in + let arguments = try ReleaseArguments.parse(context.arguments) + try arguments.validate() + + let version = arguments.version + let versionFilePath = "Sources/Version.swift" + let versionFileContent = """ + // This file is autogenerated. Do not edit. + let progressLineVersion = "\(version)" + + """ + try versionFileContent.write(toFile: versionFilePath, atomically: true, encoding: .utf8) + + try runAndPrint("git", "add", versionFilePath) + try runAndPrint("git", "commit", "-m", "chore(release): Bump version to \(version)") + print("Version bumped to \(version)") + } + ) + } + + static var cleanReleaseArtifacts: Command { + Command( + description: "Clean release artifacts", + run: { _ in + try? runAndPrint("rm", "-rf", Constants.buildArtifactsDirectory) + } + ) + } + + static var buildReleaseArtifacts: Command { + Command( + description: "Build release artifacts", + skipIf: { context in + let arguments = try ReleaseArguments.parse(context.arguments) + try arguments.validate() + let version = arguments.version + + let targetsWithExistingArtifacts = Constants.buildTargets.filter { target in + let archivePath = executableArchivePath(target: target, version: version) + return FileManager.default.fileExists(atPath: archivePath) + } + if targetsWithExistingArtifacts.count == Constants.buildTargets.count { + print("Release artifacts already exist. Skipping...") + return true + } else { + context.storage["existing-artifacts-triples"] = targetsWithExistingArtifacts.map(\.triple) + return false + } + }, + run: { context in + let arguments = try ReleaseArguments.parse(context.arguments) + try arguments.validate() + let version = arguments.version + + try FileManager.default.createDirectory( + atPath: Constants.buildArtifactsDirectory, + withIntermediateDirectories: true, + attributes: nil + ) + let existingArtifactsTriples = context.storage["existing-artifacts-triples"] as? [String] ?? [] + for target in Constants.buildTargets { + if existingArtifactsTriples.contains(target.triple) { + print("Skipping \(target.triple) as artifacts already exist") + continue + } + let (swiftBuild, swiftClean, strip, zip) = { + let buildFlags = ["--disable-sandbox", "--configuration", "release", "--triple", target.triple] + if target.os == .linux { + let platform = target.arch == .arm ? "linux/arm64" : "linux/amd64" + let dockerExec = "docker run --rm --volume \(context.projectRoot):/workdir --workdir /workdir --platform \(platform) swift:\(Constants.swiftVersion)" + let buildFlags = (buildFlags + ["--static-swift-stdlib"]).joined(separator: " ") + return ( + "\(dockerExec) swift build \(buildFlags)", + "\(dockerExec) swift package clean", + "\(dockerExec) strip -s", + "zip -j" + ) + } else { + let buildFlags = buildFlags.joined(separator: " ") + return ( + "swift build \(buildFlags)", + "swift package clean", + "strip -rSTx", + "zip -j" + ) + } + }() + + try runAndPrint(bash: swiftClean) + try runAndPrint(bash: swiftBuild) + + let binPath: String = run(bash: "\(swiftBuild) --show-bin-path").stdout + if binPath.isEmpty { + throw NSError(domain: "Fail to get bin path", code: -999) + } + let executablePath = binPath + "/\(Constants.executableName)" + + try runAndPrint(bash: "\(strip) \(executablePath)") + + let executableArchivePath = executableArchivePath(target: target, version: version) + try runAndPrint(bash: "\(zip) \(executableArchivePath) \(executablePath.replacingOccurrences(of: "/workdir", with: context.projectRoot))") + } + + print("Release artifacts built successfully at '\(Constants.buildArtifactsDirectory)'") + } + ) + } + + static var calculateBuildArtifactsSha256: Command { + @Sendable + func shasumFilePath(version: String) -> String { + ".build/artifacts/shasum-\(version)" + } + + return Command( + description: "Calculate SHA-256 checksums for build artifacts", + skipIf: { context in + let arguments = try ReleaseArguments.parse(context.arguments) + try arguments.validate() + let version = arguments.version + + let shasumFilePath = shasumFilePath(version: version) + + return FileManager.default.fileExists(atPath: shasumFilePath) + }, + run: { context in + let arguments = try ReleaseArguments.parse(context.arguments) + try arguments.validate() + let version = arguments.version + + var shasumResults = [String]() + for target in Constants.buildTargets { + let archivePath = executableArchivePath(target: target, version: version) + let file = FileHandle(forReadingAtPath: archivePath)! + let shasum = SHA256.hash(data: file.readDataToEndOfFile()) + let shasumString = shasum.compactMap { String(format: "%02x", $0) }.joined() + shasumResults.append("\(shasumString) \(archivePath)") + } + FileManager.default.createFile( + atPath: shasumFilePath(version: version), + contents: shasumResults.joined(separator: "\n").data(using: .utf8) + ) + } + ) + } + + static var createAndPushTag: Command { + Command( + description: "Create and push a tag", + skipIf: { context in + let arguments = try ReleaseArguments.parse(context.arguments) + try arguments.validate() + + let version = arguments.version + + let grepResult = run(bash: "git tag | grep \(arguments.version)") + if grepResult.succeeded { + print("Tag \(version) already exists. Skipping...") + return true + } else { + return false + } + }, + run: { context in + let arguments = try ReleaseArguments.parse(context.arguments) + try arguments.validate() + + let version = arguments.version + + print("Creating and pushing tag \(version)") + try runAndPrint("git", "tag", version) + try runAndPrint("git", "push", "origin", "tag", version) + try runAndPrint("git", "push") // push local changes like version bump + } + ) + } + + static var generateReleaseNotes: Command { + Command( + description: "Generate release notes", + dependencies: [BrewCommands.ensureGitCliffInstalled], + skipIf: { context in + let arguments = try ReleaseArguments.parse(context.arguments) + try arguments.validate() + + let version = arguments.version + let releaseNotesPath = releaseNotesPath(version: version) + if FileManager.default.fileExists(atPath: releaseNotesPath) { + print("Release notes for \(version) already exist at \(releaseNotesPath). Skipping...") + return true + } else { + return false + } + }, + run: { context in + let arguments = try ReleaseArguments.parse(context.arguments) + try arguments.validate() + + let version = arguments.version + let releaseNotesPath = releaseNotesPath(version: version) + try runAndPrint("git", "cliff", "--latest", "--strip=all", "--tag", version, "--output", releaseNotesPath) + print("Release notes generated at \(releaseNotesPath)") + } + ) + } + + static var draftReleaseWithArtifacts: Command { + Command( + description: "Draft a release on GitHub", + dependencies: [BrewCommands.ensureGhInstalled], + skipIf: { context in + let arguments = try ReleaseArguments.parse(context.arguments) + try arguments.validate() + + let tagName = arguments.version + let ghViewResult = run(bash: "gh release view \(tagName)") + if ghViewResult.succeeded { + print("Release \(tagName) already exists. Skipping...") + return true + } else { + return false + } + }, + run: { context in + let arguments = try ReleaseArguments.parse(context.arguments) + try arguments.validate() + + print("Drafting release \(arguments.version) on GitHub") + let tagName = arguments.version + let releaseTitle = arguments.version + let draftReleaseCommand = + "gh release create \(tagName) \(Constants.buildArtifactsDirectory)/*.zip --title '\(releaseTitle)' --draft --verify-tag --notes-file \(releaseNotesPath(version: tagName))" + try runAndPrint(bash: draftReleaseCommand) + } + ) + } + + private static func executableArchivePath(target: BuildTarget, version: String) -> String { + "\(Constants.buildArtifactsDirectory)/\(Constants.executableName)-\(version)-\(target.triple).zip" + } + + private static func releaseNotesPath(version: String) -> String { + ".build/artifacts/release-notes-\(version).md" + } +} diff --git a/SakeApp/Sakefile.swift b/SakeApp/Sakefile.swift new file mode 100644 index 0000000..315ed49 --- /dev/null +++ b/SakeApp/Sakefile.swift @@ -0,0 +1,46 @@ +import ArgumentParser +import Foundation +import Sake +import SwiftShell + +@main +@CommandGroup +struct Commands: SakeApp { + public static let configuration = SakeAppConfiguration( + commandGroups: [ + TestCommands.self, + ReleaseCommands.self, + ] + ) +} + +@CommandGroup +struct TestCommands { + public static var test: Command { + Command( + description: "Run tests", + dependencies: [ensureDebugBuildIsUpToDate], + run: { context in + try runAndPrint( + bash: + "\(context.projectRoot)/Tests/integration_tests.sh \(context.projectRoot)/.build/debug/progressline" + ) + } + ) + } + + private static var ensureDebugBuildIsUpToDate: Command { + Command( + description: "Ensure debug build is up to date", + run: { context in + try runAndPrint(bash: "swift build --package-path \(context.projectRoot)") + } + ) + } +} + +extension Command.Context { + var projectRoot: String { + "\(appDirectory)/.." + } +} diff --git a/Sources/ProgressLine.swift b/Sources/ProgressLine.swift index 4c7ad89..1123127 100644 --- a/Sources/ProgressLine.swift +++ b/Sources/ProgressLine.swift @@ -8,7 +8,8 @@ struct ProgressLine: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "progressline", abstract: "A command-line tool for compactly tracking the progress of piped commands.", - usage: "some-command | progressline" + usage: "some-command | progressline", + version: progressLineVersion ) @Option(name: [.long, .customShort("t")], help: "The static text to display instead of the latest stdin data.") diff --git a/Sources/Version.swift b/Sources/Version.swift new file mode 100644 index 0000000..a76faff --- /dev/null +++ b/Sources/Version.swift @@ -0,0 +1,2 @@ +// This file is autogenerated. Do not edit. +let progressLineVersion = "0.2.2"