ZapretKVN-android/scripts/update_hysteria_core.py
loop-uh ba7364bb95
All checks were successful
Android CI / build (push) Successful in 33m40s
Ядро: Hysteria app/v2.13.0 и автоматическая перегенерация серии патчей
Встроенная Hysteria поднята до app/v2.13.0 — версии, выбранной для Windows
0.8.16; заморозка ядер записана для пары Windows 0.8.16 / Android v0.4.11.

- scripts/update_hysteria_core.py выполняет docs/CORE_UPDATE.md сам: во
  временной копии исходников собирает серию цепочкой коммитов, поднимает
  модули Hysteria, разрешает ожидаемый конфликт go.mod версиями обновлённых
  модулей и формирует новые патчи. В репозиторий пишет, только если прежние
  патчи воспроизводятся байт в байт, go mod tidy -diff не требует изменений
  go.mod и затронутые пакеты компилируются с боевыми тегами; иначе отказывает
  без записи.
- scripts/prepare-stable-core.sh готовит ядра к стабильному релизу одной
  командой: берёт последний стабильный тег Windows и запускает общую
  подготовку, которая при отставании Android вызывает перегенерацию сама.
- Патчи 0003–0005, series.sha256, пины и уведомления о сторонних компонентах
  получены этим скриптом; результат совпал с ручным прогоном процедуры.

libbox.aar пересобран, тесты ядра (включая protocol/hysteria2) и
verify-project.sh проходят.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:36:36 +03:00

