Some checks failed
Windows project source guards / test (push) Failing after 26s
Оставляется то, на что ссылаются этот и три прежних выпуска; ядро — копия из Zapret 2 GUI (без лишних заплаток zstd, publish.prune). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1383 lines
57 KiB
Python
1383 lines
57 KiB
Python
#!/usr/bin/env python3
|
||
"""One-command Zapret KVN Windows stable release orchestrator."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import base64
|
||
import hashlib
|
||
import http.client
|
||
import json
|
||
import mimetypes
|
||
import os
|
||
import re
|
||
import shutil
|
||
import stat
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
import uuid
|
||
import zipfile
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Any, Iterable
|
||
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
CONSTANTS_PATH = ROOT / "xray_fluent" / "constants.py"
|
||
GEOIP_LOCK_PATH = ROOT / "scripts" / "geoip-lock.json"
|
||
CORE_LOCK_PATH = ROOT / "scripts" / "core-lock.windows-x64.json"
|
||
CORE_RESOLVER_PATH = ROOT / "scripts" / "resolve_core_versions.py"
|
||
STATE_PATH = ROOT / ".git" / "zapret-kvn-release-state.json"
|
||
LAST_RESULT_PATH = ROOT / ".git" / "zapret-kvn-release-last.json"
|
||
# Versions whose release was cancelled before any tag or Release existed.
|
||
ABANDONED_PATH = ROOT / ".git" / "zapret-kvn-release-abandoned.json"
|
||
WINDOWS_HOST = os.getenv("ZAPRETKVN_WINDOWS_REMOTE_HOST", "win10")
|
||
WINDOWS_ROOT = os.getenv(
|
||
"ZAPRETKVN_WINDOWS_RELEASE_ROOT",
|
||
r"C:\Users\privacy\ZapretKVN-local-release",
|
||
)
|
||
WINDOWS_SCP_ROOT = WINDOWS_ROOT.replace("\\", "/")
|
||
RELEASE_ROOT = Path(
|
||
os.getenv("ZAPRETKVN_RELEASE_ROOT", "/home/codex-pve/releases/zapret-kvn")
|
||
)
|
||
# Published assets are immutable in Forgejo, so the local archive only needs
|
||
# the newest bundles for quick re-checks; older ones are pruned after success.
|
||
RELEASE_ARCHIVE_KEEP = max(1, int(os.getenv("ZAPRETKVN_RELEASE_KEEP", "3")))
|
||
FORGEJO_BASE = os.getenv("FORGEJO_URL", "https://git.zapret.moe").rstrip("/")
|
||
FORGEJO_REPO = os.getenv("ZAPRETKVN_FORGEJO_REPO", "zapretkvn/zapret-kvn")
|
||
FORGEJO_TOKEN_PATH = Path(
|
||
os.getenv(
|
||
"ZAPRETKVN_FORGEJO_TOKEN",
|
||
"/home/codex-pve/.config/forgejo/zapret-kvn-release-token",
|
||
)
|
||
)
|
||
PUBLISHER_PYTHON = Path("/home/codex-pve/zapretgpt/.venv/bin/python")
|
||
PUBLISHER_SYNC = Path("/home/codex-pve/zapretgpt/zapretkvn_publisher.py")
|
||
PUBLISHER_COMMAND = Path(
|
||
"/home/codex-pve/zapretgpt/scripts/publish_zapretkvn_stable.py"
|
||
)
|
||
PUBLISHER_STATE = Path(
|
||
"/home/codex-pve/zapretgpt/data/zapretkvn_publisher_state.json"
|
||
)
|
||
# The publisher only ever syncs the newest installer into this cache, so older
|
||
# version directories are safe to prune (same env name as the publisher).
|
||
PUBLISHER_WINDOWS_CACHE = Path(
|
||
os.getenv(
|
||
"ZAPRETKVN_WINDOWS_CACHE_ROOT",
|
||
"/home/codex-pve/zapretkvn-release-work/windows",
|
||
)
|
||
)
|
||
EXPECTED_ASSET_NAMES = (
|
||
"ZapretKVN-v{version}-windows-x64.exe",
|
||
"ZapretKVN-v{version}-windows-x64.zip",
|
||
"ZapretKVN-v{version}-windows-x64.zip.sha256",
|
||
"ZapretKVN-v{version}-windows-x64.7z",
|
||
"ZapretKVN-cores-v{version}-windows-x64.7z",
|
||
)
|
||
# Подписанный список выпуска (общее ядро обновления zapret_update): программа
|
||
# ставит архив, только если его сумма есть в списке с верной подписью.
|
||
SIGNING_KEY_PATH = Path(
|
||
os.getenv(
|
||
"ZAPRETKVN_UPDATE_SIGNING_KEY",
|
||
"/home/codex-pve/.config/zapret-signing/kvn-2026a.key",
|
||
)
|
||
)
|
||
SIGNED_ASSET_NAMES = ("update-manifest.json", "update-manifest.json.sig")
|
||
# Хранилище выпуска (общее ядро, как у Zapret 2 GUI): каждый файл сборки по
|
||
# SHA-256 и заплатки от трёх прежних выпусков. Программа качает из него только
|
||
# изменившееся. Сжатие zstd есть только в Python 3.14 — сборщик хранилища
|
||
# запускается им отдельным процессом.
|
||
STORE_PYTHON = Path(
|
||
os.getenv(
|
||
"ZAPRET_UPDATE_STORE_PYTHON",
|
||
"/home/codex-pve/.local/share/uv/python/cpython-3.14.6-linux-x86_64-gnu/bin/python3.14",
|
||
)
|
||
)
|
||
STORE_CACHE = RELEASE_ROOT / "update-store"
|
||
STORE_TOKEN_PATH = Path(
|
||
os.getenv("ZAPRET_UPDATE_STORE_TOKEN", "/home/codex-pve/.config/forgejo/zapret-update-store-token")
|
||
)
|
||
STORE_OWNER = "zapretkvn"
|
||
STORE_PRODUCT = "zapret-kvn"
|
||
STORE_NAMES_FILE = "store-names.json"
|
||
PHASES = (
|
||
"source_prepared",
|
||
"dev_verified",
|
||
"stable_verified",
|
||
"assets_verified",
|
||
"tag_pushed",
|
||
"draft_created",
|
||
"assets_uploaded",
|
||
"release_published",
|
||
"telegram_published",
|
||
"complete",
|
||
)
|
||
|
||
|
||
class ReleaseError(RuntimeError):
|
||
pass
|
||
|
||
|
||
def log(message: str) -> None:
|
||
print(f"[release] {message}", flush=True)
|
||
|
||
|
||
def run(
|
||
args: Iterable[str | os.PathLike[str]],
|
||
*,
|
||
cwd: Path = ROOT,
|
||
capture: bool = False,
|
||
timeout: int | None = None,
|
||
) -> subprocess.CompletedProcess[str]:
|
||
command = [str(value) for value in args]
|
||
log("+ " + " ".join(command))
|
||
return subprocess.run(
|
||
command,
|
||
cwd=cwd,
|
||
check=True,
|
||
text=True,
|
||
capture_output=capture,
|
||
timeout=timeout,
|
||
)
|
||
|
||
|
||
def output(args: Iterable[str | os.PathLike[str]], *, cwd: Path = ROOT) -> str:
|
||
return run(args, cwd=cwd, capture=True).stdout.strip()
|
||
|
||
|
||
def sha256(path: Path) -> str:
|
||
digest = hashlib.sha256()
|
||
with path.open("rb") as handle:
|
||
while chunk := handle.read(1024 * 1024):
|
||
digest.update(chunk)
|
||
return digest.hexdigest()
|
||
|
||
|
||
def atomic_json(path: Path, payload: dict[str, Any]) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||
temporary.write_text(
|
||
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||
encoding="utf-8",
|
||
)
|
||
os.replace(temporary, path)
|
||
|
||
|
||
def read_json(path: Path) -> dict[str, Any]:
|
||
return json.loads(path.read_text(encoding="utf-8-sig"))
|
||
|
||
|
||
def parse_version(value: str) -> tuple[int, int, int]:
|
||
match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", value.strip())
|
||
if match is None:
|
||
raise ReleaseError(f"invalid stable version: {value}")
|
||
return tuple(int(part) for part in match.groups()) # type: ignore[return-value]
|
||
|
||
|
||
def version_text(parts: tuple[int, int, int]) -> str:
|
||
return ".".join(str(part) for part in parts)
|
||
|
||
|
||
def prune_release_archive(
|
||
current: str, root: Path = RELEASE_ROOT, keep: int = RELEASE_ARCHIVE_KEEP
|
||
) -> list[str]:
|
||
versions = []
|
||
for entry in root.iterdir() if root.is_dir() else ():
|
||
match = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)", entry.name)
|
||
if match and entry.is_dir() and not entry.is_symlink():
|
||
versions.append((tuple(int(part) for part in match.groups()), entry))
|
||
versions.sort(reverse=True)
|
||
removed = []
|
||
for _, entry in versions[keep:]:
|
||
if entry.name == f"v{current}":
|
||
continue
|
||
shutil.rmtree(entry)
|
||
removed.append(entry.name)
|
||
return removed
|
||
|
||
|
||
def remote_dist_asset_pattern() -> str:
|
||
# .NET allows the same named group in every alternative; only the exact
|
||
# release asset shapes match, never the ZapretKVN\ build folder or portable zip.
|
||
shapes = (
|
||
re.escape(name).replace(re.escape("{version}"), r"(?<v>\d+\.\d+\.\d+)")
|
||
for name in EXPECTED_ASSET_NAMES
|
||
)
|
||
return "^(?:" + "|".join(shapes) + ")$"
|
||
|
||
|
||
def prune_remote_dist(current: str, keep: int = RELEASE_ARCHIVE_KEEP) -> None:
|
||
command = (
|
||
"$ErrorActionPreference='Stop';"
|
||
f"$pattern='{remote_dist_asset_pattern()}';"
|
||
f"$files=Get-ChildItem -LiteralPath '{WINDOWS_ROOT}\\dist' -File | ForEach-Object {{"
|
||
" if ($_.Name -match $pattern) { [pscustomobject]@{File=$_;Version=[version]$Matches['v']} } };"
|
||
"$keep=@($files | ForEach-Object { $_.Version } | Sort-Object -Descending -Unique"
|
||
f" | Select-Object -First {keep}) + [version]'{current}';"
|
||
"$files | Where-Object { $keep -notcontains $_.Version } | ForEach-Object {"
|
||
" Remove-Item -LiteralPath $_.File.FullName; Write-Output ('pruned ' + $_.File.Name) }"
|
||
)
|
||
encoded = base64.b64encode(command.encode("utf-16-le")).decode("ascii")
|
||
run(
|
||
[
|
||
"ssh",
|
||
WINDOWS_HOST,
|
||
"powershell",
|
||
"-NoProfile",
|
||
"-NonInteractive",
|
||
"-ExecutionPolicy",
|
||
"Bypass",
|
||
"-EncodedCommand",
|
||
encoded,
|
||
],
|
||
timeout=300,
|
||
)
|
||
|
||
|
||
def prune_release_archives(version: str) -> None:
|
||
# Best effort: the release is already complete, so cleanup must never fail it.
|
||
for root in (RELEASE_ROOT, PUBLISHER_WINDOWS_CACHE):
|
||
try:
|
||
removed = prune_release_archive(version, root)
|
||
except OSError as exc:
|
||
log(f"warning: pruning {root} failed: {exc}")
|
||
else:
|
||
if removed:
|
||
log(f"pruned {root}: {', '.join(removed)}")
|
||
try:
|
||
prune_remote_dist(version)
|
||
except (OSError, ReleaseError, subprocess.SubprocessError) as exc:
|
||
log(f"warning: pruning {WINDOWS_HOST} dist failed: {exc}")
|
||
|
||
|
||
def latest_stable_tag() -> str:
|
||
tags = output(["git", "tag", "--list", "v[0-9]*.[0-9]*.[0-9]*"]).splitlines()
|
||
parsed: list[tuple[tuple[int, int, int], str]] = []
|
||
for tag in tags:
|
||
if not tag.strip():
|
||
continue
|
||
try:
|
||
parsed.append((parse_version(tag), tag))
|
||
except ReleaseError:
|
||
# The Git glob also matches prerelease suffixes. Stable publication
|
||
# must derive its next version from exact x.y.z tags only.
|
||
continue
|
||
if not parsed:
|
||
raise ReleaseError("no stable Git tag found")
|
||
return max(parsed)[1]
|
||
|
||
|
||
def next_patch(tag: str) -> str:
|
||
major, minor, patch = parse_version(tag)
|
||
return version_text((major, minor, patch + 1))
|
||
|
||
|
||
def next_minor(tag: str) -> str:
|
||
major, minor, _patch = parse_version(tag)
|
||
return version_text((major, minor + 1, 0))
|
||
|
||
|
||
def validate_next_stable_version(latest_tag: str, requested: str | None) -> str:
|
||
patch_version = next_patch(latest_tag)
|
||
version = requested or patch_version
|
||
allowed = {patch_version, next_minor(latest_tag)}
|
||
if version not in allowed:
|
||
raise ReleaseError(
|
||
"next stable must be the next patch "
|
||
f"({patch_version}) or explicit next minor ({next_minor(latest_tag)}), not {version}"
|
||
)
|
||
return version
|
||
|
||
|
||
def abandoned_versions() -> dict[str, Any]:
|
||
if not ABANDONED_PATH.is_file():
|
||
return {}
|
||
return dict(read_json(ABANDONED_PATH).get("versions", {}))
|
||
|
||
|
||
def release_base(latest_tag: str) -> str:
|
||
"""Version the next stable follows: the latest tag or a cancelled unpublished one.
|
||
|
||
A cancelled release already pushed its ``release: prepare`` commit, so
|
||
APP_VERSION is ahead of the latest tag. The base walks from the latest tag
|
||
through consecutive versions cancelled with ``--abandon`` and must end at
|
||
APP_VERSION; nothing else may be skipped.
|
||
"""
|
||
abandoned = abandoned_versions()
|
||
base = latest_tag
|
||
while True:
|
||
following = [v for v in (next_patch(base), next_minor(base)) if v in abandoned]
|
||
if not following:
|
||
break
|
||
base = f"v{following[0]}"
|
||
if current_app_version() != base.removeprefix("v"):
|
||
raise ReleaseError(
|
||
"APP_VERSION must match the latest stable before a fresh release"
|
||
" (or a release cancelled with --abandon)"
|
||
)
|
||
return base
|
||
|
||
|
||
def current_app_version() -> str:
|
||
source = CONSTANTS_PATH.read_text(encoding="utf-8")
|
||
match = re.search(r'^APP_VERSION\s*=\s*"(\d+\.\d+\.\d+)"\s*$', source, re.M)
|
||
if match is None:
|
||
raise ReleaseError("APP_VERSION assignment not found")
|
||
return match.group(1)
|
||
|
||
|
||
def set_app_version(version: str) -> None:
|
||
source = CONSTANTS_PATH.read_text(encoding="utf-8")
|
||
updated, count = re.subn(
|
||
r'^APP_VERSION\s*=\s*"\d+\.\d+\.\d+"\s*$',
|
||
f'APP_VERSION = "{version}"',
|
||
source,
|
||
count=1,
|
||
flags=re.M,
|
||
)
|
||
if count != 1:
|
||
raise ReleaseError("could not update APP_VERSION exactly once")
|
||
CONSTANTS_PATH.write_text(updated, encoding="utf-8")
|
||
|
||
|
||
def normalize_changes(values: list[str], *, allow_empty: bool = False) -> list[str]:
|
||
changes: list[str] = []
|
||
for value in values:
|
||
for item in value.split(";"):
|
||
normalized = " ".join(item.split()).strip(" -•\t")
|
||
if normalized:
|
||
changes.append(normalized)
|
||
if not changes and allow_empty:
|
||
return []
|
||
if not 1 <= len(changes) <= 6:
|
||
raise ReleaseError("changelog must contain 1 to 6 items")
|
||
if any(len(item) > 180 for item in changes):
|
||
raise ReleaseError("each changelog item must be at most 180 characters")
|
||
if len("; ".join(changes)) > 700:
|
||
raise ReleaseError("changelog must be at most 700 characters")
|
||
return changes
|
||
|
||
|
||
def phase_index(state: dict[str, Any]) -> int:
|
||
phase = state.get("phase", "")
|
||
return PHASES.index(phase) if phase in PHASES else -1
|
||
|
||
|
||
def phase_done(state: dict[str, Any], phase: str) -> bool:
|
||
return phase_index(state) >= PHASES.index(phase)
|
||
|
||
|
||
def mark_phase(state: dict[str, Any], phase: str, **updates: Any) -> None:
|
||
if phase not in PHASES:
|
||
raise ReleaseError(f"unknown release phase: {phase}")
|
||
state.update(updates)
|
||
state["phase"] = phase
|
||
atomic_json(STATE_PATH, state)
|
||
log(f"phase complete: {phase}")
|
||
|
||
|
||
def require_clean_main() -> None:
|
||
if output(["git", "branch", "--show-current"]) != "main":
|
||
raise ReleaseError("stable release must run on main")
|
||
status = output(["git", "status", "--porcelain=v1", "--untracked-files=all"])
|
||
if status:
|
||
raise ReleaseError("working tree must be clean before starting the release:\n" + status)
|
||
|
||
|
||
def refresh_stable_core_lock(*, write: bool) -> None:
|
||
"""Resolve exact Windows cores and routing data before source is pinned.
|
||
|
||
The online resolver is the only moving boundary. Once it has verified the
|
||
core bytes, captured one immutable runetfreedom snapshot, and atomically
|
||
updated the lock, every Windows build remains fully lock-driven and
|
||
reproducible from the release commit.
|
||
"""
|
||
|
||
if not CORE_RESOLVER_PATH.is_file():
|
||
raise ReleaseError(f"core resolver is missing: {CORE_RESOLVER_PATH}")
|
||
command = [
|
||
sys.executable,
|
||
str(CORE_RESOLVER_PATH.relative_to(ROOT)),
|
||
"--write" if write else "--check",
|
||
]
|
||
if not write:
|
||
command.append("--require-current")
|
||
else:
|
||
command.append("--update-runtime")
|
||
run(command)
|
||
|
||
|
||
def refresh_stable_geoip_lock(*, write: bool) -> None:
|
||
python = sys.executable
|
||
if write:
|
||
import importlib.util
|
||
if importlib.util.find_spec("maxminddb") is None:
|
||
environment = ROOT / ".cache" / "geoip-tools"
|
||
python_path = environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
|
||
if not python_path.exists():
|
||
run([sys.executable, "-m", "venv", str(environment)])
|
||
run([str(python_path), "-m", "pip", "install", "maxminddb==3.1.1"])
|
||
python = str(python_path)
|
||
run([python, "scripts/prepare_geoip.py", "--refresh" if write else "--check"])
|
||
|
||
|
||
def prepare_source(version: str) -> str:
|
||
require_clean_main()
|
||
run(["git", "fetch", "origin", "main", "--tags"])
|
||
local_head = output(["git", "rev-parse", "HEAD"])
|
||
origin_main = output(["git", "rev-parse", "origin/main"])
|
||
ancestor = subprocess.run(
|
||
["git", "merge-base", "--is-ancestor", origin_main, local_head],
|
||
cwd=ROOT,
|
||
)
|
||
if ancestor.returncode != 0:
|
||
raise ReleaseError("local main does not contain origin/main; reconcile it first")
|
||
previous_geoip = GEOIP_LOCK_PATH.read_bytes()
|
||
refresh_stable_geoip_lock(write=True)
|
||
try:
|
||
freeze_path = ROOT / "core-release-freeze.json"
|
||
freeze = json.loads(freeze_path.read_text(encoding="utf-8")) if freeze_path.exists() else None
|
||
if freeze is not None and freeze.get("releases", {}).get("windows") == version:
|
||
if __package__:
|
||
from .check_core_release_freeze import verify
|
||
else:
|
||
from check_core_release_freeze import verify
|
||
verify(ROOT, "windows", version)
|
||
log("using the coordinated core freeze; no second upstream version selection")
|
||
else:
|
||
refresh_stable_core_lock(write=True)
|
||
except Exception:
|
||
GEOIP_LOCK_PATH.write_bytes(previous_geoip)
|
||
raise
|
||
set_app_version(version)
|
||
run(["git", "diff", "--check"])
|
||
run([sys.executable, "-m", "compileall", "-q", "xray_fluent", "tests"])
|
||
release_paths = [
|
||
str(CONSTANTS_PATH.relative_to(ROOT)),
|
||
str(CORE_LOCK_PATH.relative_to(ROOT)),
|
||
str(GEOIP_LOCK_PATH.relative_to(ROOT)),
|
||
"runtime/amnezia/go.mod",
|
||
"runtime/amnezia/go.sum",
|
||
]
|
||
run(["git", "add", "--", *release_paths])
|
||
staged = output(["git", "diff", "--cached", "--name-only"]).splitlines()
|
||
allowed = set(release_paths)
|
||
if str(CONSTANTS_PATH.relative_to(ROOT)) not in staged or not set(staged).issubset(allowed):
|
||
raise ReleaseError(f"unexpected staged release files: {staged}")
|
||
run(["git", "commit", "-m", f"release: prepare v{version}"])
|
||
run(["git", "push", "origin", "main"])
|
||
commit = output(["git", "rev-parse", "HEAD"])
|
||
if output(["git", "rev-parse", "origin/main"]) != commit:
|
||
raise ReleaseError("origin/main did not advance to the release commit")
|
||
return commit
|
||
|
||
|
||
def powershell_bootstrap(mode: str, commit: str, version: str) -> None:
|
||
release_dir = rf"{WINDOWS_ROOT}\.cache\release\v{version}"
|
||
manifest = rf"{release_dir}\{mode}-manifest.json"
|
||
# The gate runs as a child process with process-level output redirection
|
||
# to remote log files, keeping the ssh channel down to bootstrap lines
|
||
# plus a log tail. Two failure classes forced this shape: PowerShell 5.1
|
||
# over non-tty ssh wraps host output in CLIXML records and a long verbose
|
||
# stream (pip + unittest -v + PyInstaller) deadlocked that channel
|
||
# mid-gate; and PowerShell-level stream redirection (*>) converts native
|
||
# stderr (e.g. "git: warning: redirecting") into terminating
|
||
# NativeCommandError under the gate's own ErrorActionPreference=Stop.
|
||
# Start-Process redirection is invisible to the child's PowerShell, so
|
||
# native stderr lands in the file without conversion.
|
||
log_file = rf"{release_dir}\{mode}-gate.log"
|
||
err_file = rf"{release_dir}\{mode}-gate.err.log"
|
||
gate_args = (
|
||
"'-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass',"
|
||
"'-File','.\\scripts\\release_windows_gate.ps1',"
|
||
f"'-Mode','{mode}','-Commit','{commit}','-Version','{version}',"
|
||
f"'-RepoRoot','{WINDOWS_ROOT}','-ManifestPath','{manifest}'"
|
||
)
|
||
command = (
|
||
"$ErrorActionPreference='Stop';"
|
||
f"$root='{WINDOWS_ROOT}';"
|
||
"Set-Location -LiteralPath $root;"
|
||
"git fetch origin main --tags;"
|
||
f"git switch --detach {commit};"
|
||
"if ($LASTEXITCODE -ne 0) { throw 'git switch failed' };"
|
||
f"New-Item -ItemType Directory -Force -Path '{release_dir}' | Out-Null;"
|
||
f"$gate = Start-Process -FilePath 'powershell' -ArgumentList @({gate_args}) "
|
||
"-WorkingDirectory $root -NoNewWindow -Wait -PassThru "
|
||
f"-RedirectStandardOutput '{log_file}' -RedirectStandardError '{err_file}';"
|
||
"if ($gate.ExitCode -ne 0) { "
|
||
"Write-Output ('GATE-FAILED exit=' + $gate.ExitCode);"
|
||
f"Get-Content -LiteralPath '{log_file}' -Tail 40;"
|
||
f"if (Test-Path -LiteralPath '{err_file}') "
|
||
f"{{ Get-Content -LiteralPath '{err_file}' -Tail 40 }};"
|
||
"exit 1 };"
|
||
"Write-Output 'GATE-DONE';"
|
||
f"Get-Content -LiteralPath '{log_file}' -Tail 3"
|
||
)
|
||
# Windows OpenSSH runs the space-joined argv through cmd.exe, which splits
|
||
# an unquoted payload at '&' — the gate invocation after 'git switch' never
|
||
# reached PowerShell. -EncodedCommand (base64 of UTF-16LE) is immune to
|
||
# remote-shell quoting entirely.
|
||
encoded = base64.b64encode(command.encode("utf-16-le")).decode("ascii")
|
||
run(
|
||
[
|
||
"ssh",
|
||
WINDOWS_HOST,
|
||
"powershell",
|
||
"-NoProfile",
|
||
"-NonInteractive",
|
||
"-ExecutionPolicy",
|
||
"Bypass",
|
||
"-EncodedCommand",
|
||
encoded,
|
||
],
|
||
timeout=3600,
|
||
)
|
||
|
||
|
||
def remote_release_path(version: str, name: str) -> str:
|
||
return f"{WINDOWS_HOST}:{WINDOWS_SCP_ROOT}/dist/{name}"
|
||
|
||
|
||
def copy_manifest(mode: str, version: str, destination: Path) -> dict[str, Any]:
|
||
remote = (
|
||
f"{WINDOWS_HOST}:{WINDOWS_SCP_ROOT}/.cache/release/"
|
||
f"v{version}/{mode}-manifest.json"
|
||
)
|
||
run(["scp", "-q", remote, destination], timeout=120)
|
||
return read_json(destination)
|
||
|
||
|
||
def verify_gate_manifest(
|
||
manifest: dict[str, Any], mode: str, version: str, commit: str
|
||
) -> None:
|
||
expected = {"schema": 1, "mode": mode, "version": version, "commit": commit}
|
||
for key, value in expected.items():
|
||
if manifest.get(key) != value:
|
||
raise ReleaseError(f"{mode} manifest mismatch for {key}")
|
||
if int(manifest.get("templates_verified") or 0) <= 0:
|
||
raise ReleaseError(f"{mode} manifest did not verify templates")
|
||
geoip = manifest.get("geoip") or {}
|
||
expected_geoip = read_json(GEOIP_LOCK_PATH)
|
||
if any(geoip.get(key) != expected_geoip.get(key) for key in ("version", "sha256", "size")):
|
||
raise ReleaseError(f"{mode} manifest GeoIP does not match release lock")
|
||
core = manifest.get("core") or {}
|
||
for digest_key in ("lock_sha256", "manifest_sha256"):
|
||
if not re.fullmatch(r"[0-9a-f]{64}", str(core.get(digest_key) or "")):
|
||
raise ReleaseError(f"{mode} manifest contains an invalid core {digest_key}")
|
||
core_sources = core.get("sources") or []
|
||
if {str(item.get("id")) for item in core_sources} != {
|
||
"xray-core",
|
||
"sing-box-extended",
|
||
"hysteria",
|
||
"amnezia",
|
||
}:
|
||
raise ReleaseError(f"{mode} manifest core source set mismatch")
|
||
for source in core_sources:
|
||
approved_amnezia = source.get("id") == "amnezia" and source.get("repository") == "amnezia-vpn/amneziawg-go" and source.get("channel") == "official-tags"
|
||
if source.get("id") == "amnezia" and not approved_amnezia:
|
||
raise ReleaseError(f"{mode} manifest contains an unapproved Amnezia source")
|
||
if not approved_amnezia and (source.get("channel") != "stable" or source.get("release_prerelease") is not False):
|
||
raise ReleaseError(f"{mode} manifest contains a non-stable core source")
|
||
if not re.fullmatch(r"[0-9a-f]{64}", str(source.get("archive_sha256") or "")):
|
||
raise ReleaseError(f"{mode} manifest contains an invalid core archive digest")
|
||
if int(source.get("asset_size") or 0) <= 0:
|
||
raise ReleaseError(f"{mode} manifest contains an invalid core asset size")
|
||
executable = manifest.get("executable") or {}
|
||
if int(executable.get("size") or 0) <= 0 or not re.fullmatch(
|
||
r"[0-9a-f]{64}", str(executable.get("sha256") or "")
|
||
):
|
||
raise ReleaseError(f"{mode} manifest contains an invalid executable")
|
||
|
||
|
||
def collect_assets(version: str, commit: str) -> tuple[Path, dict[str, Any]]:
|
||
destination = RELEASE_ROOT / f"v{version}"
|
||
destination.mkdir(parents=True, exist_ok=True)
|
||
manifest_path = destination / "stable-manifest.json"
|
||
manifest = copy_manifest("stable", version, manifest_path)
|
||
verify_gate_manifest(manifest, "stable", version, commit)
|
||
assets = manifest.get("assets") or []
|
||
expected_names = {name.format(version=version) for name in EXPECTED_ASSET_NAMES}
|
||
if {str(item.get("name")) for item in assets} != expected_names:
|
||
raise ReleaseError("stable manifest asset set mismatch")
|
||
for item in assets:
|
||
name = str(item["name"])
|
||
local = destination / name
|
||
run(["scp", "-q", remote_release_path(version, name), local], timeout=1200)
|
||
if local.stat().st_size != int(item["size"]) or sha256(local) != item["sha256"]:
|
||
raise ReleaseError(f"copied asset mismatch: {name}")
|
||
checksum_name = f"ZapretKVN-v{version}-windows-x64.zip.sha256"
|
||
zip_name = f"ZapretKVN-v{version}-windows-x64.zip"
|
||
expected_zip = (destination / checksum_name).read_text(encoding="ascii").strip()
|
||
if not re.fullmatch(r"[0-9a-f]{64}", expected_zip):
|
||
raise ReleaseError("ZIP checksum file has an invalid format")
|
||
if sha256(destination / zip_name) != expected_zip:
|
||
raise ReleaseError("ZIP checksum mismatch after Windows transfer")
|
||
sign_release(destination, version)
|
||
return destination, manifest
|
||
|
||
|
||
def _signed_release_module():
|
||
"""Ключи, которые знает программа этого коммита (xray_fluent/updates/signed_release.py)."""
|
||
import importlib.util
|
||
|
||
if str(ROOT) not in sys.path:
|
||
sys.path.insert(0, str(ROOT))
|
||
spec = importlib.util.spec_from_file_location(
|
||
"kvn_signed_release", ROOT / "xray_fluent" / "updates" / "signed_release.py"
|
||
)
|
||
module = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(module)
|
||
return module
|
||
|
||
|
||
def sign_release(directory: Path, version: str) -> None:
|
||
"""Подписать архив выпуска и проверить подпись ключами программы."""
|
||
if not SIGNING_KEY_PATH.is_file():
|
||
raise ReleaseError(f"signing key is missing: {SIGNING_KEY_PATH}")
|
||
mode = stat.S_IMODE(SIGNING_KEY_PATH.stat().st_mode)
|
||
if mode != 0o600:
|
||
raise ReleaseError(f"signing key mode must be 0600, got {mode:o}")
|
||
keys = _signed_release_module()
|
||
from zapret_update import manifest as update_manifest
|
||
from zapret_update import sign as update_sign
|
||
|
||
installer = directory / f"ZapretKVN-v{version}-windows-x64.zip"
|
||
# Список файлов сборки (путь, размер, SHA-256) собирается из того же zip,
|
||
# который подписывается: по нему программа докачивает только изменившиеся
|
||
# файлы (частичное обновление) и проверяет каждый файл подписанным хешем.
|
||
with tempfile.TemporaryDirectory(prefix="zapretkvn-sign-tree-") as scratch:
|
||
tree = Path(scratch) / "tree"
|
||
try:
|
||
with zipfile.ZipFile(installer) as bundle:
|
||
bundle.extractall(tree)
|
||
except (zipfile.BadZipFile, OSError) as exc:
|
||
raise ReleaseError(f"release ZIP cannot be unpacked for the signed file list: {exc}") from exc
|
||
built = build_store(tree, directory, version, keys)
|
||
raw_path, signature_path = update_sign.make(
|
||
key_path=SIGNING_KEY_PATH,
|
||
product=keys.PRODUCT,
|
||
channel=keys.CHANNEL,
|
||
version=version,
|
||
installer=installer,
|
||
out_dir=directory,
|
||
files=built["files"],
|
||
patches=built["patches"],
|
||
)
|
||
try:
|
||
loaded = update_manifest.load_signed(
|
||
raw_path.read_bytes(),
|
||
signature_path.read_text(encoding="ascii"),
|
||
trusted=keys.TRUSTED_KEYS,
|
||
product=keys.PRODUCT,
|
||
channel=keys.CHANNEL,
|
||
)
|
||
except update_manifest.ManifestError as exc:
|
||
raw_path.unlink(missing_ok=True)
|
||
signature_path.unlink(missing_ok=True)
|
||
raise ReleaseError(f"release signature is not accepted by the app: {exc}") from exc
|
||
log(f"signed release list with key {loaded.key}")
|
||
|
||
|
||
def previous_manifests(keys, version: str, directory: Path, *, limit: int = 3, strict: bool = False) -> list[Path]:
|
||
"""Подписанные списки прежних выпусков с хранилищем — основа для заплаток.
|
||
|
||
``strict`` — список выпусков не получен → ошибка, а не пустой ответ
|
||
(уборке хранилища нельзя путать «выпусков нет» со «сервер не ответил»).
|
||
"""
|
||
from zapret_update import manifest as update_manifest
|
||
|
||
found: list[Path] = []
|
||
try:
|
||
with urllib.request.urlopen(f"{FORGEJO_BASE}/api/v1/repos/{FORGEJO_REPO}/releases?limit=20", timeout=60) as response:
|
||
releases = json.loads(response.read())
|
||
except (OSError, ValueError) as exc:
|
||
if strict:
|
||
raise ReleaseError(f"previous releases are unavailable: {exc}") from exc
|
||
log(f"previous releases are unavailable, no patches this time: {exc}")
|
||
return found
|
||
for release in releases if isinstance(releases, list) else ():
|
||
if release.get("draft") or release.get("prerelease"):
|
||
continue
|
||
tag = str(release.get("tag_name") or "").removeprefix("v")
|
||
try:
|
||
if update_manifest.compare_versions(tag, version) >= 0:
|
||
continue
|
||
except update_manifest.ManifestError:
|
||
continue
|
||
assets = {str(item.get("name")): str(item.get("browser_download_url") or "") for item in release.get("assets") or ()}
|
||
if not all(name in assets for name in SIGNED_ASSET_NAMES):
|
||
continue
|
||
try:
|
||
with urllib.request.urlopen(assets[SIGNED_ASSET_NAMES[0]], timeout=60) as response:
|
||
raw = response.read(update_manifest.MAX_MANIFEST_BYTES + 1)
|
||
with urllib.request.urlopen(assets[SIGNED_ASSET_NAMES[1]], timeout=60) as response:
|
||
signature = response.read(1024)
|
||
loaded = update_manifest.load_signed(
|
||
raw, signature, trusted=keys.TRUSTED_KEYS, product=keys.PRODUCT, channel=keys.CHANNEL
|
||
)
|
||
except (OSError, ValueError) as exc:
|
||
log(f"release list v{tag} is not accepted: {exc}")
|
||
continue
|
||
if loaded.files and all(entry.zsize for entry in loaded.files):
|
||
target = directory / f"previous-{tag}.json"
|
||
target.write_bytes(raw)
|
||
found.append(target)
|
||
if len(found) >= limit:
|
||
break
|
||
return found
|
||
|
||
|
||
def build_store(tree: Path, directory: Path, version: str, keys) -> dict[str, Any]:
|
||
"""Собрать хранилище выпуска общим ядром (``python3.14 -m zapret_update.builder``)."""
|
||
if not STORE_PYTHON.is_file():
|
||
raise ReleaseError(f"Python 3.14 for the update store is missing: {STORE_PYTHON}")
|
||
out = directory / "store-build.json"
|
||
previous = previous_manifests(keys, version, directory)
|
||
template = f"{FORGEJO_BASE}/api/packages/{STORE_OWNER}/generic/{STORE_PRODUCT}-store/1/{{name}}"
|
||
command: list[str | os.PathLike[str]] = [
|
||
STORE_PYTHON, "-m", "zapret_update.builder", "--tree", tree, "--cache", STORE_CACHE,
|
||
"--store-url", template, "--out", out,
|
||
]
|
||
for path in previous:
|
||
command += ["--previous", path]
|
||
run(command, timeout=3600)
|
||
built = read_json(out)
|
||
(directory / STORE_NAMES_FILE).write_text(json.dumps(built["names"]), encoding="utf-8")
|
||
log(f"update store: {len(built['files'])} files, {len(built['patches'])} patches from {len(previous)} releases")
|
||
return built
|
||
|
||
|
||
def upload_store(directory: Path) -> None:
|
||
"""Залить хранилище выпуска в реестр пакетов Forgejo (общее ядро)."""
|
||
from zapret_update import publish
|
||
|
||
if not STORE_TOKEN_PATH.is_file():
|
||
raise ReleaseError(f"update store token is missing: {STORE_TOKEN_PATH}")
|
||
mode = stat.S_IMODE(STORE_TOKEN_PATH.stat().st_mode)
|
||
if mode != 0o600:
|
||
raise ReleaseError(f"update store token mode must be 0600, got {mode:o}")
|
||
names = json.loads((directory / STORE_NAMES_FILE).read_text(encoding="utf-8"))
|
||
publish.upload(
|
||
names,
|
||
cache_dir=STORE_CACHE,
|
||
owner=STORE_OWNER,
|
||
product=STORE_PRODUCT,
|
||
token=STORE_TOKEN_PATH.read_text(encoding="utf-8").strip(),
|
||
origin=FORGEJO_BASE,
|
||
log=log,
|
||
)
|
||
|
||
|
||
# Сколько прежних выпусков держать в хранилище Forgejo (их файлы и заплатки).
|
||
KEEP_RELEASES = 3
|
||
|
||
|
||
def prune_store(directory: Path, version: str) -> int:
|
||
"""Убрать из реестра Forgejo файлы, не нужные ни этому, ни прежним выпускам (общее ядро)."""
|
||
from zapret_update import manifest as update_manifest
|
||
from zapret_update import publish
|
||
|
||
keys = _signed_release_module()
|
||
paths = [directory / SIGNED_ASSET_NAMES[0], *previous_manifests(keys, version, directory, limit=KEEP_RELEASES, strict=True)]
|
||
keep = [update_manifest.parse(json.loads(path.read_text(encoding="utf-8"))) for path in paths]
|
||
return publish.prune(
|
||
keep,
|
||
owner=STORE_OWNER,
|
||
product=STORE_PRODUCT,
|
||
token=STORE_TOKEN_PATH.read_text(encoding="utf-8").strip(),
|
||
origin=FORGEJO_BASE,
|
||
log=log,
|
||
)
|
||
|
||
|
||
def signed_assets(directory: Path) -> list[dict[str, Any]]:
|
||
"""Список и подпись как файлы выпуска: имя, размер, SHA-256."""
|
||
assets = []
|
||
for name in SIGNED_ASSET_NAMES:
|
||
path = directory / name
|
||
if not path.is_file():
|
||
raise ReleaseError(f"signed release file is missing: {name}")
|
||
assets.append({"name": name, "size": path.stat().st_size, "sha256": sha256(path)})
|
||
return assets
|
||
|
||
|
||
@dataclass
|
||
class Forgejo:
|
||
token: str
|
||
|
||
@classmethod
|
||
def load(cls) -> "Forgejo":
|
||
mode = stat.S_IMODE(FORGEJO_TOKEN_PATH.stat().st_mode)
|
||
if mode != 0o600:
|
||
raise ReleaseError(f"Forgejo token mode must be 0600, got {mode:o}")
|
||
return cls(FORGEJO_TOKEN_PATH.read_text(encoding="utf-8").strip())
|
||
|
||
@property
|
||
def api(self) -> str:
|
||
return f"{FORGEJO_BASE}/api/v1/repos/{FORGEJO_REPO}"
|
||
|
||
def request(
|
||
self,
|
||
method: str,
|
||
url: str,
|
||
payload: dict[str, Any] | None = None,
|
||
*,
|
||
allow_404: bool = False,
|
||
) -> dict[str, Any] | None:
|
||
data = json.dumps(payload).encode() if payload is not None else None
|
||
request = urllib.request.Request(
|
||
url,
|
||
data=data,
|
||
headers={
|
||
"Authorization": f"token {self.token}",
|
||
"Content-Type": "application/json",
|
||
"User-Agent": "ZapretKVN-Local-Release/1",
|
||
},
|
||
method=method,
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=60) as response:
|
||
return json.load(response)
|
||
except urllib.error.HTTPError as exc:
|
||
body = exc.read().decode("utf-8", "replace")[:500]
|
||
if allow_404 and exc.code == 404:
|
||
return None
|
||
raise ReleaseError(f"Forgejo HTTP {exc.code}: {body}") from None
|
||
|
||
def release_by_tag(self, version: str) -> dict[str, Any] | None:
|
||
return self.request(
|
||
"GET", f"{self.api}/releases/tags/v{version}", allow_404=True
|
||
)
|
||
|
||
def create_draft(
|
||
self, version: str, commit: str, changes: list[str]
|
||
) -> dict[str, Any]:
|
||
result = self.request(
|
||
"POST",
|
||
f"{self.api}/releases",
|
||
{
|
||
"tag_name": f"v{version}",
|
||
"target_commitish": commit,
|
||
"name": f"Zapret KVN v{version}",
|
||
"body": "\n".join(f"- {item}" for item in changes),
|
||
"draft": True,
|
||
"prerelease": False,
|
||
},
|
||
)
|
||
assert result is not None
|
||
return result
|
||
|
||
def upload_asset(self, release_id: int, path: Path) -> dict[str, Any]:
|
||
parsed = urllib.parse.urlsplit(FORGEJO_BASE)
|
||
if parsed.scheme != "https":
|
||
raise ReleaseError("Forgejo upload requires HTTPS")
|
||
boundary = "----ZapretKVN" + uuid.uuid4().hex
|
||
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||
prefix = (
|
||
f"--{boundary}\r\n"
|
||
f'Content-Disposition: form-data; name="attachment"; filename="{path.name}"\r\n'
|
||
f"Content-Type: {mime}\r\n\r\n"
|
||
).encode()
|
||
suffix = f"\r\n--{boundary}--\r\n".encode()
|
||
endpoint = (
|
||
f"{parsed.path}/api/v1/repos/{FORGEJO_REPO}/releases/{release_id}/assets"
|
||
f"?name={urllib.parse.quote(path.name)}"
|
||
)
|
||
connection = http.client.HTTPSConnection(parsed.hostname, parsed.port, timeout=600)
|
||
connection.putrequest("POST", endpoint)
|
||
connection.putheader("Authorization", f"token {self.token}")
|
||
connection.putheader("Content-Type", f"multipart/form-data; boundary={boundary}")
|
||
connection.putheader(
|
||
"Content-Length", str(len(prefix) + path.stat().st_size + len(suffix))
|
||
)
|
||
connection.endheaders()
|
||
connection.send(prefix)
|
||
with path.open("rb") as handle:
|
||
while chunk := handle.read(1024 * 1024):
|
||
connection.send(chunk)
|
||
connection.send(suffix)
|
||
response = connection.getresponse()
|
||
body = response.read()
|
||
connection.close()
|
||
if response.status not in {200, 201}:
|
||
raise ReleaseError(
|
||
f"Forgejo asset upload failed for {path.name}: HTTP {response.status}"
|
||
)
|
||
return json.loads(body)
|
||
|
||
def download_hash(self, url: str) -> tuple[int, str]:
|
||
request = urllib.request.Request(
|
||
url,
|
||
headers={
|
||
"Authorization": f"token {self.token}",
|
||
"User-Agent": "ZapretKVN-Local-Release/1",
|
||
},
|
||
)
|
||
digest = hashlib.sha256()
|
||
size = 0
|
||
with urllib.request.urlopen(request, timeout=600) as response:
|
||
while chunk := response.read(1024 * 1024):
|
||
digest.update(chunk)
|
||
size += len(chunk)
|
||
return size, digest.hexdigest()
|
||
|
||
def publish(self, release_id: int) -> dict[str, Any]:
|
||
result = self.request(
|
||
"PATCH",
|
||
f"{self.api}/releases/{release_id}",
|
||
{"draft": False, "prerelease": False},
|
||
)
|
||
assert result is not None
|
||
return result
|
||
|
||
def latest(self) -> dict[str, Any]:
|
||
result = self.request("GET", f"{self.api}/releases/latest")
|
||
assert result is not None
|
||
return result
|
||
|
||
|
||
def ensure_tag(version: str, commit: str) -> None:
|
||
tag = f"v{version}"
|
||
existing = subprocess.run(
|
||
["git", "rev-parse", "-q", "--verify", f"refs/tags/{tag}^{{commit}}"],
|
||
cwd=ROOT,
|
||
text=True,
|
||
capture_output=True,
|
||
)
|
||
if existing.returncode == 0:
|
||
if existing.stdout.strip() != commit:
|
||
raise ReleaseError(f"existing {tag} points to another commit")
|
||
else:
|
||
run(["git", "tag", "-a", tag, commit, "-m", f"Zapret KVN {tag}"])
|
||
remote_lines = output(
|
||
[
|
||
"git",
|
||
"ls-remote",
|
||
"--tags",
|
||
"origin",
|
||
f"refs/tags/{tag}",
|
||
f"refs/tags/{tag}^{{}}",
|
||
]
|
||
).splitlines()
|
||
if not remote_lines:
|
||
run(["git", "push", "origin", tag])
|
||
return
|
||
peeled = [line.split()[0] for line in remote_lines if line.endswith("^{}")]
|
||
targets = peeled or [line.split()[0] for line in remote_lines]
|
||
if targets != [commit]:
|
||
raise ReleaseError(f"remote {tag} points to another commit")
|
||
|
||
|
||
def verify_and_upload_assets(
|
||
forgejo: Forgejo,
|
||
release: dict[str, Any],
|
||
directory: Path,
|
||
manifest: dict[str, Any],
|
||
) -> dict[str, Any]:
|
||
release_id = int(release["id"])
|
||
expected = {item["name"]: item for item in manifest["assets"]}
|
||
remote = {item["name"]: item for item in release.get("assets") or []}
|
||
extras = set(remote) - set(expected)
|
||
if extras:
|
||
raise ReleaseError(f"Forgejo draft contains unexpected assets: {sorted(extras)}")
|
||
for name, item in expected.items():
|
||
local = directory / name
|
||
asset = remote.get(name)
|
||
if asset is None:
|
||
log(f"uploading {name}")
|
||
asset = forgejo.upload_asset(release_id, local)
|
||
remote[name] = asset
|
||
if int(asset.get("size") or 0) != int(item["size"]):
|
||
raise ReleaseError(f"Forgejo size mismatch: {name}")
|
||
size, digest = forgejo.download_hash(asset["browser_download_url"])
|
||
if size != int(item["size"]) or digest != item["sha256"]:
|
||
raise ReleaseError(f"Forgejo content mismatch: {name}")
|
||
log(f"verified remote asset {name}")
|
||
refreshed = forgejo.release_by_tag(str(release["tag_name"]).removeprefix("v"))
|
||
if refreshed is None:
|
||
raise ReleaseError("Forgejo draft disappeared")
|
||
if {item["name"] for item in refreshed.get("assets") or []} != set(expected):
|
||
raise ReleaseError("Forgejo remote asset set mismatch")
|
||
return refreshed
|
||
|
||
|
||
def verify_published_release(
|
||
forgejo: Forgejo,
|
||
release: dict[str, Any],
|
||
version: str,
|
||
commit: str,
|
||
manifest: dict[str, Any],
|
||
) -> None:
|
||
expected_names = {item["name"] for item in manifest["assets"]}
|
||
if release.get("tag_name") != f"v{version}":
|
||
raise ReleaseError("published Forgejo tag mismatch")
|
||
if release.get("draft") or release.get("prerelease"):
|
||
raise ReleaseError("Forgejo release is not stable")
|
||
if release.get("target_commitish") != commit:
|
||
raise ReleaseError("Forgejo release commit mismatch")
|
||
if {item["name"] for item in release.get("assets") or []} != expected_names:
|
||
raise ReleaseError("published Forgejo asset set mismatch")
|
||
if forgejo.latest().get("tag_name") != f"v{version}":
|
||
raise ReleaseError("published release is not Latest")
|
||
|
||
|
||
def telegram_has_version(version: str) -> bool:
|
||
if not PUBLISHER_STATE.is_file():
|
||
return False
|
||
state = read_json(PUBLISHER_STATE)
|
||
return f"windows|v{version}|ZapretKVN-v{version}-windows-x64.exe|" in json.dumps(
|
||
state.get("published", {}), ensure_ascii=False
|
||
)
|
||
|
||
|
||
# Пасхалка «Удалена буква «О»»: правило одно на Zapret KVN и Zapret 2 GUI —
|
||
# общее ядро ``zapret_update.release_notes`` (только выпуск из одного пункта,
|
||
# ~3 из 10, перерыв после шутки). Здесь — только вписать её в пределы Telegram.
|
||
def _release_notes():
|
||
if str(ROOT) not in sys.path:
|
||
sys.path.insert(0, str(ROOT))
|
||
from zapret_update import release_notes
|
||
|
||
return release_notes
|
||
|
||
|
||
def published_changes(version: str, changes: list[str]) -> list[str]:
|
||
"""Строки изменений для Forgejo и Telegram; шутка — только если есть место."""
|
||
notes = _release_notes()
|
||
if not notes.wants_joke("stable", version, len(changes)):
|
||
return list(changes)
|
||
joked = [*changes, notes.LINE]
|
||
# Пределы публикатора в Telegram: до 6 строк и 700 знаков вместе.
|
||
if len(joked) > 6 or len("; ".join(joked)) > 700:
|
||
return list(changes)
|
||
return joked
|
||
|
||
|
||
def publish_telegram(version: str, changes: list[str]) -> None:
|
||
if telegram_has_version(version):
|
||
log(f"Telegram already contains v{version}")
|
||
return
|
||
run([PUBLISHER_PYTHON, PUBLISHER_SYNC, "--sync-windows"], timeout=1200)
|
||
if not PUBLISHER_COMMAND.is_file():
|
||
raise ReleaseError(f"Telegram publisher command is missing: {PUBLISHER_COMMAND}")
|
||
arguments: list[str | os.PathLike[str]] = [
|
||
PUBLISHER_PYTHON,
|
||
PUBLISHER_COMMAND,
|
||
"windows",
|
||
version,
|
||
]
|
||
for item in changes:
|
||
arguments.extend(("--change", item))
|
||
# Large installers over DPI-throttled MTProto can take well over the old
|
||
# 1800 s cap; align with the publisher's own timeout and, if the client still
|
||
# times out, trust the bot's publish ledger (the upload often completes after
|
||
# the client gives up) before failing.
|
||
publish_timeout = int(os.getenv("ZAPRET_PUBLISH_TIMEOUT", "7200"))
|
||
try:
|
||
run(arguments, timeout=publish_timeout)
|
||
except subprocess.TimeoutExpired:
|
||
if telegram_has_version(version):
|
||
log(f"Telegram publisher timed out but the ledger records v{version}; treating as published")
|
||
return
|
||
raise
|
||
if not telegram_has_version(version):
|
||
raise ReleaseError("Telegram publisher did not record the stable installer")
|
||
|
||
|
||
def preflight(version: str | None, changes: list[str], telegram: bool) -> str:
|
||
version = validate_next_stable_version(release_base(latest_stable_tag()), version)
|
||
refresh_stable_core_lock(write=False)
|
||
refresh_stable_geoip_lock(write=False)
|
||
Forgejo.load()
|
||
run(["ssh", WINDOWS_HOST, "powershell", "-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"], capture=True, timeout=30)
|
||
if telegram:
|
||
if not PUBLISHER_COMMAND.is_file():
|
||
raise ReleaseError("ZapretGPT stable publisher helper is not installed")
|
||
socket = Path("/home/codex-pve/zapretgpt/data/zapretgpt-admin.sock")
|
||
if not socket.exists() or not stat.S_ISSOCK(socket.stat().st_mode):
|
||
raise ReleaseError("ZapretGPT admin Unix socket is unavailable")
|
||
log(
|
||
json.dumps(
|
||
{"status": "ready", "version": version, "telegram": telegram, "changes": changes},
|
||
ensure_ascii=False,
|
||
)
|
||
)
|
||
return version
|
||
|
||
|
||
def load_or_create_state(args: argparse.Namespace, changes: list[str]) -> dict[str, Any]:
|
||
if STATE_PATH.is_file():
|
||
state = read_json(STATE_PATH)
|
||
if state.get("phase") == "complete":
|
||
os.replace(STATE_PATH, LAST_RESULT_PATH)
|
||
raise ReleaseError(
|
||
"the previous release was already complete; run the command again to start the next patch"
|
||
)
|
||
else:
|
||
if args.version and args.version != state.get("version"):
|
||
raise ReleaseError("--version conflicts with the active release state")
|
||
if changes and changes != state.get("changes"):
|
||
raise ReleaseError("changelog conflicts with the active release state")
|
||
log(f"resuming v{state['version']} from {state.get('phase', 'start')}")
|
||
return state
|
||
tag = latest_stable_tag()
|
||
version = validate_next_stable_version(release_base(tag), args.version)
|
||
if not changes:
|
||
raise ReleaseError("at least one --change or --changes value is required")
|
||
state = {
|
||
"schema": 1,
|
||
"phase": "",
|
||
"version": version,
|
||
"previous_tag": tag,
|
||
"changes": changes,
|
||
"telegram": not args.no_telegram,
|
||
}
|
||
atomic_json(STATE_PATH, state)
|
||
return state
|
||
|
||
|
||
def abandon(version: str, forgejo: Forgejo | None = None) -> dict[str, Any]:
|
||
"""Cancel the active release before anything immutable was published.
|
||
|
||
Allowed only while no tag and no Forgejo Release exist for the version;
|
||
the state is archived, never deleted, and the version is recorded so the
|
||
next fresh release may follow it.
|
||
"""
|
||
version = version_text(parse_version(version))
|
||
if not STATE_PATH.is_file():
|
||
raise ReleaseError("no active release to abandon")
|
||
state = read_json(STATE_PATH)
|
||
if state.get("version") != version:
|
||
raise ReleaseError(f"the active release is v{state.get('version')}, not v{version}")
|
||
if phase_done(state, "tag_pushed"):
|
||
raise ReleaseError(
|
||
f"v{version} already reached {state.get('phase')}; publish a correction under the next patch instead"
|
||
)
|
||
tag = f"v{version}"
|
||
if subprocess.run(["git", "rev-parse", "-q", "--verify", f"refs/tags/{tag}"], cwd=ROOT,
|
||
capture_output=True).returncode == 0:
|
||
raise ReleaseError(f"tag {tag} exists locally; a tagged release cannot be abandoned")
|
||
if output(["git", "ls-remote", "--tags", "origin", f"refs/tags/{tag}"]):
|
||
raise ReleaseError(f"tag {tag} exists on origin; a tagged release cannot be abandoned")
|
||
if (forgejo or Forgejo.load()).release_by_tag(version) is not None:
|
||
raise ReleaseError(f"Forgejo already has a Release for {tag}; it cannot be abandoned")
|
||
record = {
|
||
"phase": state.get("phase") or "start",
|
||
"commit": state.get("commit", ""),
|
||
"previous_tag": state.get("previous_tag", ""),
|
||
"abandoned_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||
}
|
||
versions = abandoned_versions()
|
||
versions[version] = record
|
||
atomic_json(ABANDONED_PATH, {"versions": versions})
|
||
os.replace(STATE_PATH, STATE_PATH.with_name(f"zapret-kvn-release-abandoned-v{version}.json"))
|
||
result = {"status": "abandoned", "version": version, **record}
|
||
log(json.dumps(result, ensure_ascii=False))
|
||
return result
|
||
|
||
|
||
def execute(args: argparse.Namespace) -> dict[str, Any]:
|
||
if args.abandon:
|
||
return abandon(args.abandon)
|
||
changes = normalize_changes(
|
||
(args.change or []) + ([args.changes] if args.changes else []),
|
||
allow_empty=not args.preflight,
|
||
)
|
||
# Refresh release facts before deriving a version or persisting resume state.
|
||
# This also guarantees that a failed fresh invocation cannot leave a state
|
||
# file behind merely because the worktree was dirty.
|
||
require_clean_main()
|
||
run(["git", "fetch", "origin", "main", "--tags"])
|
||
if args.preflight:
|
||
version = preflight(args.version, changes, not args.no_telegram)
|
||
return {"status": "ready", "version": version}
|
||
|
||
state = load_or_create_state(args, changes)
|
||
version = state["version"]
|
||
changes = list(state["changes"])
|
||
telegram = bool(state["telegram"])
|
||
forgejo = Forgejo.load()
|
||
|
||
if not phase_done(state, "source_prepared"):
|
||
if forgejo.release_by_tag(version) is not None:
|
||
raise ReleaseError(f"Forgejo release v{version} already exists")
|
||
if output(["git", "ls-remote", "--tags", "origin", f"refs/tags/v{version}"]):
|
||
raise ReleaseError(f"remote tag v{version} already exists")
|
||
if current_app_version() == version:
|
||
require_clean_main()
|
||
run(["git", "fetch", "origin", "main", "--tags"])
|
||
commit = output(["git", "rev-parse", "HEAD"])
|
||
if output(["git", "rev-parse", "origin/main"]) != commit:
|
||
raise ReleaseError(
|
||
"APP_VERSION already matches the candidate but HEAD is not origin/main"
|
||
)
|
||
log(f"recovered already-pushed release source {commit}")
|
||
else:
|
||
commit = prepare_source(version)
|
||
mark_phase(state, "source_prepared", commit=commit)
|
||
elif not phase_done(state, "tag_pushed"):
|
||
# A gate can fail because of the release source itself, and the only
|
||
# way to fix that is a new commit. Nothing immutable exists before the
|
||
# tag, so re-pin to the pushed HEAD and replay the gates against it;
|
||
# otherwise the release would be stuck rebuilding the broken commit,
|
||
# with a fresh start blocked by the already-bumped APP_VERSION.
|
||
require_clean_main()
|
||
head = output(["git", "rev-parse", "HEAD"])
|
||
if head != state["commit"]:
|
||
if current_app_version() != version:
|
||
raise ReleaseError(
|
||
"APP_VERSION no longer matches the active release version"
|
||
)
|
||
if output(["git", "rev-parse", "origin/main"]) != head:
|
||
raise ReleaseError("push the source fix to origin/main before resuming")
|
||
if output(["git", "ls-remote", "--tags", "origin", f"refs/tags/v{version}"]):
|
||
raise ReleaseError(f"remote tag v{version} already exists")
|
||
if forgejo.release_by_tag(version) is not None:
|
||
raise ReleaseError(f"Forgejo release v{version} already exists")
|
||
log(f"re-pinning release source to {head} after a source fix")
|
||
mark_phase(state, "source_prepared", commit=head)
|
||
commit = state["commit"]
|
||
|
||
release_dir = RELEASE_ROOT / f"v{version}"
|
||
release_dir.mkdir(parents=True, exist_ok=True)
|
||
if not phase_done(state, "dev_verified"):
|
||
powershell_bootstrap("dev", commit, version)
|
||
manifest = copy_manifest("dev", version, release_dir / "dev-manifest.json")
|
||
verify_gate_manifest(manifest, "dev", version, commit)
|
||
mark_phase(state, "dev_verified")
|
||
|
||
if not phase_done(state, "stable_verified"):
|
||
if forgejo.release_by_tag(version) is not None:
|
||
raise ReleaseError(f"Forgejo release v{version} appeared before stable build")
|
||
powershell_bootstrap("stable", commit, version)
|
||
mark_phase(state, "stable_verified")
|
||
|
||
if not phase_done(state, "assets_verified"):
|
||
release_dir, manifest = collect_assets(version, commit)
|
||
mark_phase(state, "assets_verified", manifest=str(release_dir / "stable-manifest.json"))
|
||
manifest = read_json(Path(state["manifest"]))
|
||
verify_gate_manifest(manifest, "stable", version, commit)
|
||
# В выпуск идут файлы сборки и подписанный список выпуска.
|
||
manifest = {**manifest, "assets": [*manifest["assets"], *signed_assets(release_dir)]}
|
||
|
||
if not phase_done(state, "tag_pushed"):
|
||
ensure_tag(version, commit)
|
||
mark_phase(state, "tag_pushed")
|
||
|
||
release = forgejo.release_by_tag(version)
|
||
if not phase_done(state, "draft_created"):
|
||
if release is None:
|
||
release = forgejo.create_draft(version, commit, published_changes(version, changes))
|
||
if not release.get("draft") or release.get("prerelease"):
|
||
raise ReleaseError("existing Forgejo release is not the expected draft")
|
||
mark_phase(state, "draft_created", release_id=int(release["id"]))
|
||
release = forgejo.release_by_tag(version)
|
||
if release is None:
|
||
raise ReleaseError("Forgejo release is missing")
|
||
|
||
if not phase_done(state, "assets_uploaded"):
|
||
# Хранилище — раньше файлов выпуска: программа, увидевшая выпуск,
|
||
# сразу находит всё, на что ссылается его подписанный список.
|
||
upload_store(release_dir)
|
||
release = verify_and_upload_assets(forgejo, release, release_dir, manifest)
|
||
mark_phase(state, "assets_uploaded")
|
||
|
||
if not phase_done(state, "release_published"):
|
||
if release.get("draft"):
|
||
release = forgejo.publish(int(release["id"]))
|
||
verify_published_release(forgejo, release, version, commit, manifest)
|
||
mark_phase(state, "release_published", release_url=release["html_url"])
|
||
else:
|
||
verify_published_release(forgejo, release, version, commit, manifest)
|
||
# Уборка хранилища — после выпуска; её сбой выпуску не мешает.
|
||
try:
|
||
prune_store(release_dir, version)
|
||
except Exception as exc: # noqa: BLE001
|
||
log(f"update store prune skipped: {exc}")
|
||
|
||
if telegram and not phase_done(state, "telegram_published"):
|
||
publish_telegram(version, published_changes(version, changes))
|
||
mark_phase(state, "telegram_published")
|
||
elif not telegram and not phase_done(state, "telegram_published"):
|
||
mark_phase(state, "telegram_published", telegram_skipped=True)
|
||
|
||
run(["git", "fetch", "origin", "main", "--tags"])
|
||
# Follow-up commits on main during the release (e.g. runner fixes) are
|
||
# legitimate: the published artifacts are pinned to the release commit by
|
||
# the immutable tag. Finalization only requires that main still contains
|
||
# the release commit, is synced with origin, and the tree is clean.
|
||
local_head = output(["git", "rev-parse", "HEAD"])
|
||
if local_head != output(["git", "rev-parse", "origin/main"]):
|
||
raise ReleaseError("local main and origin/main diverged during release")
|
||
contains = subprocess.run(
|
||
["git", "merge-base", "--is-ancestor", commit, local_head], cwd=ROOT
|
||
)
|
||
if contains.returncode != 0:
|
||
raise ReleaseError("main no longer contains the release commit")
|
||
require_clean_main()
|
||
mark_phase(state, "complete")
|
||
os.replace(STATE_PATH, LAST_RESULT_PATH)
|
||
prune_release_archives(version)
|
||
result = {
|
||
"status": "published",
|
||
"version": version,
|
||
"commit": commit,
|
||
"release_url": state["release_url"],
|
||
"telegram": telegram,
|
||
"assets": [item["name"] for item in manifest["assets"]],
|
||
}
|
||
log(json.dumps(result, ensure_ascii=False))
|
||
return result
|
||
|
||
|
||
def build_parser() -> argparse.ArgumentParser:
|
||
parser = argparse.ArgumentParser(
|
||
description="Build, verify and publish one Zapret KVN Windows stable release"
|
||
)
|
||
parser.add_argument(
|
||
"--version",
|
||
help="exact next patch or immediate next minor .0; defaults to the next patch",
|
||
)
|
||
parser.add_argument(
|
||
"--change",
|
||
action="append",
|
||
help="one user-facing changelog item; repeat 1-6 times",
|
||
)
|
||
parser.add_argument(
|
||
"--changes",
|
||
help="semicolon-separated changelog items (alternative to repeated --change)",
|
||
)
|
||
parser.add_argument(
|
||
"--no-telegram",
|
||
action="store_true",
|
||
help="publish Forgejo stable but do not send the installer to Telegram",
|
||
)
|
||
parser.add_argument(
|
||
"--abandon",
|
||
metavar="VERSION",
|
||
help="cancel the active release of VERSION before its tag exists; the next release follows it",
|
||
)
|
||
parser.add_argument(
|
||
"--preflight",
|
||
action="store_true",
|
||
help="validate the release environment without changing Git or external state",
|
||
)
|
||
return parser
|
||
|
||
|
||
def main() -> int:
|
||
try:
|
||
execute(build_parser().parse_args())
|
||
except (ReleaseError, OSError, subprocess.SubprocessError) as exc:
|
||
print(f"[release] ERROR: {exc}", file=sys.stderr)
|
||
return 1
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|