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
10 changes: 7 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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' \
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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; }

Expand All @@ -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; }

Expand Down
14 changes: 13 additions & 1 deletion docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions macos/build-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
74 changes: 74 additions & 0 deletions scripts/app_version.py
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 6 additions & 0 deletions scripts/build-cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
import tempfile
from typing import Any

from app_version import build_version


SCHEMA_VERSION = 2
GUEST_ARTIFACTS = {
Expand Down Expand Up @@ -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()
Expand Down
119 changes: 119 additions & 0 deletions tests/test-app-version.py
Original file line number Diff line number Diff line change
@@ -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()
62 changes: 62 additions & 0 deletions tests/test-build-cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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"
Expand Down
Loading