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
14 changes: 7 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -174,36 +174,36 @@ jobs:
--no-parallel \
--filter "$test_filter"

local-installer-tests:
name: Local Installer Tests (Xcode 27)
local-build-tests:
name: Local Build Tests (Xcode 27)
# GitHub-hosted Xcode 27 image: actions/runner-images#14404.
runs-on: xcode-27
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Run local installer tests
- name: Run local build tests
timeout-minutes: 2
run: >-
python3 -m unittest discover
--start-directory scripts/tests
--pattern 'test_install_review_monitor.py'
--pattern 'test_build_review_monitor.py'
--verbose

- name: Show Xcode environment
run: |
xcodebuild -version
xcodebuild -showsdks

- name: Run local installer integration test
- name: Run local build integration test
timeout-minutes: 8
env:
RUN_INSTALLER_INTEGRATION: '1'
RUN_LOCAL_BUILD_INTEGRATION: '1'
run: >-
python3 -m unittest discover
--start-directory scripts/tests
--pattern 'test_install_review_monitor.py'
--pattern 'test_build_review_monitor.py'
--verbose

- name: Test remote release contracts
Expand Down
43 changes: 19 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,44 +97,39 @@ separate from CodexReviewMonitor's dedicated runtime home at `~/.codex_review`.

## Build from Source

To build, install, and launch CodexReviewMonitor from the current checkout, run
the installer from the repository root:
To build a local DMG from the current checkout, run from the repository root
using Python 3.10 or newer:

```bash
./scripts/install_review_monitor.py --launch
python3 scripts/build_review_monitor.py
```

The installer builds the current checkout, applies an ad-hoc hardened-runtime
signature, validates the app, and deploys it to
`/Applications/CodexReviewMonitor.app`. Installing there requires write access.
The command creates `dist/CodexReviewMonitor_yymmdd_hhmm.dmg`, using the local
build-start time. It applies an ad-hoc hardened-runtime signature to the app and
verifies the DMG's contents. Build caches remain
in `.build` for subsequent builds. The first run prepares the pinned DMG tools
in `.build/release-tools` and may download the locked package dependencies.

The local installer requires macOS 26 or newer, an Apple silicon Mac, and
Xcode 26.4 or newer. The first build may download the package versions locked
by the repository.
Keep CodexReviewMonitor running while building. When the DMG is ready and reviews
have finished, quit the app, open the DMG, and drag `CodexReviewMonitor.app` to
Applications. Choose **Replace**, then launch the installed app. There is no need
to delete the existing app first. Builds started in the same minute replace the
same DMG only after the new image passes validation; older filenames are retained.

Quit CodexReviewMonitor before installing. If the default destination already
exists, the installer stops before building; remove the app manually before
rerunning it. The destination is checked again before deployment. To install
only for the current user, select `~/Applications` explicitly:

```bash
./scripts/install_review_monitor.py \
--destination ~/Applications/CodexReviewMonitor.app \
--launch
```
The local build requires an Apple silicon Mac and Xcode 26.4 or newer; the app
requires macOS 26 or newer. The command does not modify or launch installed apps.

The default ad-hoc signature is for local use and does not make a redistributable
or notarized app. If the Mac's management policy requires an approved local
identity, pass it explicitly; the installer never falls back to another
identity, pass it explicitly; the command never falls back to another
identity:

```bash
./scripts/install_review_monitor.py \
--signing-identity 'Apple Development: Developer Name (TEAMID)' \
--launch
python3 scripts/build_review_monitor.py \
--signing-identity 'Apple Development: Developer Name (TEAMID)'
```

Device-management policy can still prohibit locally signed apps. The installer
Device-management policy can still prohibit locally signed apps. The command
does not disable Gatekeeper or remove quarantine metadata.

