diff --git a/Makefile b/Makefile index 223f993e..e53cffe4 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ PACKAGE_NOTARY_PROFILE ?= $(RELEASE_NOTARY_PROFILE) FORCE ?= 0 .DEFAULT_GOAL := help -.PHONY: help doctor test guest runtime app build run run-ephemeral reset update-omarchy package package-preflight release release-preflight clean clean-all clean-guest +.PHONY: help doctor test guest runtime app build run run-ephemeral reset update-omarchy version-preflight package package-preflight release release-preflight clean clean-all clean-guest help: @printf '%s\n' \ @@ -57,6 +57,7 @@ test: @PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/macos/Tests/test-cocoa-pinch.py" @PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/macos/Tests/test-virtio-pinch.py" @PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/tests/test-build-cache.py" + @PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/tests/test-app-version.py" @PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/tests/test-pack-app-icon.py" @$(ROOT)/guest/test @$(ROOT)/macos/Tests/macos-compatibility.test.sh @@ -111,7 +112,10 @@ update-omarchy: --refresh-package-lock "$(ROOT)/guest/packages.lock.json" @$(ROOT)/guest/test --source "$(ROOT)/.build/upstream/omarchy-v$(OMARCHY_RELEASE)" -package-preflight: +version-preflight: + @python3 "$(ROOT)/scripts/app_version.py" --root "$(ROOT)" --require-release + +package-preflight: version-preflight @[[ "$(PACKAGE_SIGN_IDENTITY)" == "Developer ID Application:"* ]] || { echo 'error: PACKAGE_SIGN_IDENTITY must be a Developer ID Application identity' >&2; exit 1; } @[[ -n "$(strip $(PACKAGE_NOTARY_PROFILE))" ]] || { echo 'error: PACKAGE_NOTARY_PROFILE must name a notarytool keychain profile' >&2; exit 1; } @@ -123,7 +127,7 @@ package: package-preflight --sign-identity "$(PACKAGE_SIGN_IDENTITY)" \ --notarize-profile "$(PACKAGE_NOTARY_PROFILE)" -release-preflight: +release-preflight: version-preflight @[[ "$(RELEASE_SIGN_IDENTITY)" == "Developer ID Application:"* ]] || { echo 'error: RELEASE_SIGN_IDENTITY must be a Developer ID Application identity' >&2; exit 1; } @[[ -n "$(strip $(RELEASE_NOTARY_PROFILE))" ]] || { echo 'error: RELEASE_NOTARY_PROFILE must name a notarytool keychain profile' >&2; exit 1; } diff --git a/docs/releasing.md b/docs/releasing.md index c078c90e..ca81bee8 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -8,9 +8,21 @@ Releases are Apple Silicon-only and require macOS 15 or newer. make doctor make test make build -make release +# Choose the next version and tag the clean commit being packaged. +git tag -a vX.Y.Z -m "vX.Y.Z" +make package ``` +Replace `vX.Y.Z` with the intended release version. Both `make package` and +`make release` require a clean checkout, including untracked files, with an +exact `vX.Y.Z` tag on HEAD. Neither command selects the next version or checks +whether that version has already been published. Ignored build output does +not make the checkout dirty. + +After verifying the DMG, push the tag with `git push origin vX.Y.Z`, then create +the GitHub release manually using that existing tag and attach +`dist/TryOmarchy.dmg`. Packaging does not create tags or publish GitHub releases. + When the release updates Omarchy itself, first run: ```sh diff --git a/macos/build-app.sh b/macos/build-app.sh index 9b79531d..a6ec6450 100755 --- a/macos/build-app.sh +++ b/macos/build-app.sh @@ -179,6 +179,8 @@ PYTHON install -m 0644 "$macos_dir/network-helper/vendor/LICENSE" "$contents/Resources/network/LICENSE.socket_vmnet" install -m 0755 "$helper" "$contents/MacOS/omarchy-vm-helper" install -m 0644 "$macos_dir/Info.plist" "$contents/Info.plist" +python3 "$repo_dir/scripts/app_version.py" \ + --root "$repo_dir" --plist "$contents/Info.plist" install -m 0644 "$macos_dir/Credits.rtf" "$contents/Resources/Credits.rtf" install -m 0644 "$repo_dir/LICENSE" "$contents/Resources/LICENSE" install -m 0644 "$generated_icon" "$contents/Resources/TryOmarchy.icns" diff --git a/scripts/app_version.py b/scripts/app_version.py new file mode 100644 index 00000000..311b1116 --- /dev/null +++ b/scripts/app_version.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Resolve the Git version used by app stamping and build caching.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import plistlib +import re +import subprocess +import sys + + +def build_version(root: Path) -> dict[str, str] | None: + def git(*arguments: str) -> str: + return subprocess.check_output( + ["git", "-C", str(root), *arguments], + text=True, + stderr=subprocess.PIPE, + ).strip() + + try: + inside = git("rev-parse", "--is-inside-work-tree") + except (FileNotFoundError, subprocess.CalledProcessError): + return None + if inside != "true": + return None + + describe = git("describe", "--tags", "--match", "v[0-9]*", "--always") + # Unlike `git describe --dirty`, status also detects untracked source files. + if git("status", "--porcelain", "--untracked-files=all"): + describe += "-dirty" + release = re.fullmatch(r"v([0-9]+\.[0-9]+\.[0-9]+)", describe) + return { + "CFBundleShortVersionString": release.group(1) if release else "0.0.0", + "CFBundleVersion": git("rev-list", "--count", "HEAD"), + "TryOmarchyBuildDescribe": describe, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", required=True, type=Path) + action = parser.add_mutually_exclusive_group(required=True) + action.add_argument("--plist", type=Path) + action.add_argument("--require-release", action="store_true") + args = parser.parse_args() + version = build_version(args.root) + + if args.require_release: + if version and version["TryOmarchyBuildDescribe"].endswith("-dirty"): + raise ValueError("the worktree must be clean before building a signed app") + if not version or re.fullmatch( + r"v[0-9]+\.[0-9]+\.[0-9]+", version["TryOmarchyBuildDescribe"] + ) is None: + raise ValueError("HEAD must carry an exact vX.Y.Z release tag") + elif version is None: + print( + f"warning: {args.root} is not a git checkout; " + "keeping the checked-in Info.plist version", + file=sys.stderr, + ) + else: + value = plistlib.loads(args.plist.read_bytes()) + value.update(version) + args.plist.write_bytes(plistlib.dumps(value)) + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError, subprocess.CalledProcessError) as error: + print(f"app-version: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/scripts/build-cache.py b/scripts/build-cache.py index 7fc61201..75060138 100755 --- a/scripts/build-cache.py +++ b/scripts/build-cache.py @@ -16,6 +16,8 @@ import tempfile from typing import Any +from app_version import build_version + SCHEMA_VERSION = 2 GUEST_ARTIFACTS = { @@ -216,7 +218,11 @@ def fingerprint(root: Path, component: str, command: list[str]) -> str: paths = component_files(root, component) if component == "app": + digest.update( + json.dumps(build_version(root), sort_keys=True, separators=(",", ":")).encode() + ) paths.extend(app_external_files(root)) + paths.append(Path(__file__).with_name("app_version.py").resolve()) paths.append(Path(__file__).resolve()) seen: set[str] = set() diff --git a/tests/test-app-version.py b/tests/test-app-version.py new file mode 100644 index 00000000..32d019bb --- /dev/null +++ b/tests/test-app-version.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import plistlib +import shutil +import subprocess +import sys +import tempfile +import unittest + + +REPOSITORY = Path(__file__).resolve().parents[1] +VERSION_SCRIPT = REPOSITORY / "scripts/app_version.py" + + +class AppVersionTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="app-version-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + (self.root / "scripts").mkdir() + shutil.copy2(VERSION_SCRIPT, self.root / "scripts/app_version.py") + shutil.copy2(REPOSITORY / "Makefile", self.root / "Makefile") + (self.root / ".gitignore").write_text("/dist/\n/.build/\n") + self.source = self.root / "source.swift" + self.source.write_text("// initial source\n") + self.git("init", "-q") + self.git("config", "user.name", "Version Tests") + self.git("config", "user.email", "version-tests@example.invalid") + self.git("add", ".") + self.git("commit", "-qm", "Initial source") + self.plist = self.root / "dist/Info.plist" + self.plist.parent.mkdir() + self.original = plistlib.dumps({ + "CFBundleShortVersionString": "0.4.0", + "CFBundleVersion": "5", + "CFBundleIdentifier": "dev.tryomarchy.native", + }) + + def git(self, *arguments: str) -> str: + return subprocess.check_output( + ["git", "-C", str(self.root), *arguments], + text=True, stderr=subprocess.STDOUT, + ).strip() + + def stamp(self) -> dict[str, str]: + self.plist.write_bytes(self.original) + subprocess.run( + [sys.executable, str(VERSION_SCRIPT), "--root", str(self.root), + "--plist", str(self.plist)], + check=True, capture_output=True, text=True, + ) + value = plistlib.loads(self.plist.read_bytes()) + self.assertEqual("dev.tryomarchy.native", value["CFBundleIdentifier"]) + return value + + def preflight(self, expected_error: str | None = None) -> None: + result = subprocess.run( + ["make", "--no-print-directory", "-C", str(self.root), "version-preflight"], + capture_output=True, text=True, + ) + if expected_error is None: + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + else: + self.assertNotEqual(0, result.returncode) + self.assertIn(expected_error, result.stderr) + + def test_tagged_release_and_ignored_build_outputs(self) -> None: + self.git("tag", "-a", "v1.2.3", "-m", "Release") + (self.root / ".build").mkdir() + (self.root / ".build/output").write_text("ignored build output") + value = self.stamp() + self.assertEqual("1.2.3", value["CFBundleShortVersionString"]) + self.assertEqual("1", value["CFBundleVersion"]) + self.assertEqual("v1.2.3", value["TryOmarchyBuildDescribe"]) + self.preflight() + + def test_untracked_source_is_dirty_until_committed(self) -> None: + self.git("tag", "v1.2.3") + (self.root / "added.swift").write_text("// untracked app source\n") + for staged in (False, True): + with self.subTest(staged=staged): + if staged: + self.git("add", "added.swift") + value = self.stamp() + self.assertEqual("0.0.0", value["CFBundleShortVersionString"]) + self.assertEqual("v1.2.3-dirty", value["TryOmarchyBuildDescribe"]) + self.preflight("worktree must be clean") + self.git("commit", "-qm", "Add source") + value = self.stamp() + self.assertEqual("0.0.0", value["CFBundleShortVersionString"]) + self.assertEqual("2", value["CFBundleVersion"]) + self.assertRegex(value["TryOmarchyBuildDescribe"], r"^v1\.2\.3-1-g[0-9a-f]+$") + self.preflight("HEAD must carry an exact") + + def test_modified_tracked_source_is_dirty(self) -> None: + self.git("tag", "v1.2.3") + self.source.write_text("// modified source\n") + value = self.stamp() + self.assertEqual("0.0.0", value["CFBundleShortVersionString"]) + self.assertEqual("v1.2.3-dirty", value["TryOmarchyBuildDescribe"]) + self.preflight("worktree must be clean") + + def test_untagged_and_non_release_tags(self) -> None: + self.assertEqual("0.0.0", self.stamp()["CFBundleShortVersionString"]) + self.preflight("HEAD must carry an exact") + self.git("tag", "v1foo") + self.assertEqual("0.0.0", self.stamp()["CFBundleShortVersionString"]) + self.preflight("HEAD must carry an exact") + + def test_source_archive_keeps_plist_but_cannot_be_released(self) -> None: + shutil.rmtree(self.root / ".git") + self.stamp() + self.assertEqual(self.original, self.plist.read_bytes()) + self.preflight("HEAD must carry an exact") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test-build-cache.py b/tests/test-build-cache.py index 32cafbe6..7505fd6a 100755 --- a/tests/test-build-cache.py +++ b/tests/test-build-cache.py @@ -17,6 +17,7 @@ REPOSITORY = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPOSITORY / "scripts")) SPEC = importlib.util.spec_from_file_location( "try_omarchy_build_cache", REPOSITORY / "scripts/build-cache.py" ) @@ -262,6 +263,67 @@ def test_app_validation_requires_packaged_icon(self) -> None: ): build_cache.validate_app(root, None) + def test_app_fingerprint_tracks_git_version_without_source_changes(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for relative in ( + "macos/Info.plist", "LICENSE", ".build/state/guest.json", + ".build/state/runtime.json", "dist/guest/guest-manifest.json", + "dist/guest/SHA256SUMS", + "guest/scripts/install-settings-integration.py", + "guest/native-overlay/usr/local/bin/omarchy-native-settings", + "guest/native-overlay/etc/udev/rules.d/92-omarchy-native-settings.rules", + "guest/native-overlay/usr/share/applications/try-omarchy-settings.desktop", + "guest/native-overlay/etc/skel/.config/omarchy/extensions/omarchy-menu.jsonc", + *(f"macos/.build/qemu-gpu-runtime/{name}" for name in build_cache.RUNTIME_FILES), + ): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("fixture\n") + (root / ".gitignore").write_text("/dist/\n/.build/\n/macos/.build/\n") + + def git(*arguments: str) -> None: + subprocess.run( + ["git", "-C", str(root), *arguments], + check=True, capture_output=True, + ) + + git("init", "-q") + git("config", "user.name", "Cache Tests") + git("config", "user.email", "cache-tests@example.invalid") + git("add", ".") + git("commit", "-qm", "Initial source") + + def fingerprint() -> str: + return build_cache.fingerprint(root, "app", ["build-app"]) + + untagged = fingerprint() + git("tag", "v1.2.3") + tagged = fingerprint() + self.assertNotEqual(untagged, tagged) + + # A file outside the app inputs still makes its version dirty. + untracked = root / "notes.txt" + untracked.write_text("untracked\n") + dirty = fingerprint() + self.assertNotEqual(tagged, dirty) + untracked.unlink() + self.assertEqual(tagged, fingerprint()) + + source = root / "macos/Info.plist" + source.write_text("edited\n") + dirty = fingerprint() + git("add", ".") + git("commit", "-qm", "Edit source") + committed = fingerprint() + self.assertNotEqual(dirty, committed) + + git("commit", "--allow-empty", "-qm", "Advance HEAD") + self.assertNotEqual(committed, fingerprint()) + stable = fingerprint() + (root / "dist/build-log").write_text("ignored output\n") + self.assertEqual(stable, fingerprint()) + def test_state_write_is_readable_and_replaces_old_state(self) -> None: with tempfile.TemporaryDirectory() as temporary: state = Path(temporary) / "state/component.json"