mirror of
https://github.com/Shu-Ying/noname-shuying.git
synced 2026-10-13 04:20:03 +08:00
fixed # 补全部分卡牌
fixed # 修复部分已知BUG
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0.."
|
||||
set /p VERSION=Input version, for example 2.0.0.4:
|
||||
if "%VERSION%"=="" (
|
||||
set /p "SHUYING_RELEASE_VERSION=Input version, for example 2.0.0.4: "
|
||||
if not defined SHUYING_RELEASE_VERSION (
|
||||
echo Version cannot be empty.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
python tools\generate_manifest.py --root . --version "%VERSION%" --out dist\manifest.json
|
||||
python tools\prepare_release_manifest.py --root .
|
||||
if errorlevel 1 (
|
||||
echo Release preparation failed. Commit all source changes first.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
pause
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -17,9 +18,17 @@ EXCLUDE_DIRS = {
|
||||
"tools",
|
||||
"__pycache__",
|
||||
"verification",
|
||||
"node_modules",
|
||||
"Slay the Spire 2",
|
||||
".godot",
|
||||
".codex",
|
||||
".agents",
|
||||
".aws",
|
||||
"logs",
|
||||
}
|
||||
EXCLUDE_FILES = {".gitignore", "log.txt"}
|
||||
EXCLUDE_EXTENSIONS = {".md", ".mjs"}
|
||||
EXCLUDE_PATHS = {("mengsan", "assets", "sts2")}
|
||||
EXCLUDE_FILES = {".gitignore", "log.txt", "Thumbs.db", "Desktop.ini", ".DS_Store"}
|
||||
EXCLUDE_EXTENSIONS = {".md", ".mjs", ".cjs", ".log", ".pyc", ".tmp", ".bak", ".swp", ".swo"}
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
@@ -35,16 +44,26 @@ def should_include(path: Path, root: Path) -> bool:
|
||||
return (
|
||||
path.is_file()
|
||||
and relative.name not in EXCLUDE_FILES
|
||||
and not relative.name.startswith(".env")
|
||||
and path.suffix.lower() not in EXCLUDE_EXTENSIONS
|
||||
and not any(part in EXCLUDE_DIRS for part in relative.parts)
|
||||
and not any(relative.parts[:len(prefix)] == prefix for prefix in EXCLUDE_PATHS)
|
||||
)
|
||||
|
||||
|
||||
def scan_files(root: Path) -> list[Path]:
|
||||
return sorted(
|
||||
(path for path in root.rglob("*") if should_include(path, root)),
|
||||
key=lambda path: str(path.relative_to(root)).replace("\\", "/"),
|
||||
)
|
||||
files = []
|
||||
for directory, folders, names in os.walk(root):
|
||||
parent = Path(directory)
|
||||
folders[:] = [name for name in folders if name not in EXCLUDE_DIRS
|
||||
and (parent / name).relative_to(root).parts not in EXCLUDE_PATHS]
|
||||
for name in names:
|
||||
path = parent / name
|
||||
if should_include(path, root):
|
||||
if not path.resolve().is_relative_to(root):
|
||||
raise ValueError(f"file escapes source root: {path}")
|
||||
files.append(path)
|
||||
return sorted(files, key=lambda path: str(path.relative_to(root)).replace("\\", "/"))
|
||||
|
||||
|
||||
def normalize_path(path: str) -> str:
|
||||
@@ -329,7 +348,7 @@ def main() -> None:
|
||||
root, version, config, previous_manifest
|
||||
)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
output.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8", newline="\n")
|
||||
module_output = output.parent / "modules"
|
||||
for name, module_manifest in modules.items():
|
||||
module_path = module_output / f"{name}.json"
|
||||
@@ -337,6 +356,7 @@ def main() -> None:
|
||||
module_path.write_text(
|
||||
json.dumps(module_manifest, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
for name, module_manifest in legacy_modules.items():
|
||||
module_path = module_output / f"{name}.legacy.json"
|
||||
@@ -344,6 +364,7 @@ def main() -> None:
|
||||
module_path.write_text(
|
||||
json.dumps(module_manifest, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
if module_output.is_dir():
|
||||
expected = {f"{name}.json" for name in modules}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Generate release manifests from committed Git bytes, without creating a tag."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def git(root: Path, *args: str) -> bytes:
|
||||
return subprocess.run(["git", "-c", "core.autocrlf=false", *args], cwd=root,
|
||||
check=True, stdout=subprocess.PIPE).stdout
|
||||
|
||||
|
||||
def prepare(root: Path, version: str) -> None:
|
||||
if not re.fullmatch(r"\d+\.\d+\.\d+\.\d+(?:-rc\.\d+)?", version):
|
||||
raise ValueError("Invalid release version")
|
||||
repository = Path(git(root, "rev-parse", "--show-toplevel").decode("utf-8").strip()).resolve()
|
||||
if repository != root:
|
||||
raise ValueError("Run this tool from the extension repository")
|
||||
rows = git(root, "status", "--porcelain=v1", "-z", "--untracked-files=all").decode("utf-8").split("\0")
|
||||
dirty = []
|
||||
i = 0
|
||||
while i < len(rows):
|
||||
row = rows[i]
|
||||
if row:
|
||||
path = row[3:]
|
||||
if not path.startswith("dist/"):
|
||||
dirty.append(path)
|
||||
if "R" in row[:2] or "C" in row[:2]:
|
||||
i += 1
|
||||
if i < len(rows) and not rows[i].startswith("dist/"):
|
||||
dirty.append(rows[i])
|
||||
i += 1
|
||||
if dirty:
|
||||
raise ValueError("Commit source and new runtime files first; pending files: " + ", ".join(dirty[:8]))
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="shuying-release-") as temporary:
|
||||
workspace = Path(temporary)
|
||||
archive = workspace / "source.zip"
|
||||
stage = workspace / "source"
|
||||
git(root, "archive", "--format=zip", f"--output={archive}", "HEAD")
|
||||
with zipfile.ZipFile(archive) as source:
|
||||
# Git trees cannot contain traversal paths; still check before extracting.
|
||||
for item in source.infolist():
|
||||
path = stage / item.filename
|
||||
if not path.resolve().is_relative_to(stage.resolve()):
|
||||
raise ValueError("Unsafe archive path")
|
||||
source.extractall(stage)
|
||||
subprocess.run([sys.executable, str(stage / "tools/generate_manifest.py"),
|
||||
"--root", str(stage), "--version", version], check=True)
|
||||
subprocess.run([sys.executable, str(stage / "tools/verify_manifest.py"),
|
||||
"--root", str(stage), "--version", version], check=True)
|
||||
output = root / "dist"
|
||||
output.mkdir(exist_ok=True)
|
||||
shutil.copyfile(stage / "dist/manifest.json", output / "manifest.json")
|
||||
components = output / "modules"
|
||||
components.mkdir(exist_ok=True)
|
||||
generated = list((stage / "dist/modules").glob("*.json"))
|
||||
expected = {path.name for path in generated}
|
||||
for path in generated:
|
||||
shutil.copyfile(path, components / path.name)
|
||||
for stale in components.glob("*.json"):
|
||||
if stale.name not in expected:
|
||||
stale.unlink()
|
||||
print("Manifests are ready. Commit dist/manifest.json and dist/modules/*.json before creating the release tag.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", default=".")
|
||||
parser.add_argument("--version", default=os.environ.get("SHUYING_RELEASE_VERSION"))
|
||||
args = parser.parse_args()
|
||||
if not args.version:
|
||||
parser.error("--version is required")
|
||||
prepare(Path(args.root).resolve(), args.version)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Read-only validation of the manifests committed in a release tag."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from generate_manifest import build_manifest, load_config, preserve_removals, scan_files
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict:
|
||||
if not path.is_file():
|
||||
raise ValueError(f"Missing committed manifest: {path}. Generate and commit manifests before tagging.")
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"Manifest must be an object: {path}")
|
||||
return value
|
||||
|
||||
|
||||
def compare_manifest(actual: dict, expected: dict, label: str) -> None:
|
||||
# generated_at and the export directory's name do not affect installed bytes.
|
||||
fields = ("version", "algorithm", "update_channels", "file_count", "size_bytes",
|
||||
"folder_sha256", "core", "assets", "remove", "removeDirectories", "files")
|
||||
if "module_id" in expected:
|
||||
fields += ("module_id",)
|
||||
for field in fields:
|
||||
if actual.get(field) != expected.get(field):
|
||||
raise ValueError(f"Stale or invalid {label}: {field}. Regenerate manifests before tagging.")
|
||||
if actual.get("tree", {}).get("children") != expected["tree"]["children"]:
|
||||
raise ValueError(f"Invalid {label}: tree")
|
||||
|
||||
|
||||
def verify_imports(root: Path, names: set[str]) -> None:
|
||||
patterns = (
|
||||
re.compile(r'''\b(?:import|export)\s+(?:[^;]*?\bfrom\s*)?["'](\.[^"']+)["']'''),
|
||||
re.compile(r'''\bimport\s*\(\s*["'](\.[^"']+)["']'''),
|
||||
)
|
||||
for name in sorted(names):
|
||||
if not name.endswith(".js"):
|
||||
continue
|
||||
path = root / name
|
||||
source = path.read_text(encoding="utf-8")
|
||||
for pattern in patterns:
|
||||
for match in pattern.finditer(source):
|
||||
target = (path.parent / match[1]).resolve()
|
||||
if target.is_relative_to(root):
|
||||
dependency = target.relative_to(root).as_posix()
|
||||
if dependency not in names or not target.is_file():
|
||||
raise ValueError(f"Missing packaged import: {name} -> {match[1]}. Commit all runtime files before releasing.")
|
||||
|
||||
|
||||
def verify(root: Path, version: str) -> None:
|
||||
if not re.fullmatch(r"\d+\.\d+\.\d+\.\d+(?:-rc\.\d+)?", version):
|
||||
raise ValueError("Invalid release version")
|
||||
source = (root / "extension.js").read_text(encoding="utf-8")
|
||||
match = re.search(r'shuYingLocalVersion\s*=\s*"([^"]+)"', source)
|
||||
if not match or match[1] != version:
|
||||
raise ValueError(f"extension.js version must be {version}")
|
||||
for path in scan_files(root):
|
||||
if not path.resolve().is_relative_to(root):
|
||||
raise ValueError(f"Release file escapes source root: {path}")
|
||||
|
||||
output = root / "dist/manifest.json"
|
||||
actual = read_json(output)
|
||||
config = preserve_removals(load_config(root, "tools/manifest_config.json"), output)
|
||||
expected, modules, legacy = build_manifest(root, version, config, actual)
|
||||
compare_manifest(actual, expected, "base manifest")
|
||||
if actual.get("modules") != expected.get("modules"):
|
||||
raise ValueError("Invalid module descriptors")
|
||||
for name, expected_module in modules.items():
|
||||
component = read_json(root / actual["modules"][name]["manifest"])
|
||||
compare_manifest(component, expected_module, f"{name} manifest")
|
||||
packaged_files = set(actual["files"])
|
||||
for component in modules.values():
|
||||
packaged_files.update(component["files"])
|
||||
verify_imports(root, packaged_files)
|
||||
for name, baseline in legacy.items():
|
||||
snapshot = read_json(root / actual["modules"][name]["legacy_manifest"])
|
||||
files = snapshot.get("files")
|
||||
prefix = config["modules"][name]["root"] + "/"
|
||||
if (snapshot.get("version") != version or snapshot.get("module_id") != name
|
||||
or snapshot.get("snapshot") != "legacy" or not isinstance(files, list)
|
||||
or any(not isinstance(path, str) or not path.startswith(prefix)
|
||||
or "\\" in path or ":" in path
|
||||
or any(part in {"", ".", ".."} for part in path.split("/"))
|
||||
for path in files)
|
||||
or len(files) != len(set(files))
|
||||
or not set(baseline["files"]).issubset(files)):
|
||||
raise ValueError(f"Invalid or incomplete {name} legacy ownership snapshot")
|
||||
print(f"Verified {version}: {actual['file_count']} base files; "
|
||||
+ ", ".join(f"{name}: {item['file_count']} files" for name, item in modules.items()))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", default=".")
|
||||
parser.add_argument("--version", required=True)
|
||||
args = parser.parse_args()
|
||||
verify(Path(args.root).resolve(), args.version)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user