## More Detail
Expand Down
128 changes: 128 additions & 0 deletions scripts/build_review_monitor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Build a local CodexReviewMonitor DMG without changing installed apps."""

from __future__ import annotations

import argparse
import fcntl
import os
import shlex
import subprocess
import sys
import tempfile
from datetime import datetime
from pathlib import Path


APP_NAME = "CodexReviewMonitor"
APP_BUNDLE_NAME = f"{APP_NAME}.app"


def run(arguments: list[str], *, cwd: Path | None = None, **kwargs) -> None:
print(f"+ {shlex.join(arguments)}", flush=True)
subprocess.run(arguments, cwd=cwd, check=True, **kwargs)


def packaging_python(repo_root: Path) -> Path:
environment = repo_root / ".build" / "release-tools"
python = environment / "bin" / "python3"
if not python.exists():
run([sys.executable, "-m", "venv", str(environment)])
run(
[
str(python), "-m", "pip", "install", "--disable-pip-version-check",
"--require-hashes", "--only-binary=:all:",
"-r", str(repo_root / "scripts" / "release-requirements.txt"),
]
)
return python


def build(repo_root: Path, output_dir: Path, signing_identity: str = "-") -> Path:
build_root = repo_root / ".build"
build_root.mkdir(parents=True, exist_ok=True)
with (build_root / "local-dmg.lock").open("a") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
timestamp = datetime.now().strftime("%y%m%d_%H%M")
archive_name = f"{APP_NAME}_{timestamp}.dmg"
python = packaging_python(repo_root)
derived_data = build_root / "local-dmg-arm64"
run(
[
"/usr/bin/xcodebuild", "build",
"-project", "Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj",
"-scheme", APP_NAME,
"-configuration", "Release",
"-destination", "generic/platform=macOS",
"-derivedDataPath", str(derived_data),
"-disableAutomaticPackageResolution",
"-onlyUsePackageVersionsFromResolvedFile",
"-skipMacroValidation",
"ARCHS=arm64", "ONLY_ACTIVE_ARCH=NO",
"CODE_SIGNING_ALLOWED=NO", "CODE_SIGNING_REQUIRED=NO",
"CODE_SIGN_IDENTITY=",
],
cwd=repo_root,
)

output_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(
prefix=".build-review-monitor-", dir=output_dir
) as directory:
stage_root = Path(directory)
app = stage_root / APP_BUNDLE_NAME
product = derived_data / "Build" / "Products" / "Release" / APP_BUNDLE_NAME
environment = dict(os.environ)
environment.pop("DITTONORSRC", None)
run(
[
"/usr/bin/ditto", "--rsrc", "--extattr", "--acl", "--qtn",
str(product), str(app),
],
env=environment,
)
run(
[
"/usr/bin/codesign", "--force", "--options", "runtime",
"--timestamp=none", "--sign", signing_identity, str(app),
]
)
run(["/usr/bin/codesign", "--verify", "--deep", "--strict", str(app)])
pending_archive = stage_root / archive_name
run(
[str(python), str(repo_root / "scripts" / "build_dmg.py"),
str(app), str(pending_archive)],
env=environment,
)

archive = output_dir / archive_name
pending_archive.replace(archive)

print(f"Created local DMG: {archive}")
print("When reviews have finished, quit CodexReviewMonitor, open the DMG, "
Comment thread
lynnswap marked this conversation as resolved.
"and drag the app to Applications to replace it.")
return archive


def main(arguments: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--signing-identity", default="-", metavar="IDENTITY",
help="local codesign identity (default: '-' for ad-hoc signing)",
)
parsed = parser.parse_args(arguments)
if sys.version_info < (3, 10):
parser.error("DMG packaging requires Python 3.10 or newer.")
if not parsed.signing_identity:
parser.error("--signing-identity must not be empty.")
repo_root = Path(__file__).resolve().parent.parent
try:
build(repo_root, repo_root / "dist", parsed.signing_identity)
except (OSError, subprocess.CalledProcessError) as error:
print(f"error: {error}", file=sys.stderr)
return 1
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading