diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2388eb7..9c67ad1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,7 +2,6 @@ name: CI
on:
pull_request:
- branches: [main]
push:
branches: [main]
workflow_dispatch:
@@ -11,14 +10,15 @@ concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
-permissions:
- contents: read
+permissions: {}
jobs:
quality:
name: Quality
runs-on: macos-26
timeout-minutes: 15
+ permissions:
+ contents: read
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -37,16 +37,24 @@ jobs:
unzip -j "${archive}" swiftlint -d "${RUNNER_TEMP}/swiftlint"
echo "${RUNNER_TEMP}/swiftlint" >> "${GITHUB_PATH}"
- name: Check Swift formatting
- run: swift format lint --recursive --strict Sources Tests Package.swift
+ run: swift format lint --recursive --strict App Sources Tests Package.swift
- name: Run SwiftLint
run: |
test "$(swiftlint version)" = "0.65.0"
swiftlint lint --strict
+ - name: Test conventional subjects
+ run: bash Tests/Scripts/run-title-tests.sh
+ - name: Test version consistency
+ run: |
+ bash Tests/Scripts/run-version-consistency-tests.sh
+ bash scripts/check-version-consistency.sh
tests:
name: Tests
runs-on: macos-26
timeout-minutes: 15
+ permissions:
+ contents: read
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -71,6 +79,8 @@ jobs:
name: Build
runs-on: macos-26
timeout-minutes: 15
+ permissions:
+ contents: read
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -90,15 +100,69 @@ jobs:
run: swift package resolve
- name: Test DMG packaging
run: bash Tests/Scripts/run-dmg-packaging-tests.sh
- - name: Build debug configuration
- run: swift build
- name: Build release configuration
run: swift build -c release
+ app:
+ name: App
+ runs-on: macos-26
+ timeout-minutes: 20
+ permissions:
+ contents: read
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: Restore Xcode dependencies
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: |
+ .build/ci/DerivedData/SourcePackages
+ ~/Library/Caches/org.swift.swiftpm
+ key: xcode-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Package.resolved', 'project.yml') }}
+ restore-keys: xcode-${{ runner.os }}-${{ runner.arch }}-
+ - name: Install XcodeGen
+ env:
+ XCODEGEN_SHA256: 4d9e34b62172d645eed6457cac13fc222569974098ef4ee9c3368bedf0196806
+ XCODEGEN_VERSION: 2.46.0
+ run: |
+ archive="${RUNNER_TEMP}/xcodegen.zip"
+ curl --fail --location --silent --show-error \
+ --output "${archive}" \
+ "https://github.com/yonaskolb/XcodeGen/releases/download/${XCODEGEN_VERSION}/xcodegen.zip"
+ echo "${XCODEGEN_SHA256} ${archive}" | shasum -a 256 --check
+ unzip -q "${archive}" -d "${RUNNER_TEMP}"
+ echo "${RUNNER_TEMP}/xcodegen/bin" >> "${GITHUB_PATH}"
+ - name: Generate Xcode project
+ run: xcodegen generate
+ - name: Test application
+ run: >-
+ xcodebuild -quiet -project Clamshell.xcodeproj
+ -scheme ClamshellApp -configuration Debug
+ -destination "platform=macOS"
+ -derivedDataPath .build/ci/DerivedData
+ CODE_SIGNING_ALLOWED=NO test
+ - name: Test control extension
+ run: >-
+ xcodebuild -quiet -project Clamshell.xcodeproj
+ -scheme ClamshellControl -configuration Debug
+ -destination "platform=macOS"
+ -derivedDataPath .build/ci/DerivedData
+ CODE_SIGNING_ALLOWED=NO test
+ - name: Build release application
+ run: >-
+ xcodebuild -quiet -project Clamshell.xcodeproj
+ -scheme ClamshellApp -configuration Release
+ -derivedDataPath .build/ci/DerivedData
+ CODE_SIGNING_ALLOWED=NO build
+
workflows:
name: Workflow lint
runs-on: ubuntu-latest
timeout-minutes: 10
+ permissions:
+ contents: read
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 9ad40a2..0411d9a 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -11,8 +11,7 @@ concurrency:
group: codeql-${{ github.ref }}
cancel-in-progress: true
-permissions:
- contents: read
+permissions: {}
jobs:
analyze:
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index afc0271..f3b93e4 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -2,21 +2,21 @@ name: PR
on:
pull_request:
- branches: [main]
types: [opened, edited, reopened, synchronize]
concurrency:
group: pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
-permissions:
- contents: read
+permissions: {}
jobs:
title:
name: Title
runs-on: ubuntu-latest
timeout-minutes: 5
+ permissions:
+ contents: read
steps:
- name: Check out trusted base
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -45,6 +45,8 @@ jobs:
name: Commits
runs-on: ubuntu-latest
timeout-minutes: 5
+ permissions:
+ contents: read
steps:
- name: Check out pull request
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
diff --git a/Tests/Scripts/run-title-tests.sh b/Tests/Scripts/run-title-tests.sh
new file mode 100755
index 0000000..aab78d3
--- /dev/null
+++ b/Tests/Scripts/run-title-tests.sh
@@ -0,0 +1,45 @@
+#!/bin/bash
+set -euo pipefail
+
+repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+readonly repository_root
+readonly validator="$repository_root/scripts/check-conventional-subject.sh"
+
+fail() {
+ echo "FAIL: $*" >&2
+ exit 1
+}
+
+assert_accepts() {
+ local subject="$1"
+ "$validator" "$subject" >/dev/null 2>&1 ||
+ fail "expected subject to pass: $subject"
+}
+
+assert_rejects() {
+ local subject="$1"
+ if "$validator" "$subject" >/dev/null 2>&1; then
+ fail "expected subject to fail: $subject"
+ fi
+}
+
+run_tests() {
+ [[ -x "$validator" ]] || fail "missing executable validator: $validator"
+
+ local verb
+ for verb in feat fix docs test build ci refactor perf style chore revert; do
+ assert_accepts "$verb(test-area): describe the change"
+ done
+
+ assert_rejects "feat: omit the area"
+ assert_rejects "feature(cli): use an unsupported verb"
+ assert_rejects "feat(CLI): use an upper-case area"
+ assert_rejects "feat(cli): end with a full stop."
+ assert_rejects "feat(cli): "
+ assert_rejects "feat(cli):"
+ assert_rejects " feat(cli): start with whitespace"
+
+ echo "All conventional subject tests passed."
+}
+
+run_tests
diff --git a/Tests/Scripts/run-version-consistency-tests.sh b/Tests/Scripts/run-version-consistency-tests.sh
new file mode 100755
index 0000000..e95ab26
--- /dev/null
+++ b/Tests/Scripts/run-version-consistency-tests.sh
@@ -0,0 +1,170 @@
+#!/bin/bash
+set -euo pipefail
+
+repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+readonly repository_root
+readonly version_library="$repository_root/scripts/lib/version-consistency.sh"
+temporary_root=""
+
+cleanup() {
+ [[ -n "$temporary_root" ]] || return 0
+ rm -rf "$temporary_root" || true
+}
+
+fail() {
+ echo "FAIL: $*" >&2
+ exit 1
+}
+
+assert_contains() {
+ local expected="$1"
+ local actual="$2"
+
+ [[ "$actual" == *"$expected"* ]] ||
+ fail "expected output to contain '$expected', got: $actual"
+}
+
+assert_fails() {
+ local expected="$1"
+ shift
+
+ local output
+ if output="$("$@" 2>&1)"; then
+ fail "expected command to fail: $*"
+ fi
+ assert_contains "$expected" "$output"
+}
+
+create_fixture() {
+ local name="$1"
+ local fixture="$temporary_root/$name"
+
+ mkdir -p \
+ "$fixture/App/ClamshellApp" \
+ "$fixture/App/ClamshellControl" \
+ "$fixture/Sources/ClamshellCore"
+ printf '0.1.0\n' >"$fixture/version.txt"
+ cat >"$fixture/Sources/ClamshellCore/BuildVersion.swift" <<'SWIFT'
+public enum BuildVersion {
+ // x-release-please-version
+ public static let current = "0.1.0"
+}
+SWIFT
+ cat >"$fixture/project.yml" <<'YAML'
+settings:
+ base:
+ # x-release-please-version
+ MARKETING_VERSION: "0.1.0"
+YAML
+ cat >"$fixture/.release-please-manifest.json" <<'JSON'
+{".":"0.1.0"}
+JSON
+ cat >"$fixture/release-please-config.json" <<'JSON'
+{
+ "packages": {
+ ".": {
+ "release-type": "simple",
+ "extra-files": [
+ {
+ "type": "generic",
+ "path": "Sources/ClamshellCore/BuildVersion.swift"
+ },
+ {
+ "type": "generic",
+ "path": "project.yml"
+ }
+ ]
+ }
+ }
+}
+JSON
+ cat >"$fixture/App/ClamshellApp/Info.plist" <<'PLIST'
+
+
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+
+
+PLIST
+ cp \
+ "$fixture/App/ClamshellApp/Info.plist" \
+ "$fixture/App/ClamshellControl/Info.plist"
+ printf '%s\n' "$fixture"
+}
+
+run_tests() {
+ [[ -f "$version_library" ]] || fail "missing $version_library"
+ # shellcheck source=/dev/null
+ source "$version_library"
+
+ temporary_root="$(mktemp -d "${TMPDIR:-/tmp}/clamshellctl-version-tests.XXXXXX")"
+ trap cleanup EXIT
+
+ local aligned_fixture
+ aligned_fixture="$(create_fixture aligned)"
+ check_version_consistency "$aligned_fixture" >/dev/null ||
+ fail "an aligned fixture failed validation"
+
+ local cli_drift_fixture
+ cli_drift_fixture="$(create_fixture cli-drift)"
+ sed -i '' 's/current = "0.1.0"/current = "0.2.0"/' \
+ "$cli_drift_fixture/Sources/ClamshellCore/BuildVersion.swift"
+ assert_fails \
+ "CLI version must be 0.1.0: 0.2.0" \
+ check_version_consistency \
+ "$cli_drift_fixture"
+
+ local app_drift_fixture
+ app_drift_fixture="$(create_fixture app-drift)"
+ sed -i '' 's/MARKETING_VERSION: "0.1.0"/MARKETING_VERSION: "0.2.0"/' \
+ "$app_drift_fixture/project.yml"
+ assert_fails \
+ "app version must be 0.1.0: 0.2.0" \
+ check_version_consistency \
+ "$app_drift_fixture"
+
+ local manifest_drift_fixture
+ manifest_drift_fixture="$(create_fixture manifest-drift)"
+ printf '{".":"0.2.0"}\n' \
+ >"$manifest_drift_fixture/.release-please-manifest.json"
+ assert_fails \
+ "release manifest version must be 0.1.0: 0.2.0" \
+ check_version_consistency \
+ "$manifest_drift_fixture"
+
+ local static_plist_fixture
+ static_plist_fixture="$(create_fixture static-plist)"
+ sed -i '' "s/\$(MARKETING_VERSION)/0.1.0/" \
+ "$static_plist_fixture/App/ClamshellControl/Info.plist"
+ assert_fails \
+ "control extension version must use \$(MARKETING_VERSION)" \
+ check_version_consistency \
+ "$static_plist_fixture"
+
+ local release_config_fixture
+ release_config_fixture="$(create_fixture release-config)"
+ jq \
+ '.packages["."]."extra-files"
+ |= map(select((if type == "string" then . else .path end) != "project.yml"))' \
+ "$release_config_fixture/release-please-config.json" \
+ >"$release_config_fixture/release-please-config.updated.json"
+ mv \
+ "$release_config_fixture/release-please-config.updated.json" \
+ "$release_config_fixture/release-please-config.json"
+ assert_fails \
+ "release configuration must update project.yml" \
+ check_version_consistency \
+ "$release_config_fixture"
+
+ local malformed_fixture
+ malformed_fixture="$(create_fixture malformed)"
+ printf '01.0.0\n' >"$malformed_fixture/version.txt"
+ assert_fails \
+ "version.txt must use MAJOR.MINOR.PATCH" \
+ check_version_consistency \
+ "$malformed_fixture"
+
+ echo "All version consistency tests passed."
+}
+
+run_tests
diff --git a/project.yml b/project.yml
index cc3ea32..0a199d0 100644
--- a/project.yml
+++ b/project.yml
@@ -16,6 +16,7 @@ settings:
DEVELOPMENT_TEAM: ""
ARCHS: "arm64 x86_64"
ONLY_ACTIVE_ARCH: NO
+ # x-release-please-version
MARKETING_VERSION: "0.1.0"
CURRENT_PROJECT_VERSION: 1
diff --git a/release-please-config.json b/release-please-config.json
index 75e18a0..ceae7d7 100644
--- a/release-please-config.json
+++ b/release-please-config.json
@@ -13,6 +13,10 @@
{
"type": "generic",
"path": "Sources/ClamshellCore/BuildVersion.swift"
+ },
+ {
+ "type": "generic",
+ "path": "project.yml"
}
],
"changelog-sections": [
diff --git a/scripts/check-version-consistency.sh b/scripts/check-version-consistency.sh
new file mode 100755
index 0000000..0a161bf
--- /dev/null
+++ b/scripts/check-version-consistency.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+set -euo pipefail
+
+repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+readonly repository_root
+# shellcheck disable=SC1091
+source "$repository_root/scripts/lib/version-consistency.sh"
+
+check_version_consistency "$repository_root"
diff --git a/scripts/check.sh b/scripts/check.sh
index 17774fd..d1125ef 100755
--- a/scripts/check.sh
+++ b/scripts/check.sh
@@ -21,8 +21,10 @@ command -v swiftlint >/dev/null || {
swift format lint --recursive --strict "${swift_paths[@]}"
swiftlint lint --strict
+bash Tests/Scripts/run-title-tests.sh
+bash Tests/Scripts/run-version-consistency-tests.sh
+bash scripts/check-version-consistency.sh
swift test
-swift build
swift build -c release
bash Tests/Scripts/run-dmg-packaging-tests.sh
@@ -41,9 +43,29 @@ if [[ -f project.yml ]]; then
}
xcodegen generate
xcodebuild \
+ -quiet \
-project Clamshell.xcodeproj \
-scheme ClamshellApp \
-configuration Debug \
+ -destination "platform=macOS" \
+ -derivedDataPath .build/check/DerivedData \
+ CODE_SIGNING_ALLOWED=NO \
+ test
+ xcodebuild \
+ -quiet \
+ -project Clamshell.xcodeproj \
+ -scheme ClamshellControl \
+ -configuration Debug \
+ -destination "platform=macOS" \
+ -derivedDataPath .build/check/DerivedData \
+ CODE_SIGNING_ALLOWED=NO \
+ test
+ xcodebuild \
+ -quiet \
+ -project Clamshell.xcodeproj \
+ -scheme ClamshellApp \
+ -configuration Release \
+ -derivedDataPath .build/check/DerivedData \
CODE_SIGNING_ALLOWED=NO \
build
fi
diff --git a/scripts/lib/version-consistency.sh b/scripts/lib/version-consistency.sh
new file mode 100644
index 0000000..74ed46c
--- /dev/null
+++ b/scripts/lib/version-consistency.sh
@@ -0,0 +1,183 @@
+#!/bin/bash
+
+version_consistency_error() {
+ echo "error: $*" >&2
+ return 1
+}
+
+require_version_file() {
+ local description="$1"
+ local path="$2"
+
+ [[ -f "$path" && ! -L "$path" ]] ||
+ version_consistency_error "$description is not a regular file: $path"
+}
+
+read_single_match() {
+ local description="$1"
+ local expression="$2"
+ local path="$3"
+ local matches
+
+ matches="$(sed -nE "$expression" "$path")" ||
+ version_consistency_error "could not read $description: $path" || return
+ [[ -n "$matches" && "$(printf '%s\n' "$matches" | wc -l | tr -d ' ')" == "1" ]] ||
+ version_consistency_error "$description must have one version value: $path" || return
+ printf '%s\n' "$matches"
+}
+
+read_version_file() {
+ local path="$1"
+ local line_count
+
+ line_count="$(awk 'END { print NR }' "$path")" ||
+ version_consistency_error "could not read version.txt" || return
+ [[ "$line_count" == "1" ]] ||
+ version_consistency_error "version.txt must contain one line" || return
+ tr -d '\n' <"$path"
+}
+
+read_cli_version() {
+ read_single_match \
+ "CLI version" \
+ 's/^[[:space:]]*public static let current = "([^"]+)"[[:space:]]*$/\1/p' \
+ "$1"
+}
+
+read_app_version() {
+ read_single_match \
+ "app version" \
+ 's/^[[:space:]]*MARKETING_VERSION:[[:space:]]*"([^"]+)"([[:space:]]*#.*)?$/\1/p' \
+ "$1"
+}
+
+read_plist_version_reference() {
+ read_single_match \
+ "$1 version" \
+ '/CFBundleShortVersionString<\/key>/{n;s/^[[:space:]]*([^<]+)<\/string>[[:space:]]*$/\1/p;}' \
+ "$2"
+}
+
+read_manifest_version() {
+ jq -er '.["."] | strings' "$1" 2>/dev/null ||
+ version_consistency_error "release manifest must contain one string version"
+}
+
+require_release_updater() {
+ local configuration="$1"
+ local path="$2"
+
+ jq -e \
+ --arg path "$path" \
+ '.packages["."]."extra-files"
+ | map(if type == "string" then . else .path end)
+ | index($path) != null' \
+ "$configuration" >/dev/null 2>&1 ||
+ version_consistency_error "release configuration must update $path"
+}
+
+verify_release_configuration() {
+ local configuration="$1"
+
+ jq -e '.packages["."]."release-type" == "simple"' \
+ "$configuration" >/dev/null 2>&1 ||
+ version_consistency_error "release configuration must use the simple release type" ||
+ return
+ require_release_updater \
+ "$configuration" \
+ "Sources/ClamshellCore/BuildVersion.swift" || return
+ require_release_updater "$configuration" "project.yml"
+}
+
+require_version_match() {
+ local description="$1"
+ local expected="$2"
+ local actual="$3"
+
+ [[ "$actual" == "$expected" ]] ||
+ version_consistency_error "$description must be $expected: $actual"
+}
+
+require_version_reference() {
+ local description="$1"
+ local expected="$2"
+ local actual="$3"
+
+ [[ "$actual" == "$expected" ]] ||
+ version_consistency_error "$description must use $expected: $actual"
+}
+
+check_version_consistency() {
+ local root="$1"
+ local version_path="$root/version.txt"
+ local cli_path="$root/Sources/ClamshellCore/BuildVersion.swift"
+ local project_path="$root/project.yml"
+ local manifest_path="$root/.release-please-manifest.json"
+ local configuration_path="$root/release-please-config.json"
+ local app_plist_path="$root/App/ClamshellApp/Info.plist"
+ local control_plist_path="$root/App/ClamshellControl/Info.plist"
+
+ command -v jq >/dev/null ||
+ version_consistency_error "jq is required" || return
+
+ local path
+ for path in \
+ "$version_path" \
+ "$cli_path" \
+ "$project_path" \
+ "$manifest_path" \
+ "$configuration_path" \
+ "$app_plist_path" \
+ "$control_plist_path"; do
+ require_version_file "version source" "$path" || return
+ done
+
+ local version
+ version="$(read_version_file "$version_path")" || return
+ [[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] ||
+ version_consistency_error \
+ "version.txt must use MAJOR.MINOR.PATCH without leading zeros: $version" ||
+ return
+
+ local cli_version
+ cli_version="$(read_cli_version "$cli_path")" || return
+ require_version_match "CLI version" "$version" "$cli_version" || return
+
+ local app_version
+ app_version="$(read_app_version "$project_path")" || return
+ require_version_match "app version" "$version" "$app_version" || return
+
+ local manifest_version
+ manifest_version="$(read_manifest_version "$manifest_path")" || return
+ require_version_match \
+ "release manifest version" \
+ "$version" \
+ "$manifest_version" || return
+
+ local app_reference
+ app_reference="$(read_plist_version_reference "app bundle" "$app_plist_path")" ||
+ return
+ require_version_reference \
+ "app bundle version" \
+ "\$(MARKETING_VERSION)" \
+ "$app_reference" || return
+
+ local control_reference
+ control_reference="$(
+ read_plist_version_reference "control extension" "$control_plist_path"
+ )" || return
+ require_version_reference \
+ "control extension version" \
+ "\$(MARKETING_VERSION)" \
+ "$control_reference" || return
+
+ grep -Fq "x-release-please-version" "$cli_path" ||
+ version_consistency_error "CLI version needs a release-please marker" ||
+ return
+ grep -Fq "x-release-please-version" "$project_path" ||
+ version_consistency_error "app version needs a release-please marker" ||
+ return
+ verify_release_configuration "$configuration_path" || return
+
+ printf 'Version consistency checks passed: %s\n' "$version"
+}