Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 23 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: |
Expand Down
18 changes: 11 additions & 7 deletions mac-app/Sources/CloudMachineAgent/BuildAppCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions mac-app/Sources/CloudMachineAgent/BuildPaths.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
4 changes: 1 addition & 3 deletions mac-app/Sources/CloudMachineAgent/MakeDmgCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading