Add: windows mvp - transparent bugs not fixed
This commit is contained in:
197
scripts/package_windows.py
Normal file
197
scripts/package_windows.py
Normal file
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build and package a portable Windows ZIP for sprimo-app."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DIST = ROOT / "dist"
|
||||
BIN_REL = ROOT / "target" / "release" / "sprimo-app.exe"
|
||||
ASSETS_REL = ROOT / "assets"
|
||||
|
||||
|
||||
class PackagingError(RuntimeError):
|
||||
"""Raised when packaging preconditions are not met."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackageLayout:
|
||||
version: str
|
||||
zip_path: Path
|
||||
checksum_path: Path
|
||||
|
||||
|
||||
def run(cmd: list[str], cwd: Path = ROOT) -> None:
|
||||
result = subprocess.run(cmd, cwd=cwd, check=False)
|
||||
if result.returncode != 0:
|
||||
raise PackagingError(f"command failed ({result.returncode}): {' '.join(cmd)}")
|
||||
|
||||
|
||||
def read_version() -> str:
|
||||
cmd = ["cargo", "metadata", "--format-version", "1", "--no-deps"]
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=ROOT,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise PackagingError(f"failed to read cargo metadata: {proc.stderr.strip()}")
|
||||
|
||||
data = json.loads(proc.stdout)
|
||||
root_pkg = data.get("workspace_root")
|
||||
if not root_pkg:
|
||||
raise PackagingError("workspace metadata missing workspace_root")
|
||||
|
||||
# Prefer workspace package version.
|
||||
manifest_path = ROOT / "Cargo.toml"
|
||||
manifest = manifest_path.read_text(encoding="utf-8")
|
||||
marker = "version = "
|
||||
for line in manifest.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith(marker):
|
||||
return stripped.split("=", 1)[1].strip().strip('"')
|
||||
|
||||
# Fallback to first package version from metadata.
|
||||
packages = data.get("packages", [])
|
||||
if packages:
|
||||
return packages[0]["version"]
|
||||
raise PackagingError("could not determine version")
|
||||
|
||||
|
||||
def ensure_release_binary() -> Path:
|
||||
if not BIN_REL.exists():
|
||||
run(["cargo", "build", "--release", "-p", "sprimo-app"])
|
||||
if not BIN_REL.exists():
|
||||
raise PackagingError(f"release binary missing: {BIN_REL}")
|
||||
return BIN_REL
|
||||
|
||||
|
||||
def ensure_assets() -> None:
|
||||
required = [
|
||||
ASSETS_REL / "sprite-packs" / "default" / "manifest.json",
|
||||
ASSETS_REL / "sprite-packs" / "default" / "sprite.png",
|
||||
]
|
||||
missing = [path for path in required if not path.exists()]
|
||||
if missing:
|
||||
joined = ", ".join(str(path) for path in missing)
|
||||
raise PackagingError(f"required assets missing: {joined}")
|
||||
|
||||
|
||||
def iter_stage_files(stage_root: Path) -> Iterable[Path]:
|
||||
for path in stage_root.rglob("*"):
|
||||
if path.is_file():
|
||||
yield path
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
while True:
|
||||
chunk = handle.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def package() -> PackageLayout:
|
||||
version = read_version()
|
||||
ensure_assets()
|
||||
binary = ensure_release_binary()
|
||||
|
||||
DIST.mkdir(parents=True, exist_ok=True)
|
||||
artifact_name = f"sprimo-windows-x64-v{version}"
|
||||
zip_path = DIST / f"{artifact_name}.zip"
|
||||
checksum_path = DIST / f"{artifact_name}.zip.sha256"
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="sprimo-package-") as temp_dir:
|
||||
stage = Path(temp_dir) / artifact_name
|
||||
stage.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
shutil.copy2(binary, stage / "sprimo-app.exe")
|
||||
shutil.copytree(ASSETS_REL, stage / "assets", dirs_exist_ok=True)
|
||||
|
||||
readme = stage / "README.txt"
|
||||
readme.write_text(
|
||||
"Sprimo portable package\n"
|
||||
"Run: sprimo-app.exe\n"
|
||||
"Assets are expected at ./assets relative to the executable.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
archive_base = DIST / artifact_name
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
if checksum_path.exists():
|
||||
checksum_path.unlink()
|
||||
|
||||
shutil.make_archive(str(archive_base), "zip", root_dir=stage.parent, base_dir=stage.name)
|
||||
|
||||
checksum = sha256_file(zip_path)
|
||||
checksum_path.write_text(f"{checksum} {zip_path.name}\n", encoding="utf-8")
|
||||
return PackageLayout(version=version, zip_path=zip_path, checksum_path=checksum_path)
|
||||
|
||||
|
||||
def smoke() -> None:
|
||||
layout = package()
|
||||
print(f"package created: {layout.zip_path}")
|
||||
print(f"checksum file: {layout.checksum_path}")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="sprimo-smoke-") as temp_dir:
|
||||
unzip_dir = Path(temp_dir) / "unpacked"
|
||||
shutil.unpack_archive(layout.zip_path, unzip_dir)
|
||||
root_candidates = [p for p in unzip_dir.iterdir() if p.is_dir()]
|
||||
if not root_candidates:
|
||||
raise PackagingError("smoke failed: package root directory missing")
|
||||
pkg_root = root_candidates[0]
|
||||
|
||||
required = [
|
||||
pkg_root / "sprimo-app.exe",
|
||||
pkg_root / "assets" / "sprite-packs" / "default" / "manifest.json",
|
||||
pkg_root / "assets" / "sprite-packs" / "default" / "sprite.png",
|
||||
]
|
||||
missing = [path for path in required if not path.exists()]
|
||||
if missing:
|
||||
joined = ", ".join(str(path) for path in missing)
|
||||
raise PackagingError(f"smoke failed: expected files missing: {joined}")
|
||||
|
||||
print("smoke check passed: package structure is valid")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Sprimo Windows packaging helper")
|
||||
parser.add_argument("command", choices=["package", "smoke"], help="action to execute")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
if args.command == "package":
|
||||
layout = package()
|
||||
print(f"created: {layout.zip_path}")
|
||||
print(f"sha256: {layout.checksum_path}")
|
||||
else:
|
||||
smoke()
|
||||
return 0
|
||||
except PackagingError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
127
scripts/qa_validate.py
Normal file
127
scripts/qa_validate.py
Normal file
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate issue QA workflow artifacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ISSUES_DIR = ROOT / "issues"
|
||||
|
||||
REQUIRED_SECTIONS = (
|
||||
"Title",
|
||||
"Severity",
|
||||
"Environment",
|
||||
"Summary",
|
||||
"Reproduction Steps",
|
||||
"Expected Result",
|
||||
"Actual Result",
|
||||
"Root Cause Analysis",
|
||||
"Fix Plan",
|
||||
"Implementation Notes",
|
||||
"Verification",
|
||||
"Status History",
|
||||
"Closure",
|
||||
)
|
||||
|
||||
CLOSED_STATES = {"Verification Passed", "Closed"}
|
||||
|
||||
|
||||
def issue_files() -> list[Path]:
|
||||
return sorted(ISSUES_DIR.glob("issue[0-9]*.md"))
|
||||
|
||||
|
||||
def has_section(content: str, name: str) -> bool:
|
||||
pattern = rf"(?m)^##\s+{re.escape(name)}\s*$"
|
||||
return bool(re.search(pattern, content))
|
||||
|
||||
|
||||
def current_status(content: str) -> str | None:
|
||||
match = re.search(r"(?m)^- Current Status:\s*`?([^`\n]+)`?\s*$", content)
|
||||
return match.group(1).strip() if match else None
|
||||
|
||||
|
||||
def screenshot_refs(content: str) -> list[str]:
|
||||
pattern = r"issues/screenshots/[A-Za-z0-9._-]+\.png"
|
||||
return sorted(set(re.findall(pattern, content)))
|
||||
|
||||
|
||||
def has_before_ref(refs: list[str], issue_num: str) -> bool:
|
||||
legacy = f"issues/screenshots/issue{issue_num}.png"
|
||||
return any("-before-" in ref for ref in refs) or legacy in refs
|
||||
|
||||
|
||||
def has_after_ref(refs: list[str]) -> bool:
|
||||
return any("-after-" in ref for ref in refs)
|
||||
|
||||
|
||||
def command_check_present(content: str) -> bool:
|
||||
required = (
|
||||
"`cargo check --workspace`",
|
||||
"`cargo test --workspace`",
|
||||
"`just qa-validate`",
|
||||
)
|
||||
return all(token in content for token in required)
|
||||
|
||||
|
||||
def verify_issue(path: Path) -> list[str]:
|
||||
errors: list[str] = []
|
||||
content = path.read_text(encoding="utf-8")
|
||||
issue_num_match = re.search(r"issue([0-9]+)\.md$", path.name)
|
||||
issue_num = issue_num_match.group(1) if issue_num_match else "?"
|
||||
|
||||
for section in REQUIRED_SECTIONS:
|
||||
if not has_section(content, section):
|
||||
errors.append(f"{path}: missing section '## {section}'")
|
||||
|
||||
refs = screenshot_refs(content)
|
||||
for ref in refs:
|
||||
if not (ROOT / ref).exists():
|
||||
errors.append(f"{path}: missing screenshot file: {ref}")
|
||||
|
||||
if not has_before_ref(refs, issue_num):
|
||||
errors.append(
|
||||
f"{path}: missing before screenshot reference "
|
||||
"(use issueN-before-...png or legacy issueN.png)"
|
||||
)
|
||||
|
||||
status = current_status(content)
|
||||
if status in CLOSED_STATES and not has_after_ref(refs):
|
||||
errors.append(
|
||||
f"{path}: status '{status}' requires at least one after screenshot reference"
|
||||
)
|
||||
|
||||
if status in CLOSED_STATES and not command_check_present(content):
|
||||
errors.append(
|
||||
f"{path}: status '{status}' requires command checklist entries for "
|
||||
"cargo check, cargo test, and just qa-validate"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def main() -> int:
|
||||
files = issue_files()
|
||||
if not files:
|
||||
print("error: no issue files found under issues/", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
failures: list[str] = []
|
||||
for path in files:
|
||||
failures.extend(verify_issue(path))
|
||||
|
||||
if failures:
|
||||
for failure in failures:
|
||||
print(f"error: {failure}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"qa validation passed ({len(files)} issue file(s) checked)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
Reference in New Issue
Block a user