프로젝트 소스 공개
This commit is contained in:
@@ -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.
|
||||
Executable
+96
@@ -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"
|
||||
Executable
+116
@@ -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"
|
||||
Executable
+5
@@ -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" "$@"
|
||||
@@ -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')
|
||||
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)
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user