From 6b0a3b57ed78d5c81fe4afd8836bbf78831db2bb Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:19:05 +0100 Subject: [PATCH 1/2] ci(release): enforce automated release checks --- .github/workflows/ci.yml | 76 +++++++- .github/workflows/codeql.yml | 3 +- .github/workflows/pr.yml | 8 +- Tests/Scripts/run-title-tests.sh | 45 +++++ .../Scripts/run-version-consistency-tests.sh | 170 ++++++++++++++++ project.yml | 1 + release-please-config.json | 4 + scripts/check-version-consistency.sh | 9 + scripts/check.sh | 24 ++- scripts/lib/version-consistency.sh | 183 ++++++++++++++++++ 10 files changed, 511 insertions(+), 12 deletions(-) create mode 100755 Tests/Scripts/run-title-tests.sh create mode 100755 Tests/Scripts/run-version-consistency-tests.sh create mode 100755 scripts/check-version-consistency.sh create mode 100644 scripts/lib/version-consistency.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2388eb7..7cfd71e 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}/xcodegen" + 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" +} From 623eb8cdfaf4ec231cb72ca2ff860c7e41240567 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:21:03 +0100 Subject: [PATCH 2/2] fix(ci): install xcodegen on hosted runner --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7cfd71e..9c67ad1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,7 +132,7 @@ jobs: --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}/xcodegen" + unzip -q "${archive}" -d "${RUNNER_TEMP}" echo "${RUNNER_TEMP}/xcodegen/bin" >> "${GITHUB_PATH}" - name: Generate Xcode project run: xcodegen generate