#!/usr/bin/env python3 """Fail-closed, manually triggered ProjectQuint macOS Alpha release harness.""" from __future__ import annotations import argparse import datetime as dt import email.utils import hashlib import html import json import os import plistlib import re import shlex import shutil import subprocess import sys import tempfile import urllib.error import urllib.request from pathlib import Path from typing import Iterable from release_configuration import load_configuration ROOT = Path(__file__).resolve().parents[1] VERSION_SOURCE = ( ROOT / "Assets/ProjectQuint/Runtime/Core/Release/ProjectQuintReleaseVersion.cs" ) VERSION_TEST = ( ROOT / "Assets/ProjectQuint/Tests/EditMode/ProjectQuintReleaseVersionTests.cs" ) SITE_ROOT = ROOT / "Website/projectquint-site" DOWNLOAD_PAGE = SITE_ROOT / "src/pages/download.astro" CHANGELOG_PAGE = SITE_ROOT / "src/pages/changelog.astro" UPDATE_MANIFEST = SITE_ROOT / "public/updates/latest.json" SPARKLE_APPCAST = SITE_ROOT / "public/updates/appcast.xml" SITE_README = SITE_ROOT / "README.md" CONFIGURATION = load_configuration(ROOT) STABLE_APP = CONFIGURATION.stable_app RELEASES_ROOT = CONFIGURATION.releases_root UNITY = Path( os.environ.get( "PROJECTQUINT_UNITY_PATH", "/Applications/Unity/Hub/Editor/6000.3.20f1/Unity.app/Contents/MacOS/Unity", ) ) SSH_KEY = CONFIGURATION.ssh_key DEPLOY_HOST = CONFIGURATION.deploy_host PUBLIC_ORIGIN = "https://projectquint.duckdns.org" REMOTE_RELEASE_ROOT = "/srv/projectquint/releases" PRODUCT_VERSION = "0.1.0" CHANNEL = "alpha" NPM = Path(shutil.which("npm") or "/usr/bin/npm") SPARKLE_ROOT = ROOT / "ThirdParty/Sparkle/2.9.4" SPARKLE_SIGN_UPDATE = SPARKLE_ROOT / "bin/sign_update" SPARKLE_KEY_ACCOUNT = "com.teamdenia.projectquint" SPARKLE_FEED_URL = f"{PUBLIC_ORIGIN}/updates/appcast.xml" SPARKLE_PUBLIC_ED_KEY = "w94qM58k8MAhIxYYQiR1ivyR5CyMW1tlRV68lQoLehA=" class ReleaseError(RuntimeError): pass def log(message: str) -> None: print(f"[release] {message}", flush=True) def run( command: Iterable[str | os.PathLike[str]], *, cwd: Path = ROOT, env: dict[str, str] | None = None, capture: bool = False, ) -> subprocess.CompletedProcess[str]: argv = [str(value) for value in command] log("$ " + " ".join(argv)) return subprocess.run( argv, cwd=cwd, env=env, check=True, text=True, capture_output=capture, ) def read_text(path: Path) -> str: if not path.is_file(): raise ReleaseError(f"Required file is missing: {path}") return path.read_text(encoding="utf-8") def write_text_atomic(path: Path, content: str) -> None: temporary = path.with_name(f".{path.name}.release-{os.getpid()}") temporary.write_text(content, encoding="utf-8") temporary.replace(path) def replace_exact_regex( content: str, pattern: str, replacement: str, *, label: str, ) -> str: updated, count = re.subn(pattern, replacement, content, flags=re.MULTILINE) if count != 1: raise ReleaseError(f"{label}: expected one match, found {count}") return updated def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def sha256_url(url: str) -> str: digest = hashlib.sha256() with urllib.request.urlopen(url, timeout=60) as response: if response.status != 200: raise ReleaseError(f"Expected HTTP 200 for {url}, got {response.status}") while True: chunk = response.read(1024 * 1024) if not chunk: break digest.update(chunk) return digest.hexdigest() def verify_public_artifact(url: str, digest: str, signature: str) -> None: with tempfile.TemporaryDirectory(prefix="projectquint-public-update-") as temporary: artifact = Path(temporary) / "ProjectQuint-update.dmg" with urllib.request.urlopen(url, timeout=60) as response: if response.status != 200: raise ReleaseError(f"Expected HTTP 200 for {url}, got {response.status}") with artifact.open("wb") as output: shutil.copyfileobj(response, output, length=1024 * 1024) if sha256_file(artifact) != digest: raise ReleaseError("Publicly downloaded DMG checksum does not match") run( [ SPARKLE_SIGN_UPDATE, "--account", SPARKLE_KEY_ACCOUNT, "--verify", artifact, signature, ] ) def fetch_json(url: str) -> dict: with urllib.request.urlopen(url, timeout=30) as response: if response.status != 200: raise ReleaseError(f"Expected HTTP 200 for {url}, got {response.status}") return json.load(response) def http_status(url: str, *, method: str = "GET") -> tuple[int, str]: request = urllib.request.Request(url, method=method) try: with urllib.request.urlopen(request, timeout=30) as response: return response.status, response.geturl() except urllib.error.HTTPError as error: return error.code, error.geturl() def require_tools() -> None: CONFIGURATION.require_deployment() required = [ UNITY, SSH_KEY, ROOT / "Tools/build-mac-stable-release.sh", SITE_ROOT / "ops/deploy-oci.sh", Path("/usr/bin/hdiutil"), Path("/usr/bin/codesign"), Path("/usr/bin/file"), Path("/usr/bin/ssh"), Path("/usr/bin/rsync"), NPM, Path("/usr/bin/ditto"), SPARKLE_SIGN_UPDATE, ] missing = [str(path) for path in required if not path.exists()] if missing: raise ReleaseError("Missing release dependencies:\n- " + "\n- ".join(missing)) def sign_sparkle_update(artifact: Path) -> str: result = run( [ SPARKLE_SIGN_UPDATE, "--account", SPARKLE_KEY_ACCOUNT, "-p", artifact, ], capture=True, ) signature = result.stdout.strip() if not re.fullmatch(r"[A-Za-z0-9+/]{86}==", signature): raise ReleaseError("Sparkle did not return a valid Ed25519 signature") run( [ SPARKLE_SIGN_UPDATE, "--account", SPARKLE_KEY_ACCOUNT, "--verify", artifact, signature, ] ) return signature def parse_revision_from_version(version: str) -> int: match = re.fullmatch(r"0\.1\.0-alpha\.(\d+)", version) if not match: raise ReleaseError(f"Unsupported live version format: {version}") return int(match.group(1)) def local_revision() -> int: source = read_text(VERSION_SOURCE) match = re.search(r"public const int ChannelRevision = (\d+);", source) if not match: raise ReleaseError("Could not read ChannelRevision from version source") return int(match.group(1)) def set_release_version(revision: int) -> None: version = f"{PRODUCT_VERSION}-{CHANNEL}.{revision}" source = read_text(VERSION_SOURCE) source = replace_exact_regex( source, r"public const int ChannelRevision = \d+;", f"public const int ChannelRevision = {revision};", label="ChannelRevision", ) source = replace_exact_regex( source, r"public const int BuildNumber = \d+;", f"public const int BuildNumber = {revision};", label="BuildNumber", ) write_text_atomic(VERSION_SOURCE, source) tests = read_text(VERSION_TEST) tests = replace_exact_regex( tests, r"Assert\.That\(ProjectQuintReleaseVersion\.ChannelRevision, Is\.EqualTo\(\d+\)\);", f"Assert.That(ProjectQuintReleaseVersion.ChannelRevision, Is.EqualTo({revision}));", label="version test revision", ) tests = replace_exact_regex( tests, r"Assert\.That\(ProjectQuintReleaseVersion\.BuildNumber, Is\.EqualTo\(\d+\)\);", f"Assert.That(ProjectQuintReleaseVersion.BuildNumber, Is.EqualTo({revision}));", label="version test build", ) tests = replace_exact_regex( tests, r'Assert\.That\(ProjectQuintReleaseVersion\.DisplayVersion, Is\.EqualTo\(".*?"\)\);', f'Assert.That(ProjectQuintReleaseVersion.DisplayVersion, Is.EqualTo("{version}"));', label="version test display", ) tests = replace_exact_regex( tests, r'Is\.EqualTo\(".*? \(build \d+\)"\)\);', f'Is.EqualTo("{version} (build {revision})"));', label="version test display with build", ) write_text_atomic(VERSION_TEST, tests) def run_unity_tests(version: str) -> tuple[Path, Path]: output = ROOT / "_workspace/validation" / f"release-{version}" output.mkdir(parents=True, exist_ok=True) related_xml = output / "version.xml" full_xml = output / "full.xml" run( [ UNITY, "-batchmode", "-nographics", "-projectPath", ROOT, "-runTests", "-testPlatform", "EditMode", "-testFilter", "ProjectQuint.Tests.ProjectQuintReleaseVersionTests", "-testResults", related_xml, "-logFile", output / "version.log", ] ) run( [ UNITY, "-batchmode", "-nographics", "-projectPath", ROOT, "-runTests", "-testPlatform", "EditMode", "-testResults", full_xml, "-logFile", output / "full.log", ] ) validate_test_result(related_xml, expected_failed=0) validate_test_result(full_xml, expected_failed=0) return related_xml, full_xml def validate_test_result(path: Path, *, expected_failed: int) -> None: text = read_text(path) header = re.search(r"]+>", text) if not header: raise ReleaseError(f"Unity test result is malformed: {path}") if 'result="Passed"' not in header.group(0): raise ReleaseError(f"Unity tests did not pass: {path}") failed = re.search(r'failed="(\d+)"', header.group(0)) if not failed or int(failed.group(1)) != expected_failed: raise ReleaseError(f"Unexpected failed test count: {path}") def build_stable() -> Path: env = os.environ.copy() env["PROJECTQUINT_BUILD_PATH"] = str(STABLE_APP) run([ROOT / "Tools/build-mac-stable-release.sh"], env=env) return STABLE_APP def plist_value(app: Path, key: str) -> str: with (app / "Contents/Info.plist").open("rb") as stream: return str(plistlib.load(stream)[key]) def validate_app(app: Path, revision: int) -> str: if not app.is_dir(): raise ReleaseError(f"Stable app was not produced: {app}") with (app / "Contents/Info.plist").open("rb") as stream: info = plistlib.load(stream) if str(info["CFBundleShortVersionString"]) != PRODUCT_VERSION: raise ReleaseError("Stable app marketing version does not match") if str(info["CFBundleVersion"]) != str(revision): raise ReleaseError("Stable app build number does not match") expected_sparkle = { "SUFeedURL": SPARKLE_FEED_URL, "SUPublicEDKey": SPARKLE_PUBLIC_ED_KEY, "SUEnableAutomaticChecks": False, "SUAutomaticallyUpdate": False, "SUAllowsAutomaticUpdates": False, "SUVerifyUpdateBeforeExtraction": True, } for key, value in expected_sparkle.items(): if info.get(key) != value: raise ReleaseError(f"Stable app Sparkle configuration mismatch: {key}") run(["/usr/bin/codesign", "--verify", "--deep", "--strict", "--verbose=2", app]) executable = app / "Contents/MacOS/ProjectQuint" file_output = run(["/usr/bin/file", executable], capture=True).stdout if "x86_64" not in file_output or "arm64" not in file_output: raise ReleaseError("Stable app is not a Universal x86_64/arm64 binary") sparkle = app / "Contents/Frameworks/Sparkle.framework" sparkle_binary = sparkle / "Versions/B/Sparkle" if not sparkle_binary.is_file(): raise ReleaseError("Stable app does not contain Sparkle.framework") updater_bundles = list(app.rglob("ProjectQuintUpdater.bundle")) if len(updater_bundles) != 1: raise ReleaseError("Stable app must contain exactly one Sparkle bridge") updater_binary = updater_bundles[0] / "Contents/MacOS/ProjectQuintUpdater" for binary, label in ( (sparkle_binary, "Sparkle framework"), (updater_binary, "Sparkle bridge"), ): file_output = run(["/usr/bin/file", binary], capture=True).stdout if "x86_64" not in file_output or "arm64" not in file_output: raise ReleaseError(f"{label} is not Universal") run(["/usr/bin/codesign", "--verify", "--deep", "--strict", sparkle]) run(["/usr/bin/codesign", "--verify", "--strict", updater_bundles[0]]) return sha256_file(executable) def create_dmg(app: Path, revision: int) -> tuple[Path, str, int]: version = f"{PRODUCT_VERSION}-{CHANNEL}.{revision}" release_dir = RELEASES_ROOT / version release_dir.mkdir(parents=True, exist_ok=True) artifact = release_dir / f"ProjectQuint-{version}-macOS-universal.dmg" checksum_path = artifact.with_name(f"{artifact.name}.sha256") stable_executable_hash = sha256_file(app / "Contents/MacOS/ProjectQuint") if artifact.exists(): validate_dmg(artifact, revision, stable_executable_hash) digest = sha256_file(artifact) size = artifact.stat().st_size write_text_atomic(checksum_path, f"{digest} {artifact}\n") log(f"Reusing verified local artifact: {artifact}") return artifact, digest, size with tempfile.TemporaryDirectory(prefix="projectquint-release-dmg-") as staging_name: staging = Path(staging_name) run(["/usr/bin/ditto", app, staging / "ProjectQuint.app"]) (staging / "Applications").symlink_to("/Applications") partial = release_dir / f".{artifact.stem}.partial-{os.getpid()}.dmg" try: run( [ "/usr/bin/hdiutil", "create", "-volname", f"ProjectQuint {PRODUCT_VERSION} Alpha {revision}", "-srcfolder", staging, "-format", "UDZO", partial, ] ) run(["/usr/bin/hdiutil", "verify", partial]) validate_dmg(partial, revision, stable_executable_hash) partial.replace(artifact) finally: if partial.exists(): partial.unlink() digest = sha256_file(artifact) size = artifact.stat().st_size write_text_atomic(checksum_path, f"{digest} {artifact}\n") return artifact, digest, size def validate_dmg(dmg: Path, revision: int, expected_executable_hash: str) -> None: run(["/usr/bin/hdiutil", "verify", dmg]) with tempfile.TemporaryDirectory(prefix="projectquint-release-mount-") as mount_name: mount = Path(mount_name) attached = False try: run( [ "/usr/bin/hdiutil", "attach", "-readonly", "-nobrowse", "-mountpoint", mount, dmg, ] ) attached = True applications = mount / "Applications" if not applications.is_symlink() or os.readlink(applications) != "/Applications": raise ReleaseError("DMG does not contain the /Applications symlink") mounted_app = mount / "ProjectQuint.app" validate_app(mounted_app, revision) mounted_hash = sha256_file( mounted_app / "Contents/MacOS/ProjectQuint" ) if mounted_hash != expected_executable_hash: raise ReleaseError("DMG app does not match the current Stable app") finally: if attached: run(["/usr/bin/hdiutil", "detach", mount]) def human_size(size: int) -> str: return f"{size / 1_000_000:.1f} MB" def update_site( revision: int, digest: str, size: int, sparkle_signature: str, title: str, changes: list[str], published_at: str, ) -> None: version = f"{PRODUCT_VERSION}-{CHANNEL}.{revision}" artifact_name = f"ProjectQuint-{version}-macOS-universal.dmg" download_path = f"/downloads/{version}/{artifact_name}" page = read_text(DOWNLOAD_PAGE) page = replace_exact_regex( page, r'href="/downloads/0\.1\.0-alpha\.\d+/ProjectQuint-0\.1\.0-alpha\.\d+-macOS-universal\.dmg"', f'href="{download_path}"', label="download href", ) page = replace_exact_regex( page, r"0\.1\.0 ALPHA \d+", f"0.1.0 ALPHA {revision}", label="download release label", ) page = replace_exact_regex( page, r"
VERSION
0\.1\.0-alpha\.\d+
", f"
VERSION
{version}
", label="download version", ) page = replace_exact_regex( page, r"
SIZE
[\d.]+ MB
", f"
SIZE
{human_size(size)}
", label="download size", ) page = replace_exact_regex( page, r"[0-9a-f]{64}", f"{digest}", label="download checksum", ) write_text_atomic(DOWNLOAD_PAGE, page) manifest = { "schemaVersion": 1, "channel": CHANNEL, "available": True, "version": version, "buildNumber": revision, "publishedAt": published_at, "downloadUrl": f"{PUBLIC_ORIGIN}{download_path}", "notesUrl": "/changelog/", "sha256": digest, "sizeBytes": size, } write_text_atomic( UPDATE_MANIFEST, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", ) published = dt.datetime.fromisoformat(published_at) if published.tzinfo is None: raise ReleaseError("published_at must include a timezone") pub_date = email.utils.format_datetime(published) escaped_version = html.escape(version, quote=True) escaped_url = html.escape(f"{PUBLIC_ORIGIN}{download_path}", quote=True) escaped_signature = html.escape(sparkle_signature, quote=True) appcast = f""" ProjectQuint Alpha Updates {PUBLIC_ORIGIN}/updates/appcast.xml Signed ProjectQuint macOS Alpha releases. en {escaped_version} {PUBLIC_ORIGIN}/download/ {html.escape(pub_date)} {revision} {escaped_version} 12.0.0 {PUBLIC_ORIGIN}/changelog/ """ write_text_atomic(SPARKLE_APPCAST, appcast) changelog = read_text(CHANGELOG_PAGE) marker = f"