342 lines
18 KiB
Python
Executable file
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
"""Перевести встроенную Hysteria на новую официальную версию.
Автоматизирует docs/CORE_UPDATE.md. Hysteria вшита в libbox патчем
core-patches/0003, а следующие патчи трогают соседние строки go.mod, поэтому
новая версия — это перегенерация серии, а не правка одной строки.
Скрипт работает во временной копии исходников ядра и пишет в репозиторий
только после того, как итог прошёл все проверки:
- каждый текущий патч байт в байт воспроизводится из цепочки коммитов, то есть
новые патчи отличаются от прежних только самим обновлением;
- `go mod tidy -diff` не требует изменений go.mod;
- пакеты, которые затрагивают патчи, компилируются с боевыми тегами сборки.
Любое расхождение — отказ без записи. Сборку libbox.aar выполняет preBuild-хук
Gradle (scripts/ensure-libbox.sh), когда видит новые пины.
scripts/update_hysteria_core.py --tag app/v2.13.0 # проверить
scripts/update_hysteria_core.py --tag app/v2.13.0 --write # записать
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
HYSTERIA_MODULES = ("github.com/apernet/hysteria/core/v2", "github.com/apernet/hysteria/extras/v2")
HYSTERIA_PATCH_INDEX = 3
COMPILE_PACKAGES = (
"./protocol/hysteria2",
"./protocol/xraycore",
"./parser/link",
"./route/rule",
"./transport/wireguard",
)
VERSION_FILES = (
"docs/THIRD_PARTY_NOTICES.md",
"app/src/main/res/raw/third_party_notices.txt",
"docs/IMPORT_FORMATS.md",
)
class UpdateError(RuntimeError):
"""Обновление нельзя выполнить автоматически; репозиторий не изменён."""
def read_properties(path: Path) -> dict[str, str]:
return dict(line.split("=", 1) for line in path.read_text(encoding="utf-8").splitlines() if "=" in line)
def sha256_text(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
class Workspace:
def __init__(self, source: Path, go: str, env: dict[str, str]) -> None:
self.source = source
self.go = go
self.env = env
def run(self, *args: str, check: bool = True, capture: bool = True) -> subprocess.CompletedProcess:
return subprocess.run(
list(args), cwd=self.source, env=self.env, check=check, text=True,
stdout=subprocess.PIPE if capture else None, stderr=subprocess.PIPE if capture else None,
)
def git(self, *args: str, check: bool = True) -> subprocess.CompletedProcess:
return self.run("git", "-c", "user.name=core-update", "-c", "user.email=core-update@localhost",
"-c", "commit.gpgsign=false", "-c", "core.autocrlf=false", *args, check=check)
def diff(self, older: str, newer: str) -> bytes:
return subprocess.run(["git", "diff", "--abbrev=8", older, newer], cwd=self.source, env=self.env,
check=True, stdout=subprocess.PIPE).stdout
def tidy_go_mod_lines(self) -> list[str]:
"""Строки, которые `go mod tidy` поменял бы в go.mod (без go.sum)."""
result = self.run(self.go, "mod", "tidy", "-diff", check=False)
if result.returncode not in (0, 1):
raise UpdateError(f"go mod tidy -diff: {result.stderr.strip()[-400:]}")
lines: list[str] = []
in_go_mod = False
for line in result.stdout.splitlines():
if line.startswith("diff "):
in_go_mod = line.rstrip().endswith("go.mod")
continue
if in_go_mod and line[:1] in "+-" and not line.startswith(("+++", "---")):
lines.append(line)
return lines
def require_lines(go_mod: str) -> dict[str, str]:
"""module -> полная строка require (с отступом и комментарием)."""
found: dict[str, str] = {}
for line in go_mod.splitlines():
match = re.fullmatch(r"\t(\S+) (v\S+)( // indirect)?", line)
if match:
found[match.group(1)] = line
return found
def resolve_go_mod_conflict(text: str, upgrades: dict[str, str]) -> str:
"""Конфликт в go.mod: сторона патча, но с версиями обновлённых модулей.
Следующий патч серии написан поверх прежней Hysteria, поэтому его сторона
верна во всём, кроме версий, которые только что поднял `go get`.
"""
def pick(match: re.Match) -> str:
theirs = match.group(2)
for old, new in upgrades.items():
theirs = theirs.replace(old, new)
return theirs
resolved, count = re.subn(r"<<<<<<< [^\n]*\n(.*?)=======\n(.*?)>>>>>>> [^\n]*\n", pick, text, flags=re.S)
if count == 0 or "<<<<<<<" in resolved or ">>>>>>>" in resolved:
raise UpdateError("go.mod: конфликт не удалось разрешить по правилу обновления версий")
return resolved
def resolve_go_sum_conflict(text: str, stale: list[str]) -> str:
"""Конфликт в go.sum: объединение сторон без хешей прежних версий."""
lines = sorted({line for line in text.splitlines() if line and not line.startswith(("<<<<<<<", "=======", ">>>>>>>"))})
return "\n".join(line for line in lines if not any(line.startswith(prefix) for prefix in stale)) + "\n"
def build_tags(root: Path) -> str:
match = re.search(r'^CORE_TAGS="([^"]+)"', (root / "scripts/build-core.sh").read_text(encoding="utf-8"), re.M)
if not match:
raise UpdateError("scripts/build-core.sh: не найден CORE_TAGS")
# Те же теги, что у тестов сборки: без обхода проверки linkname.
return match.group(1).removesuffix(",badlinkname,tfogo_checklinkname0")
def obtain_source(root: Path, properties: dict[str, str], destination: Path) -> None:
cached = root / "core-build/source"
if (cached / ".git").exists():
head = subprocess.run(["git", "-C", str(cached), "rev-parse", "HEAD"], text=True, capture_output=True)
dirty = subprocess.run(["git", "-C", str(cached), "status", "--porcelain"], text=True, capture_output=True)
if head.stdout.strip() == properties["CORE_COMMIT"] and not dirty.stdout.strip():
# Не `git clone --shared`: core-build/source — неглубокий клон.
shutil.copytree(cached, destination, symlinks=True)
return
destination.mkdir(parents=True)
for command in (
["git", "init", "-q"],
["git", "remote", "add", "origin", properties["CORE_REPOSITORY"]],
["git", "fetch", "-q", "--depth=1", "origin", properties["CORE_COMMIT"]],
["git", "checkout", "-q", "FETCH_HEAD"],
):
subprocess.run(command, cwd=destination, check=True)
def update(root: Path, tag: str, write: bool, go: str) -> dict:
if not re.fullmatch(r"app/v\d+\.\d+\.\d+", tag):
raise UpdateError(f"Ожидается тег вида app/vX.Y.Z, получено: {tag}")
version = tag.removeprefix("app/")
properties_path = root / "core.properties"
properties = read_properties(properties_path)
previous_tag = properties["HYSTERIA_CORE_TAG"]
if previous_tag == tag:
return {"changed": False, "tag": tag, "commit": properties["HYSTERIA_CORE_COMMIT"]}
manifest_path = root / properties["CORE_PATCH_FILE"]
series = [line.split() for line in manifest_path.read_text(encoding="utf-8").splitlines() if line.strip()]
for digest, relative in series:
if sha256_text((root / relative).read_bytes()) != digest:
raise UpdateError(f"Патч не совпадает с series.sha256: {relative}")
if len(series) < HYSTERIA_PATCH_INDEX or "hysteria" not in series[HYSTERIA_PATCH_INDEX - 1][1]:
raise UpdateError("Патч Hysteria больше не третий в серии: обновите скрипт под новую серию")
expected_go = "go" + properties["GO_VERSION"]
actual_go = subprocess.run([go, "env", "GOVERSION"], text=True, capture_output=True, check=True).stdout.strip()
if actual_go != expected_go:
raise UpdateError(f"Нужен {expected_go} (GO_VERSION), найден {actual_go}")
with tempfile.TemporaryDirectory(prefix="hysteria-core-update-") as temporary:
source = Path(temporary) / "source"
obtain_source(root, properties, source)
env = {**os.environ, "GOTOOLCHAIN": "local", "GOFLAGS": "-mod=mod", "GIT_EDITOR": "true"}
work = Workspace(source, go, env)
# Цепочка коммитов p0..pN и доказательство, что настройки диффа дают
# прежние патчи байт в байт.
work.git("tag", "-f", "p0")
for index, (_, relative) in enumerate(series, start=1):
work.git("apply", "--whitespace=nowarn", str(root / relative))
work.git("add", "-A")
work.git("commit", "-q", "-m", f"p{index}")
work.git("tag", "-f", f"p{index}")
if work.diff(f"p{index - 1}", f"p{index}") != (root / relative).read_bytes():
raise UpdateError(f"Патч не воспроизводится из цепочки коммитов: {relative}")
base = f"p{HYSTERIA_PATCH_INDEX}"
work.git("checkout", "-q", base)
baseline_tidy = set(work.tidy_go_mod_lines())
before = require_lines((source / "go.mod").read_text(encoding="utf-8"))
fetched = work.run(go, "get", *(f"{module}@{version}" for module in HYSTERIA_MODULES), check=False)
if fetched.returncode != 0:
raise UpdateError(f"go get Hysteria {version}: {fetched.stderr.strip()[-600:]}")
after = require_lines((source / "go.mod").read_text(encoding="utf-8"))
if set(before) != set(after):
raise UpdateError("go get изменил состав модулей go.mod: нужен ручной разбор по docs/CORE_UPDATE.md")
upgrades = {before[module].strip(): after[module].strip() for module in before if before[module] != after[module]}
if not all(module in " ".join(upgrades) for module in HYSTERIA_MODULES):
raise UpdateError("go get не поднял модули Hysteria")
new_tidy = [line for line in work.tidy_go_mod_lines() if line not in baseline_tidy]
if new_tidy:
raise UpdateError("go mod tidy требует новых строк go.mod: " + "; ".join(new_tidy[:6]))
# Из go.sum уходят только хеши прежних версий обновлённых модулей.
stale = [re.sub(r" // indirect$", "", old) + suffix for old in upgrades for suffix in (" ", "/go.mod ")]
go_sum = source / "go.sum"
go_sum.write_text(
"".join(line for line in go_sum.read_text(encoding="utf-8").splitlines(keepends=True)
if not any(line.startswith(prefix) for prefix in stale)),
encoding="utf-8",
)
work.git("add", "-A")
work.git("commit", "-q", "-m", f"n{HYSTERIA_PATCH_INDEX}")
work.git("tag", "-f", f"n{HYSTERIA_PATCH_INDEX}")
for index in range(HYSTERIA_PATCH_INDEX + 1, len(series) + 1):
picked = work.git("cherry-pick", f"p{index}", check=False)
if picked.returncode != 0:
conflicted = work.git("diff", "--name-only", "--diff-filter=U").stdout.split()
unexpected = [name for name in conflicted if name not in ("go.mod", "go.sum")]
if unexpected or not conflicted:
raise UpdateError(f"Патч {series[index - 1][1]} конфликтует вне go.mod/go.sum: {unexpected}")
for name in conflicted:
path = source / name
text = path.read_text(encoding="utf-8")
path.write_text(
resolve_go_mod_conflict(text, upgrades) if name == "go.mod" else resolve_go_sum_conflict(text, stale),
encoding="utf-8",
)
work.git("add", name)
work.git("cherry-pick", "--continue")
work.git("tag", "-f", f"n{index}")
final_mod = (source / "go.mod").read_text(encoding="utf-8")
final_sum = go_sum.read_text(encoding="utf-8")
for old, new in upgrades.items():
if old in final_mod or new not in final_mod:
raise UpdateError(f"В итоговом go.mod осталась прежняя версия: {old}")
if any(line.startswith(prefix) for line in final_sum.splitlines() for prefix in stale):
raise UpdateError("В итоговом go.sum остались хеши прежних версий")
patches: dict[str, bytes] = {}
for index, (_, relative) in enumerate(series, start=1):
if index < HYSTERIA_PATCH_INDEX:
patches[relative] = (root / relative).read_bytes()
continue
older = f"p{index - 1}" if index == HYSTERIA_PATCH_INDEX else f"n{index - 1}"
patches[relative] = work.diff(older, f"n{index}")
# Итоговое дерево должно собираться: подготовка Xray, tidy и компиляция
# затронутых патчами пакетов с боевыми тегами.
prepare = source / "protocol/xraycore/prepare.sh"
if prepare.exists():
work.run("bash", str(prepare), properties["XRAY_CORE_MODULE"], properties["XRAY_CORE_COMMIT"])
compile_env = {**env, "GOFLAGS": ""}
tidy = Workspace(source, go, compile_env).tidy_go_mod_lines()
if tidy:
raise UpdateError("Итоговый go.mod не приведён (go mod tidy -diff): " + "; ".join(tidy[:6]))
compiled = subprocess.run(
[go, "test", "-count=1", "-run", "^$", "-tags", build_tags(root), *COMPILE_PACKAGES],
cwd=source, env=compile_env, text=True, capture_output=True,
)
if compiled.returncode != 0:
raise UpdateError("Ядро не компилируется с новой Hysteria:\n" + (compiled.stdout + compiled.stderr)[-1500:])
origin = json.loads(work.run(go, "mod", "download", "-json", f"{HYSTERIA_MODULES[0]}@{version}").stdout)["Origin"]
commit = origin["Hash"]
if not re.fullmatch(r"[0-9a-f]{40}", commit) or not str(origin.get("Ref", "")).endswith(f"/{version}"):
raise UpdateError(f"Не удалось подтвердить коммит Hysteria {version}: {origin}")
manifest = "".join(f"{sha256_text(patches[relative])} {relative}\n" for _, relative in series)
new_properties = properties_path.read_text(encoding="utf-8")
for key, value in (
("HYSTERIA_CORE_TAG", tag),
("HYSTERIA_CORE_COMMIT", commit),
("CORE_PATCH_SHA256", sha256_text(manifest.encode())),
):
new_properties, count = re.subn(rf"(?m)^{key}=.*$", f"{key}={value}", new_properties)
if count != 1:
raise UpdateError(f"core.properties: ожидался один ключ {key}")
previous_version = previous_tag.removeprefix("app/")
documents: dict[Path, str] = {}
for relative in VERSION_FILES:
path = root / relative
text = path.read_text(encoding="utf-8")
updated = text.replace(previous_tag, tag).replace(f"@{previous_version}", f"@{version}")
updated = updated.replace(properties["HYSTERIA_CORE_COMMIT"], commit)
if previous_tag in text and updated == text:
raise UpdateError(f"Не удалось обновить версию в {relative}")
documents[path] = updated
result = {
"changed": True, "previous": previous_tag, "tag": tag, "commit": commit,
"patches": [relative for _, relative in series if patches[relative] != (root / relative).read_bytes()],
"upgrades": upgrades,
}
if write:
for relative, data in patches.items():
(root / relative).write_bytes(data)
manifest_path.write_text(manifest, encoding="utf-8")
properties_path.write_text(new_properties, encoding="utf-8")
for path, text in documents.items():
path.write_text(text, encoding="utf-8")
return result
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--tag", required=True, help="официальный тег Hysteria, например app/v2.13.0")
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
parser.add_argument("--go", default="go")
parser.add_argument("--write", action="store_true", help="записать патчи и пины; без флага — только проверка")
args = parser.parse_args()
try:
result = update(args.root.resolve(), args.tag, args.write, args.go)
except UpdateError as error:
print(f"update_hysteria_core: {error}", file=sys.stderr)
return 1
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())