Files
ProjectQuint/Tools/release_mac_alpha.py

981 lines
32 KiB
Python
Executable File

#!/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"<test-run [^>]+>", 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"<strong>0\.1\.0 ALPHA \d+</strong>",
f"<strong>0.1.0 ALPHA {revision}</strong>",
label="download release label",
)
page = replace_exact_regex(
page,
r"<div><dt>VERSION</dt><dd>0\.1\.0-alpha\.\d+</dd></div>",
f"<div><dt>VERSION</dt><dd>{version}</dd></div>",
label="download version",
)
page = replace_exact_regex(
page,
r"<div><dt>SIZE</dt><dd>[\d.]+ MB</dd></div>",
f"<div><dt>SIZE</dt><dd>{human_size(size)}</dd></div>",
label="download size",
)
page = replace_exact_regex(
page,
r"<code>[0-9a-f]{64}</code>",
f"<code>{digest}</code>",
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"""<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>ProjectQuint Alpha Updates</title>
<link>{PUBLIC_ORIGIN}/updates/appcast.xml</link>
<description>Signed ProjectQuint macOS Alpha releases.</description>
<language>en</language>
<item>
<title>{escaped_version}</title>
<link>{PUBLIC_ORIGIN}/download/</link>
<pubDate>{html.escape(pub_date)}</pubDate>
<sparkle:version>{revision}</sparkle:version>
<sparkle:shortVersionString>{escaped_version}</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>12.0.0</sparkle:minimumSystemVersion>
<sparkle:releaseNotesLink>{PUBLIC_ORIGIN}/changelog/</sparkle:releaseNotesLink>
<enclosure
url="{escaped_url}"
length="{size}"
type="application/octet-stream"
sparkle:edSignature="{escaped_signature}" />
</item>
</channel>
</rss>
"""
write_text_atomic(SPARKLE_APPCAST, appcast)
changelog = read_text(CHANGELOG_PAGE)
marker = f"<p class=\"card-kicker\">{version.upper()}</p>"
if marker not in changelog:
items = "\n".join(
f" <li>{html.escape(change)}</li>" for change in changes
)
entry = f""" <article class="timeline-entry">
<div class="timeline-entry__date">
<span>{dt.datetime.now().year}</span>
<strong>{dt.datetime.now().strftime('%m.%d')}</strong>
</div>
<div class="timeline-entry__body">
{marker}
<h2>{html.escape(title)}</h2>
<ul>
{items}
</ul>
</div>
</article>
"""
changelog = changelog.replace(
' <section class="timeline">\n',
' <section class="timeline">\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 <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)