프로젝트 소스 공개

This commit is contained in:
2026-09-06 17:11:40 +09:00
commit 0f0431de5a
676 changed files with 106118 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
# ProjectQuint macOS Alpha release harness
Public releases are always manually approved. Normal implementation, Unity
tests, and Stable builds never invoke this harness and never change the public
update manifest.
## Machine configuration
Keep machine-specific paths out of source control. Create `.release.local.json`
at the repository root, or set the corresponding environment variables:
| JSON field | Environment variable | Purpose |
| --- | --- | --- |
| `deploy_host` | `PROJECTQUINT_DEPLOY_HOST` | SSH destination |
| `ssh_key` | `PROJECTQUINT_DEPLOY_KEY` | Absolute path to an existing SSH identity |
| `remote_source` | `PROJECTQUINT_REMOTE_SOURCE` | Remote static-site source directory |
| `stable_app` | `PROJECTQUINT_STABLE_APP` | Absolute output path ending in `.app` |
| `releases_root` | `PROJECTQUINT_RELEASES_ROOT` | Local release artifact directory |
Environment variables take precedence. Build outputs default to
`_workspace/builds/Stable/ProjectQuint.app` and `_workspace/releases`.
The deployment host and SSH identity are required before any remote operation.
Never put private-key contents or authentication tokens in this file.
## Read-only plan
```bash
Tools/release-mac-alpha.sh --revision 3
```
This checks the live manifest, local release source, SSH access, and required
tools. It does not edit files, build, upload, or deploy.
## Publish
The Stable app may remain open while the replacement build is produced. The
running process is never terminated by the harness; it continues to run the
previous binary until it exits.
Run:
```bash
Tools/release-mac-alpha.sh \
--revision 3 \
--publish \
--title "Pack Manager and first-play improvements" \
--change "Non-blocking first-library guidance" \
--change "Recommended 4UE, AERY, and 6UE difficulty tables"
```
The harness:
1. updates the explicit release revision and build number;
2. runs the release-version and full Unity EditMode suites;
3. builds and validates the macOS Universal Stable;
4. creates, mounts, and validates a DMG with an Applications shortcut;
5. signs the DMG with the ProjectQuint Sparkle Ed25519 key stored in Keychain;
6. updates and builds the download page, changelog, JSON manifest, and appcast;
7. uploads the DMG to a new immutable OCI release directory;
8. deploys the static site only after the artifact checksum matches remotely;
9. downloads the public DMG and verifies both SHA-256 and its Sparkle signature.
If a run stops after the exact artifact has already reached OCI but before the
site is switched, inspect the failure and rerun the same command with
`--resume`. Resume accepts the remote artifact only when its checksum matches
the locally verified DMG.
The previous public manifest and release remain available if any step before
the final site deployment fails. Existing remote release artifacts are never
overwritten.
## Sparkle signing contract
- Framework: Sparkle 2.9.4
- Feed: `https://projectquint.duckdns.org/updates/appcast.xml`
- Keychain account: `com.teamdenia.projectquint`
- Public key: `w94qM58k8MAhIxYYQiR1ivyR5CyMW1tlRV68lQoLehA=`
The private key stays only in the macOS login Keychain. Never copy it into the
project, app bundle, website, deployment server, CI variables, logs, or release
reports. Backing up or moving that key is a separate, explicitly approved
security operation.
The first public build that embeds Sparkle is a bridge release: users on older
builds must install that DMG manually. Automatic signed replacement works from
that bridge release to the following release. Because the current Alpha uses
ad-hoc code signing, Gatekeeper installation instructions still apply to the
bridge release; Sparkle does not replace Apple notarization.
+96
View File
@@ -0,0 +1,96 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIRECTORY="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIRECTORY/.." && pwd)"
FFMPEG_ROOT="$PROJECT_ROOT/ThirdParty/FFmpeg"
SOURCE_ARCHIVE="$FFMPEG_ROOT/Source/ffmpeg-8.1.2.tar.xz"
OUTPUT_BINARY="$FFMPEG_ROOT/macOS/ffmpeg"
EXPECTED_SHA256="464beb5e7bf0c311e68b45ae2f04e9cc2af88851abb4082231742a74d97b524c"
BUILD_ROOT="$(mktemp -d /tmp/projectquint-ffmpeg-build.XXXXXX)"
BUILD_JOBS="$(sysctl -n hw.ncpu)"
cleanup() {
rm -rf "$BUILD_ROOT"
}
trap cleanup EXIT
if [[ ! -f "$SOURCE_ARCHIVE" ]]; then
echo "Missing pinned source archive: $SOURCE_ARCHIVE" >&2
exit 1
fi
ACTUAL_SHA256="$(shasum -a 256 "$SOURCE_ARCHIVE" | awk '{print $1}')"
if [[ "$ACTUAL_SHA256" != "$EXPECTED_SHA256" ]]; then
echo "FFmpeg source hash mismatch: $ACTUAL_SHA256" >&2
exit 1
fi
tar -xf "$SOURCE_ARCHIVE" -C "$BUILD_ROOT"
SOURCE_ROOT="$BUILD_ROOT/ffmpeg-8.1.2"
COMMON_OPTIONS=(
--target-os=darwin
--cc=clang
--disable-everything
--disable-autodetect
--disable-doc
--disable-debug
--disable-network
--disable-x86asm
--enable-small
--enable-ffmpeg
--disable-ffprobe
--disable-ffplay
--enable-avcodec
--enable-avformat
--enable-avfilter
--enable-swscale
--enable-protocol=file
--enable-demuxer=asf,avi
--enable-decoder=wmv1,wmv2,wmv3,vc1,mpeg4,msmpeg4v1,msmpeg4v2,msmpeg4v3,mjpeg,h263,h264,rawvideo
--enable-parser=vc1,mpeg4video,mjpeg,h263,h264
--enable-encoder=h264_videotoolbox
--enable-muxer=mp4
--enable-filter=format,scale
--enable-videotoolbox
)
build_architecture() {
local architecture="$1"
local build_directory="$BUILD_ROOT/build-$architecture"
local install_directory="$BUILD_ROOT/install-$architecture"
mkdir -p "$build_directory" "$install_directory"
(
cd "$build_directory"
"$SOURCE_ROOT/configure" \
"--prefix=$install_directory" \
"--arch=$architecture" \
"--extra-cflags=-arch $architecture -mmacosx-version-min=12.0" \
"--extra-ldflags=-arch $architecture -mmacosx-version-min=12.0" \
"${COMMON_OPTIONS[@]}"
make -j"$BUILD_JOBS"
make install
)
}
build_architecture arm64
build_architecture x86_64
mkdir -p "$(dirname "$OUTPUT_BINARY")"
lipo -create \
"$BUILD_ROOT/install-arm64/bin/ffmpeg" \
"$BUILD_ROOT/install-x86_64/bin/ffmpeg" \
-output "$OUTPUT_BINARY"
chmod 755 "$OUTPUT_BINARY"
lipo "$OUTPUT_BINARY" -verify_arch arm64 x86_64
VERSION_OUTPUT="$($OUTPUT_BINARY -version)"
if [[ "$VERSION_OUTPUT" == *"--enable-gpl"* ||
"$VERSION_OUTPUT" == *"--enable-nonfree"* ||
"$VERSION_OUTPUT" == *"libx264"* ]]; then
echo "Refusing non-LGPL FFmpeg configuration." >&2
exit 1
fi
echo "Built Universal LGPL FFmpeg: $OUTPUT_BINARY"
+116
View File
@@ -0,0 +1,116 @@
#!/bin/zsh
set -euo pipefail
script_dir=${0:A:h}
project_root=${script_dir:h}
unity_executable=${PROJECTQUINT_UNITY_PATH:-/Applications/Unity/Hub/Editor/6000.3.20f1/Unity.app/Contents/MacOS/Unity}
output_app=${PROJECTQUINT_BUILD_PATH:-}
if [[ -z "$output_app" || "$output_app" != /* || "$output_app" != *.app ]]; then
print -u2 "PROJECTQUINT_BUILD_PATH must be an absolute .app output path."
exit 2
fi
if [[ ! -x "$unity_executable" ]]; then
print -u2 "Unity executable was not found: $unity_executable"
exit 2
fi
release_manifest="$project_root/Packages/manifest.release.json"
if [[ ! -f "$release_manifest" ]]; then
print -u2 "Release package manifest was not found: $release_manifest"
exit 2
fi
fmod_package=("$project_root"/Library/PackageCache/com.fmod.fmod-for-unity-2-03@*(N/))
if (( ${#fmod_package} != 1 )); then
print -u2 "Expected exactly one cached FMOD package, found ${#fmod_package}."
exit 2
fi
staging_base=${TMPDIR:-/tmp}
staging_root=$(/usr/bin/mktemp -d "$staging_base/ProjectQuint-release.XXXXXX")
staging_project="$staging_root/ProjectQuint"
log_directory="$project_root/_workspace/logs"
timestamp=$(/bin/date +%Y%m%d-%H%M%S)
build_log="$log_directory/release-staging-build-$timestamp.log"
previous_app=
build_succeeded=0
cleanup() {
local exit_code=$?
trap - EXIT INT TERM
if (( build_succeeded == 0 )) && [[ -n "$previous_app" && -d "$previous_app" ]]; then
if [[ -d "$output_app" ]]; then
local failed_directory="$project_root/_workspace/failed-builds/$timestamp"
/bin/mkdir -p "$failed_directory"
/bin/mv "$output_app" "$failed_directory/ProjectQuint.app"
fi
/bin/mkdir -p "${output_app:h}"
/bin/mv "$previous_app" "$output_app"
fi
/bin/rm -rf "$staging_root"
exit "$exit_code"
}
trap cleanup EXIT INT TERM
/bin/mkdir -p "$staging_project/Packages" "$log_directory"
for source_directory in Assets ProjectSettings ThirdParty Tools Native; do
/usr/bin/rsync -a "$project_root/$source_directory/" "$staging_project/$source_directory/"
done
/bin/cp "$release_manifest" "$staging_project/Packages/manifest.json"
embedded_fmod="$staging_project/Packages/com.fmod.fmod-for-unity-2-03"
/bin/cp -cR "${fmod_package[1]}" "$embedded_fmod"
if [[ "$embedded_fmod" != "$staging_project"/Packages/* || ! -d "$embedded_fmod/FMOD" ]]; then
print -u2 "Refusing to prune an unresolved FMOD staging path: $embedded_fmod"
exit 2
fi
/bin/rm -rf \
"$embedded_fmod/FMOD/addons/Haptics" \
"$embedded_fmod/FMOD/addons/Haptics.meta" \
"$embedded_fmod/FMOD/addons/ResonanceAudio" \
"$embedded_fmod/FMOD/addons/ResonanceAudio.meta" \
"$embedded_fmod/FMOD/platforms/mac/lib/fmod_haptics.bundle" \
"$embedded_fmod/FMOD/platforms/mac/lib/fmod_haptics.bundle.meta" \
"$embedded_fmod/FMOD/platforms/mac/lib/resonanceaudio.bundle" \
"$embedded_fmod/FMOD/platforms/mac/lib/resonanceaudio.bundle.meta"
for platform_name in android html5 ios linux tvos uwp visionos win; do
/bin/rm -rf \
"$embedded_fmod/FMOD/platforms/$platform_name/lib" \
"$embedded_fmod/FMOD/platforms/$platform_name/lib.meta"
done
/bin/rm -f \
"$embedded_fmod/FMOD/platform_ios.mm" \
"$embedded_fmod/FMOD/platform_ios.mm.meta"
if [[ -d "$output_app" ]]; then
build_root=${output_app:h:h}
previous_directory="$build_root/Stable-before-release-prune-$timestamp"
/bin/mkdir -p "$previous_directory"
previous_app="$previous_directory/ProjectQuint.app"
/bin/mv "$output_app" "$previous_app"
fi
PROJECTQUINT_BUILD_PATH="$output_app" \
"$unity_executable" \
-batchmode \
-nographics \
-projectPath "$staging_project" \
-executeMethod ProjectQuint.Editor.ProjectQuintBuild.BuildMacStable \
-quit \
-logFile "$build_log"
if [[ ! -d "$output_app" ]]; then
print -u2 "Unity exited without producing the expected app: $output_app"
exit 1
fi
build_succeeded=1
print "Stable release build: $output_app"
print "Build log: $build_log"
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec /usr/bin/python3 "$SCRIPT_DIR/release_mac_alpha.py" "$@"
+99
View File
@@ -0,0 +1,99 @@
"""Machine-specific release configuration, kept outside the public source tree."""
from __future__ import annotations
import argparse
import json
import os
import shlex
from dataclasses import dataclass
from pathlib import Path
class ConfigurationError(RuntimeError):
pass
@dataclass(frozen=True)
class ReleaseConfiguration:
stable_app: Path
releases_root: Path
deploy_host: str
ssh_key: Path | None
remote_source: str
def require_deployment(self) -> None:
if not self.deploy_host:
raise ConfigurationError('Set PROJECTQUINT_DEPLOY_HOST or deploy_host in .release.local.json')
if self.deploy_host.startswith('-') or any(character.isspace() for character in self.deploy_host):
raise ConfigurationError('The deployment host must be one SSH destination')
if self.ssh_key is None or not self.ssh_key.is_file():
raise ConfigurationError('PROJECTQUINT_DEPLOY_KEY must name an existing SSH identity file')
# These paths are passed to a remote shell by the deployment tools.
if not self.remote_source.startswith('/') or any(
character not in '/._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
for character in self.remote_source
):
raise ConfigurationError('The remote source must be an absolute path without shell metacharacters')
def environment(self) -> dict[str, str]:
self.require_deployment()
return {
'PROJECTQUINT_STABLE_APP': str(self.stable_app),
'PROJECTQUINT_RELEASES_ROOT': str(self.releases_root),
'PROJECTQUINT_DEPLOY_HOST': self.deploy_host,
'PROJECTQUINT_DEPLOY_KEY': str(self.ssh_key),
'PROJECTQUINT_REMOTE_SOURCE': self.remote_source,
}
def shell_exports(self) -> str:
return '\n'.join(f'export {key}={shlex.quote(value)}' for key, value in self.environment().items())
def load_configuration(root: Path) -> ReleaseConfiguration:
path = root / '.release.local.json'
values = {}
if path.exists():
try:
values = json.loads(path.read_text(encoding='utf-8'))
except (OSError, ValueError) as error:
raise ConfigurationError('Cannot read .release.local.json') from error
allowed = {'stable_app', 'releases_root', 'deploy_host', 'ssh_key', 'remote_source'}
if not isinstance(values, dict) or values.keys() - allowed:
raise ConfigurationError('Unknown release configuration fields')
if any(not isinstance(value, str) or not value for value in values.values()):
raise ConfigurationError('Release configuration values must be non-empty strings')
def value(key: str, environment: str, default: str) -> str:
return os.environ.get(environment, values.get(key, default))
def absolute_path(key: str, environment: str, default: Path | None) -> Path | None:
raw = value(key, environment, str(default) if default else '')
if not raw:
return None
result = Path(raw)
if not result.is_absolute():
raise ConfigurationError(f'{environment} must be an absolute path')
return result
stable_app = absolute_path('stable_app', 'PROJECTQUINT_STABLE_APP', root / '_workspace/builds/Stable/ProjectQuint.app')
releases_root = absolute_path('releases_root', 'PROJECTQUINT_RELEASES_ROOT', root / '_workspace/releases')
if stable_app is None or stable_app.suffix != '.app' or releases_root is None:
raise ConfigurationError('Release output paths must include an absolute .app path and releases directory')
return ReleaseConfiguration(
stable_app=stable_app,
releases_root=releases_root,
deploy_host=value('deploy_host', 'PROJECTQUINT_DEPLOY_HOST', ''),
ssh_key=absolute_path('ssh_key', 'PROJECTQUINT_DEPLOY_KEY', None),
remote_source=value('remote_source', 'PROJECTQUINT_REMOTE_SOURCE', '/srv/projectquint/site-source'),
)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--shell', action='store_true', required=True)
parser.parse_args()
try:
print(load_configuration(Path(__file__).resolve().parents[1]).shell_exports())
except ConfigurationError as error:
parser.exit(2, f'Release configuration: {error}\n')
+980
View File
@@ -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)
+63
View File
@@ -0,0 +1,63 @@
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
TOOLS = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(TOOLS))
from release_configuration import ConfigurationError, load_configuration
class ReleaseConfigurationTests(unittest.TestCase):
def test_public_checkout_uses_local_build_paths_and_requires_deploy_target(self):
with tempfile.TemporaryDirectory() as temporary, mock.patch.dict(os.environ, {}, clear=True):
root = Path(temporary)
config = load_configuration(root)
self.assertEqual(config.stable_app, root / '_workspace/builds/Stable/ProjectQuint.app')
self.assertEqual(config.releases_root, root / '_workspace/releases')
with self.assertRaisesRegex(ConfigurationError, 'DEPLOY_HOST'):
config.require_deployment()
def test_environment_overrides_local_config_and_preserves_spaces(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
key = root / 'test key'
key.touch()
(root / '.release.local.json').write_text(json.dumps({
'deploy_host': 'release@host.invalid',
'ssh_key': str(key),
'remote_source': '/srv/projectquint/site-source',
}))
with mock.patch.dict(os.environ, {'PROJECTQUINT_DEPLOY_HOST': 'release@override.invalid'}, clear=True):
config = load_configuration(root)
config.require_deployment()
self.assertEqual(config.deploy_host, 'release@override.invalid')
self.assertEqual(config.ssh_key, key)
self.assertEqual(config.remote_source, '/srv/projectquint/site-source')
def test_invalid_configuration_fails_before_deployment(self):
with tempfile.TemporaryDirectory() as temporary, mock.patch.dict(os.environ, {}, clear=True):
root = Path(temporary)
for text in ['{', '{"unexpected": "value"}', '{"ssh_key": "relative/key"}']:
with self.subTest(text=text):
(root / '.release.local.json').write_text(text)
with self.assertRaises(ConfigurationError):
load_configuration(root)
def test_shell_export_quotes_values_and_does_not_execute_them(self):
import subprocess
with tempfile.TemporaryDirectory() as temporary, mock.patch.dict(os.environ, {}, clear=True):
root = Path(temporary)
key = root / "key with spaces"
key.touch()
(root / '.release.local.json').write_text(json.dumps({
'deploy_host': 'release@host.invalid', 'ssh_key': str(key),
'remote_source': '/srv/projectquint/site-source',
}))
config = load_configuration(root)
exports = config.shell_exports()
output = subprocess.check_output(['bash', '-c', exports + '\nprintf "%s" "$PROJECTQUINT_DEPLOY_KEY"'], text=True)
self.assertEqual(output, str(key))
+154
View File
@@ -0,0 +1,154 @@
import importlib.util
import json
import tempfile
import unittest
import sys
from pathlib import Path
from unittest import mock
MODULE_PATH = Path(__file__).resolve().parents[1] / "release_mac_alpha.py"
sys.path.insert(0, str(MODULE_PATH.parent))
SPEC = importlib.util.spec_from_file_location("release_mac_alpha", MODULE_PATH)
release = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(release)
class ReleaseHarnessTests(unittest.TestCase):
def test_parse_revision_accepts_current_alpha_contract(self):
self.assertEqual(release.parse_revision_from_version("0.1.0-alpha.12"), 12)
with self.assertRaises(release.ReleaseError):
release.parse_revision_from_version("0.1.0")
def test_replace_exact_regex_fails_closed_on_ambiguous_input(self):
self.assertEqual(
release.replace_exact_regex(
"revision=2",
r"revision=\d+",
"revision=3",
label="revision",
),
"revision=3",
)
with self.assertRaises(release.ReleaseError):
release.replace_exact_regex(
"revision=2\nrevision=2",
r"revision=\d+",
"revision=3",
label="revision",
)
def test_set_release_version_updates_source_and_independent_contract_test(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "ProjectQuintReleaseVersion.cs"
tests = root / "ProjectQuintReleaseVersionTests.cs"
source.write_text(
"public const int ChannelRevision = 2;\n"
"public const int BuildNumber = 2;\n",
encoding="utf-8",
)
tests.write_text(
"Assert.That(ProjectQuintReleaseVersion.ChannelRevision, Is.EqualTo(2));\n"
"Assert.That(ProjectQuintReleaseVersion.BuildNumber, Is.EqualTo(2));\n"
'Assert.That(ProjectQuintReleaseVersion.DisplayVersion, Is.EqualTo("0.1.0-alpha.2"));\n'
'Is.EqualTo("0.1.0-alpha.2 (build 2)"));\n',
encoding="utf-8",
)
with mock.patch.object(release, "VERSION_SOURCE", source), mock.patch.object(
release, "VERSION_TEST", tests
):
release.set_release_version(3)
self.assertIn("ChannelRevision = 3", source.read_text())
self.assertIn("BuildNumber = 3", source.read_text())
self.assertIn("0.1.0-alpha.3", tests.read_text())
self.assertIn("build 3", tests.read_text())
def test_update_site_writes_download_manifest_and_one_changelog_entry(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
download = root / "download.astro"
changelog = root / "changelog.astro"
manifest = root / "latest.json"
appcast = root / "appcast.xml"
readme = root / "README.md"
download.write_text(
'href="/downloads/0.1.0-alpha.2/ProjectQuint-0.1.0-alpha.2-macOS-universal.dmg"\n'
"<strong>0.1.0 ALPHA 2</strong>\n"
"<div><dt>VERSION</dt><dd>0.1.0-alpha.2</dd></div>\n"
"<div><dt>SIZE</dt><dd>77.7 MB</dd></div>\n"
f"<code>{'a' * 64}</code>\n",
encoding="utf-8",
)
changelog.write_text(' <section class="timeline">\n', encoding="utf-8")
manifest.write_text("{}\n", encoding="utf-8")
appcast.write_text("", encoding="utf-8")
readme.write_text("# Site\n", encoding="utf-8")
digest = "b" * 64
signature = "A" * 86 + "=="
patches = [
mock.patch.object(release, "DOWNLOAD_PAGE", download),
mock.patch.object(release, "CHANGELOG_PAGE", changelog),
mock.patch.object(release, "UPDATE_MANIFEST", manifest),
mock.patch.object(release, "SPARKLE_APPCAST", appcast),
mock.patch.object(release, "SITE_README", readme),
]
for patch in patches:
patch.start()
try:
release.update_site(
3,
digest,
80_000_000,
signature,
"Release title",
["Change one", "Change two"],
"2026-07-29T18:00:00+09:00",
)
release.update_site(
3,
digest,
80_000_000,
signature,
"Release title",
["Change one", "Change two"],
"2026-07-29T18:00:00+09:00",
)
finally:
for patch in reversed(patches):
patch.stop()
self.assertIn("0.1.0-alpha.3", download.read_text())
self.assertIn("80.0 MB", download.read_text())
self.assertEqual(changelog.read_text().count("0.1.0-ALPHA.3"), 1)
self.assertEqual(readme.read_text().count("## 0.1.0-alpha.3 release"), 1)
data = json.loads(manifest.read_text())
self.assertEqual(data["buildNumber"], 3)
self.assertEqual(data["sha256"], digest)
self.assertEqual(data["sizeBytes"], 80_000_000)
appcast_text = appcast.read_text()
self.assertIn("0.1.0-alpha.3", appcast_text)
self.assertIn('sparkle:version>3</sparkle:version', appcast_text)
self.assertIn('length="80000000"', appcast_text)
self.assertIn(signature, appcast_text)
def test_build_stable_does_not_block_for_a_running_existing_app(self):
with mock.patch.object(release, "run") as run:
app = release.build_stable()
self.assertEqual(app, release.STABLE_APP)
run.assert_called_once()
self.assertEqual(
run.call_args.args[0],
[release.ROOT / "Tools/build-mac-stable-release.sh"],
)
self.assertEqual(
run.call_args.kwargs["env"]["PROJECTQUINT_BUILD_PATH"],
str(release.STABLE_APP),
)
if __name__ == "__main__":
unittest.main()