zapret-kvn/scripts/release_windows.py
loop-uh 6ea3d5aeef
Some checks failed
Windows project source guards / test (push) Failing after 26s
Выпуск: уборка хранилища Forgejo после публикации
Оставляется то, на что ссылаются этот и три прежних выпуска; ядро —
копия из Zapret 2 GUI (без лишних заплаток zstd, publish.prune).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:51:14 +03:00

1383 lines
57 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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())