981 lines
32 KiB
Python
Executable File
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)
|