diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7d9b92..fcbf95d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,14 +8,17 @@ on: jobs: build-app: name: Build Swift package - runs-on: macos-14 + runs-on: macos-15 steps: - uses: actions/checkout@v4 - name: install swift-format run: brew install swift-format - - name: swift format lint + # The Homebrew binary by name, not `swift format`: on macos-15 the latter + # runs the older swift-format bundled with Xcode 16, whose rules differ + # (it flagged code the current release accepts). + - name: swift-format lint working-directory: mac-app - run: swift format lint --strict --recursive Sources Tests + run: swift-format lint --strict --recursive Sources Tests - name: swift build working-directory: mac-app run: swift build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35ddc3b..1a0bbcd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -117,22 +117,20 @@ jobs: name: Build, release, update tap needs: decide if: needs.decide.outputs.skip != 'true' - runs-on: macos-14 + runs-on: macos-15 permissions: contents: write env: IS_RELEASE: ${{ needs.decide.outputs.release }} VERSION: ${{ needs.decide.outputs.version }} + # Read by build-app and make-dmg; the computed version must not be + # written into mac-app/VERSION, which would mark every release dirty. + CM_RELEASE_VERSION: ${{ needs.decide.outputs.version }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - # The build reads its version from this file; a computed patch release - # must report itself as that version, not as the base. - - name: Set the release version - run: printf '%s\n' "$VERSION" > mac-app/VERSION - - name: swift test working-directory: mac-app run: swift test @@ -166,6 +164,11 @@ jobs: -k /Library/Keychains/System.keychain "$RUNNER_TEMP/cert.pem" rm -f "$RUNNER_TEMP/cert.p12" + # Shown before the build: v1.3.0 shipped marked DIRTY-TREE, and the log + # did not say which file a runner step had changed. + - name: Working tree state + run: git status --porcelain && git diff --stat + - name: Build CloudMachine.app (universal) working-directory: mac-app run: swift run cloudmachine-agent build-app --universal @@ -193,6 +196,20 @@ jobs: # `hdiutil create` on GitHub's macOS runners fails now and then with # "Resource busy" - it did on the first v1.3.0 run, after passing three # dry runs. Retried with a growing pause; a real failure still fails. + # A release must be exactly a commit. v1.3.0 was not: the macos-14 + # runner (Swift 5.10) could not use swift-argument-parser 1.8.2, which + # needs Swift 6, quietly resolved 1.7.2 and rewrote Package.resolved. + # Any tracked file changed by the build stops the release here. + - name: Build did not change tracked files + if: env.IS_RELEASE == 'true' + run: | + if [ -n "$(git status --porcelain --untracked-files=no)" ]; then + git status --porcelain --untracked-files=no + git diff + echo "::error::The build changed tracked files; the release would not match its commit." + exit 1 + fi + - name: Package .dmg working-directory: mac-app run: | diff --git a/mac-app/Sources/CloudMachineAgent/BuildAppCommand.swift b/mac-app/Sources/CloudMachineAgent/BuildAppCommand.swift index 2bf5bd8..fe03404 100644 --- a/mac-app/Sources/CloudMachineAgent/BuildAppCommand.swift +++ b/mac-app/Sources/CloudMachineAgent/BuildAppCommand.swift @@ -40,9 +40,7 @@ struct BuildApp: AsyncParsableCommand { let appBundle = buildDir.appendingPathComponent("\(appName).app") let fm = FileManager.default - let version = - (try? String(contentsOf: macAppRoot.appendingPathComponent("VERSION"), encoding: .utf8))? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "1.0.0" + let version = BuildPaths.version let buildNumber = await resolveBuildNumber(projectRoot: projectRoot) print( @@ -106,7 +104,8 @@ struct BuildApp: AsyncParsableCommand { infoPlistContent = infoPlistContent.replacingOccurrences(of: "__CM_VERSION__", with: version) infoPlistContent = infoPlistContent.replacingOccurrences(of: "__CM_BUILD__", with: buildNumber) let commit = await resolveCommit(projectRoot: projectRoot) - let dirty = await workingTreeIsDirty(projectRoot: projectRoot) + let changes = await uncommittedChanges(projectRoot: projectRoot) + let dirty = !changes.isEmpty infoPlistContent = infoPlistContent.replacingOccurrences(of: "__CM_COMMIT__", with: commit) infoPlistContent = infoPlistContent.replacingOccurrences( of: "__CM_DIRTY__", with: dirty ? "true" : "false") @@ -118,6 +117,9 @@ struct BuildApp: AsyncParsableCommand { L10n.tr( "==> WARNING: you are building from a DIRTY tree - the version will not point to a commit." )) + // Named, not just counted: on a CI runner nobody can look at the tree + // afterwards, and v1.3.0 shipped marked dirty with no clue which file. + for line in changes { print(" \(line)") } } try infoPlistContent.write( to: appBundle.appendingPathComponent("Contents/Info.plist"), atomically: true, encoding: .utf8 @@ -193,13 +195,15 @@ struct BuildApp: AsyncParsableCommand { /// /// `status --porcelain` also covers untracked files - and rightly so: a new /// source file that nobody added goes into the binary just the same. - private func workingTreeIsDirty(projectRoot: URL) async -> Bool { + private func uncommittedChanges(projectRoot: URL) async -> [String] { guard let result = try? await ProcessRunner.run( "/usr/bin/git", ["-C", projectRoot.path, "status", "--porcelain"]), result.succeeded - else { return false } - return !result.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return [] } + return result.stdout.split(separator: "\n").map(String.init).filter { + !$0.trimmingCharacters(in: .whitespaces).isEmpty + } } private func resolveBuildNumber(projectRoot: URL) async -> String { diff --git a/mac-app/Sources/CloudMachineAgent/BuildPaths.swift b/mac-app/Sources/CloudMachineAgent/BuildPaths.swift index 42ce8f7..af2f856 100644 --- a/mac-app/Sources/CloudMachineAgent/BuildPaths.swift +++ b/mac-app/Sources/CloudMachineAgent/BuildPaths.swift @@ -18,4 +18,19 @@ enum BuildPaths { static var projectRoot: URL { macAppRoot.deletingLastPathComponent() } + + /// The version to build: `CM_RELEASE_VERSION` when set, else `mac-app/VERSION`. + /// + /// The release workflow computes patch versions (1.3.0 -> 1.3.1) and passes + /// them in the environment. Writing them into VERSION instead would leave the + /// tree modified, and every release would report itself as DIRTY-TREE. + static var version: String { + if let forced = ProcessInfo.processInfo.environment["CM_RELEASE_VERSION"]? + .trimmingCharacters(in: .whitespacesAndNewlines), !forced.isEmpty + { + return forced + } + return (try? String(contentsOf: macAppRoot.appendingPathComponent("VERSION"), encoding: .utf8))? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "1.0.0" + } } diff --git a/mac-app/Sources/CloudMachineAgent/MakeDmgCommand.swift b/mac-app/Sources/CloudMachineAgent/MakeDmgCommand.swift index 96964c2..5810b77 100644 --- a/mac-app/Sources/CloudMachineAgent/MakeDmgCommand.swift +++ b/mac-app/Sources/CloudMachineAgent/MakeDmgCommand.swift @@ -16,9 +16,7 @@ struct MakeDmg: AsyncParsableCommand { let buildDir = macAppRoot.appendingPathComponent("build") let appBundle = buildDir.appendingPathComponent("\(appName).app") let stagingDir = buildDir.appendingPathComponent("dmg-staging") - let version = - (try? String(contentsOf: macAppRoot.appendingPathComponent("VERSION"), encoding: .utf8))? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "1.0.0" + let version = BuildPaths.version let dmgPath = buildDir.appendingPathComponent("\(appName)-\(version).dmg") let fm = FileManager.default