{version.upper()}

" if marker not in changelog: items = "\n".join( f"
  • {html.escape(change)}
  • " for change in changes ) entry = f"""
    {marker}

    {html.escape(title)}

      {items}
    """ changelog = changelog.replace( '
    \n', '
    \n' + entry, 1, ) write_text_atomic(CHANGELOG_PAGE, changelog) readme = read_text(SITE_README) heading = f"## {version} release" if heading not in readme: change_lines = "\n".join(f" - {change}" for change in changes) readme += f""" {heading} - Artifact URL: `{PUBLIC_ORIGIN}{download_path}` - Server path: `{REMOTE_RELEASE_ROOT}/{version}/{artifact_name}` - SHA-256: `{digest}` - Size: `{size:,}` bytes - Changes: {change_lines} - Earlier release artifacts remain immutable at their original URLs. """ write_text_atomic(SITE_README, readme) def validate_site_build(version: str, digest: str, sparkle_signature: str) -> None: run([NPM, "run", "check"], cwd=SITE_ROOT) run([NPM, "run", "build"], cwd=SITE_ROOT) dist = SITE_ROOT / "dist" if not (dist / "download/index.html").is_file(): raise ReleaseError("Astro download page was not built") combined = "\n".join( path.read_text(encoding="utf-8", errors="ignore") for path in dist.rglob("*") if path.is_file() and path.stat().st_size < 5_000_000 ) if ( version not in combined or digest not in combined or sparkle_signature not in combined ): raise ReleaseError("Built site does not contain the release metadata") appcast = dist / "updates/appcast.xml" if not appcast.is_file(): raise ReleaseError("Sparkle appcast was not built") forbidden = ["/Users/", "/home/"] current_user = os.environ.get("USER", "") if current_user: forbidden.append(current_user) leaked = [token for token in forbidden if token.lower() in combined.lower()] if leaked: raise ReleaseError(f"Public site privacy scan failed: {leaked}") if list(dist.rglob("*.map")): raise ReleaseError("Public site contains source maps") def ssh_args() -> list[str]: return [ "/usr/bin/ssh", "-i", str(SSH_KEY), "-o", "IdentitiesOnly=yes", "-o", "BatchMode=yes", DEPLOY_HOST, ] def remote_command(command: str, *, capture: bool = False) -> subprocess.CompletedProcess[str]: return run([*ssh_args(), command], capture=capture) def upload_artifact( artifact: Path, digest: str, revision: int, *, resume: bool, ) -> None: version = f"{PRODUCT_VERSION}-{CHANNEL}.{revision}" remote_dir = f"{REMOTE_RELEASE_ROOT}/{version}" remote_artifact = f"{remote_dir}/{artifact.name}" exists = remote_command( f"test -f '{remote_artifact}' && sha256sum '{remote_artifact}' || true", capture=True, ).stdout.strip() if exists: remote_digest = exists.split()[0] if not resume: raise ReleaseError( f"Remote immutable artifact already exists: {remote_artifact}. " "Use --resume only after verifying this is the same interrupted release." ) if remote_digest != digest: raise ReleaseError("Existing remote artifact checksum does not match") log("Reusing matching remote immutable artifact") return remote_command( f"sudo mkdir -p '{remote_dir}' && " f"sudo chown ubuntu:ubuntu '{remote_dir}'" ) uploading = f"{remote_artifact}.uploading-{os.getpid()}" run( [ "/usr/bin/rsync", "-a", "-e", shlex.join(ssh_args()[:-1]), artifact, f"{DEPLOY_HOST}:{uploading}", ] ) result = remote_command(f"sha256sum '{uploading}'", capture=True) if result.stdout.split()[0] != digest: remote_command(f"rm -f '{uploading}'") raise ReleaseError("Uploaded artifact checksum does not match") remote_command( f"mv '{uploading}' '{remote_artifact}' && " f"printf '%s %s\\n' '{digest}' '{artifact.name}' > " f"'{remote_artifact}.sha256'" ) def deploy_site() -> None: run([SITE_ROOT / "ops/deploy-oci.sh"], cwd=SITE_ROOT) def verify_live( revision: int, digest: str, size: int, sparkle_signature: str, previous_download_url: str, ) -> None: version = f"{PRODUCT_VERSION}-{CHANNEL}.{revision}" artifact_name = f"ProjectQuint-{version}-macOS-universal.dmg" artifact_url = f"{PUBLIC_ORIGIN}/downloads/{version}/{artifact_name}" routes = [ "/", "/download/", "/guide/", "/resources/", "/changelog/", "/privacy/", "/robots.txt", "/sitemap.xml", "/updates/latest.json", "/updates/appcast.xml", ] for route in routes: status, _ = http_status(f"{PUBLIC_ORIGIN}{route}") if status != 200: raise ReleaseError(f"Live route failed ({status}): {route}") for url in [previous_download_url, artifact_url]: status, _ = http_status(url, method="HEAD") if status != 200: raise ReleaseError(f"Live artifact failed ({status}): {url}") manifest = fetch_json(f"{PUBLIC_ORIGIN}/updates/latest.json") expected = { "version": version, "buildNumber": revision, "downloadUrl": artifact_url, "sha256": digest, "sizeBytes": size, } for key, value in expected.items(): if manifest.get(key) != value: raise ReleaseError(f"Live manifest mismatch for {key}") with urllib.request.urlopen(f"{PUBLIC_ORIGIN}/download/", timeout=30) as response: page = response.read().decode("utf-8") if version not in page or digest not in page: raise ReleaseError("Live download page does not show the new release") with urllib.request.urlopen( f"{PUBLIC_ORIGIN}/updates/appcast.xml", timeout=30, ) as response: appcast = response.read().decode("utf-8") if ( version not in appcast or artifact_url not in appcast or sparkle_signature not in appcast or f'length="{size}"' not in appcast ): raise ReleaseError("Live Sparkle appcast does not match the release") verify_public_artifact(artifact_url, digest, sparkle_signature) redirect_request = urllib.request.Request( "http://projectquint.duckdns.org/download/", method="GET", ) opener = urllib.request.build_opener(NoRedirectHandler()) try: opener.open(redirect_request, timeout=30) raise ReleaseError("HTTP endpoint did not redirect to HTTPS") except urllib.error.HTTPError as error: if error.code not in (301, 302, 307, 308): raise location = error.headers.get("Location", "") if not location.startswith("https://"): raise ReleaseError("HTTP redirect target is not HTTPS") origin = remote_command( "systemctl is-active nginx && sudo nginx -t", capture=True, ) if "active" not in origin.stdout: raise ReleaseError("Nginx is not active after deployment") class NoRedirectHandler(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None def write_release_report( revision: int, artifact: Path, digest: str, size: int, sparkle_signature: str, executable_hash: str, related_xml: Path, full_xml: Path, ) -> Path: version = f"{PRODUCT_VERSION}-{CHANNEL}.{revision}" report_dir = ROOT / "_workspace/release-reports" report_dir.mkdir(parents=True, exist_ok=True) report = report_dir / f"{version}.json" data = { "version": version, "buildNumber": revision, "artifact": str(artifact), "artifactSha256": digest, "artifactSizeBytes": size, "sparkleEdSignature": sparkle_signature, "sparkleFeedUrl": f"{PUBLIC_ORIGIN}/updates/appcast.xml", "mainExecutableSha256": executable_hash, "relatedTestResult": str(related_xml), "fullEditModeResult": str(full_xml), "downloadUrl": ( f"{PUBLIC_ORIGIN}/downloads/{version}/{artifact.name}" ), "verifiedAt": dt.datetime.now().astimezone().isoformat(timespec="seconds"), } write_text_atomic(report, json.dumps(data, ensure_ascii=False, indent=2) + "\n") return report def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description=( "Manually publish one ProjectQuint macOS Alpha. Without --publish, " "the command performs a read-only release plan." ) ) parser.add_argument("--revision", type=int, required=True) parser.add_argument("--publish", action="store_true") parser.add_argument("--resume", action="store_true") parser.add_argument("--title") parser.add_argument("--change", action="append", default=[]) return parser.parse_args() def main() -> int: args = parse_args() if args.revision <= 0: raise ReleaseError("--revision must be a positive integer") if args.resume and not args.publish: raise ReleaseError("--resume requires --publish") if args.publish and (not args.title or not args.change): raise ReleaseError("--publish requires --title and at least one --change") require_tools() live_manifest = fetch_json(f"{PUBLIC_ORIGIN}/updates/latest.json") live_revision = parse_revision_from_version(str(live_manifest["version"])) source_revision = local_revision() target_version = f"{PRODUCT_VERSION}-{CHANNEL}.{args.revision}" log(f"Live release: {live_manifest['version']} (build {live_manifest['buildNumber']})") log(f"Local version source revision: {source_revision}") log(f"Target release: {target_version} (build {args.revision})") if args.revision < live_revision: raise ReleaseError( "Target revision cannot be older than the live immutable release" ) if args.revision == live_revision and not args.resume: raise ReleaseError( "Target revision is already live. Use --resume only to finish or " "re-verify the exact interrupted release." ) if source_revision not in (live_revision, args.revision): raise ReleaseError( "Local version source must match either the live or target revision" ) remote_command("true") remote_artifact = ( f"{REMOTE_RELEASE_ROOT}/{target_version}/" f"ProjectQuint-{target_version}-macOS-universal.dmg" ) remote_exists = ( remote_command( f"test -f '{remote_artifact}' && printf exists || true", capture=True, ).stdout.strip() == "exists" ) if args.publish and remote_exists and not args.resume: raise ReleaseError( f"Remote immutable artifact already exists: {remote_artifact}. " "Use --resume only for the same interrupted release." ) if not args.publish: log("PLAN ONLY: no files, builds, manifests, or remote state were changed.") log( "Publish with --publish --title and one or more " "--change <item> arguments." ) return 0 if source_revision != args.revision: set_release_version(args.revision) related_xml, full_xml = run_unity_tests(target_version) app = build_stable() executable_hash = validate_app(app, args.revision) artifact, digest, size = create_dmg(app, args.revision) sparkle_signature = sign_sparkle_update(artifact) published_at = dt.datetime.now().astimezone().isoformat(timespec="seconds") update_site( args.revision, digest, size, sparkle_signature, args.title, args.change, published_at, ) validate_site_build(target_version, digest, sparkle_signature) upload_artifact( artifact, digest, args.revision, resume=args.resume, ) deploy_site() verify_live( args.revision, digest, size, sparkle_signature, str(live_manifest["downloadUrl"]), ) report = write_release_report( args.revision, artifact, digest, size, sparkle_signature, executable_hash, related_xml, full_xml, ) log(f"RELEASE VERIFIED: {target_version}") log(f"Download: {PUBLIC_ORIGIN}/download/") log(f"Report: {report}") return 0 if __name__ == "__main__": try: raise SystemExit(main()) except (ReleaseError, subprocess.CalledProcessError, OSError) as error: print(f"[release] FAILED: {error}", file=sys.stderr, flush=True) raise SystemExit(1)