프로젝트 소스 공개
This commit is contained in:
Executable
+980
@@ -0,0 +1,980 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user