From 6ccbb16dadfc6f88812a82530dfcc03a4e710f7d Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Tue, 6 Oct 2026 10:04:48 +0200 Subject: [PATCH 1/3] fix(release): releases are built from a clean tree and say which file is not v1.3.0 was released marked DIRTY-TREE with no clue why. And the patch version step added in #13 wrote the computed version into mac-app/VERSION, which would have marked every later release dirty too. - build-app and make-dmg take the version from CM_RELEASE_VERSION when set; the workflow passes it instead of rewriting VERSION. - build-app lists the uncommitted paths when the tree is dirty, and the workflow prints `git status` before building. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 13 ++++++++----- .../CloudMachineAgent/BuildAppCommand.swift | 18 +++++++++++------- .../Sources/CloudMachineAgent/BuildPaths.swift | 15 +++++++++++++++ .../CloudMachineAgent/MakeDmgCommand.swift | 4 +--- 4 files changed, 35 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35ddc3b..2eb1f6b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -123,16 +123,14 @@ jobs: 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 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 From 26773e5b9a7a452cc162118f6a1ed4a63144db28 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Tue, 6 Oct 2026 10:08:53 +0200 Subject: [PATCH 2/3] fix(ci): build on macos-15, so releases use the pinned dependencies swift-argument-parser 1.8.2 (pinned in Package.resolved) needs Swift 6. The macos-14 runner has Swift 5.10, so it quietly resolved 1.7.2 and rewrote Package.resolved: CI tested, and v1.3.0 shipped, a different dependency than the repository pins - which is what the DIRTY-TREE marker was saying. CI and releases now run on macos-15 (Swift 6), with the runner's default Xcode, not a pinned one. A release also stops if the build changed any tracked file. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7d9b92..ae43e72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2eb1f6b..1a0bbcd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -117,7 +117,7 @@ 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: @@ -196,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: | From 1b5e8a2ffd18f5c13d8ca3dbeec2300508c8c293 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Tue, 6 Oct 2026 10:13:32 +0200 Subject: [PATCH 3/3] fix(ci): lint with Homebrew's swift-format, not the one bundled with Xcode 16 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae43e72..fcbf95d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,9 +13,12 @@ jobs: - 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