mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-21 16:34:21 +08:00
✨ feat(cli): 新增独立命令行与发布链
- 新增无头运行时及连接、查询、导出、批处理、审计和 MCP 命令 - 复用活动数据根、密文存储与跨进程锁,落实写入安全和取消语义 - 增加六平台 CLI 归档、独立校验和、Docker、npm 与 WinGet 分发 - 隔离 GUI/CLI 更新资产并强化 macOS 签名与公证门禁 - 补充并发、审计、事务及发布契约回归测试 Refs #902
This commit is contained in:
359
tools/cli-release-assets.test.py
Normal file
359
tools/cli-release-assets.test.py
Normal file
@@ -0,0 +1,359 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static contract checks for CLI release assets and distribution gates."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GITHUB_EXPRESSION = re.compile(r"\$\{\{.*?\}\}")
|
||||
|
||||
|
||||
def extract_workflow_run_script(source: str, step_name: str) -> str:
|
||||
"""Return one literal run block without requiring a YAML dependency."""
|
||||
lines = source.splitlines()
|
||||
marker = f"- name: {step_name}"
|
||||
matching_lines = [index for index, line in enumerate(lines) if line.strip() == marker]
|
||||
if len(matching_lines) != 1:
|
||||
raise AssertionError(
|
||||
f"expected exactly one workflow step named {step_name!r}, found {len(matching_lines)}"
|
||||
)
|
||||
|
||||
step_index = matching_lines[0]
|
||||
step_indent = len(lines[step_index]) - len(lines[step_index].lstrip())
|
||||
run_index = None
|
||||
for index in range(step_index + 1, len(lines)):
|
||||
line = lines[index]
|
||||
stripped = line.lstrip()
|
||||
indent = len(line) - len(stripped)
|
||||
if stripped and indent <= step_indent:
|
||||
break
|
||||
if stripped == "run: |":
|
||||
run_index = index
|
||||
break
|
||||
if run_index is None:
|
||||
raise AssertionError(f"workflow step {step_name!r} has no literal run block")
|
||||
|
||||
run_indent = len(lines[run_index]) - len(lines[run_index].lstrip())
|
||||
body: list[str] = []
|
||||
for line in lines[run_index + 1 :]:
|
||||
stripped = line.lstrip()
|
||||
indent = len(line) - len(stripped)
|
||||
if stripped and indent <= run_indent:
|
||||
break
|
||||
body.append(line)
|
||||
script = textwrap.dedent("\n".join(body)) + "\n"
|
||||
return GITHUB_EXPRESSION.sub("GITHUB_EXPRESSION", script)
|
||||
|
||||
|
||||
class CLIReleaseAssetsTest(unittest.TestCase):
|
||||
def test_stable_and_dev_workflows_publish_the_same_six_archive_names(self) -> None:
|
||||
expected_tokens = (
|
||||
"gonavi-cli_${version}_${{ matrix.goos }}_${{ matrix.goarch }}.${{ matrix.extension }}",
|
||||
'extension: tar.gz',
|
||||
'extension: zip',
|
||||
)
|
||||
for name in ("release.yml", "dev-build.yml"):
|
||||
source = (ROOT / ".github" / "workflows" / name).read_text(encoding="utf-8")
|
||||
for token in expected_tokens:
|
||||
self.assertIn(token, source, f"{token!r} missing from {name}")
|
||||
self.assertIn("github.com/rhysd/actionlint/cmd/actionlint@v1.7.12", source)
|
||||
self.assertIn(".github/workflows/publish-release.yml", source)
|
||||
self.assertIn("python3 tools/cli-release-assets.test.py", source)
|
||||
self.assertIn("python3 tools/validate-npm-cli-package-version.test.py", source)
|
||||
self.assertIn("CLI artifact set is invalid", source)
|
||||
self.assertIn("CLI release asset set is invalid", source)
|
||||
self.assertIn("CLI checksum file contents are invalid", source)
|
||||
self.assertIn("find cli-assets -type f -printf '%P\\n' | sort", source)
|
||||
self.assertIn("find cli-assets -maxdepth 1 -type f -name 'gonavi-cli_*'", source)
|
||||
self.assertIn('actual_entries="$(unzip -Z1 "$asset" | sort)"', source)
|
||||
self.assertIn('actual_entries="$(tar -tzf "$asset" | sed', source)
|
||||
self.assertIn('gonavi-cli_${version}_checksums.txt', source)
|
||||
self.assertIn('(cd cli-assets && sha256sum "${expected[@]}"', source)
|
||||
self.assertIn('sha256sum --check "$cli_checksum_name"', source)
|
||||
self.assertIn(
|
||||
'./tools/generate-driver-agent-revisions.sh --platform "${{ matrix.goos }}/${{ matrix.goarch }}"',
|
||||
source,
|
||||
)
|
||||
self.assertIn("--component gui", source)
|
||||
self.assertIn("--assets-dir release-assets", source)
|
||||
self.assertIn("release-assets/*", source)
|
||||
self.assertIn("cli-assets/*", source)
|
||||
self.assertNotIn(
|
||||
'install -m 0644 "cli-assets/${asset}" "release-assets/${asset}"',
|
||||
source,
|
||||
)
|
||||
self.assertNotIn("xattr -cr", source)
|
||||
if name == "dev-build.yml":
|
||||
self.assertIn('SHORT_SHA="${GITHUB_SHA:0:7}"', source)
|
||||
self.assertNotIn("git rev-parse --short HEAD", source)
|
||||
|
||||
def test_release_workflow_bash_blocks_are_syntax_checked(self) -> None:
|
||||
workflow_steps = {
|
||||
"release.yml": (
|
||||
"Build and package CLI",
|
||||
"Package macOS DMG",
|
||||
"Validate CLI artifact staging",
|
||||
"Generate CLI checksums",
|
||||
"Generate SHA256SUMS",
|
||||
"Verify CLI release assets",
|
||||
"Generate static update manifest (latest.json)",
|
||||
),
|
||||
"dev-build.yml": (
|
||||
"Build and package dev CLI",
|
||||
"Package macOS DMG",
|
||||
"Validate dev CLI artifact staging",
|
||||
"Generate dev CLI checksums",
|
||||
"Generate SHA256SUMS",
|
||||
"Verify dev CLI release assets",
|
||||
"Generate static update manifest (latest-dev.json)",
|
||||
),
|
||||
"publish-release.yml": (
|
||||
"Prepare and verify stable mirror payload",
|
||||
"Verify public CLI release assets for npm postinstall",
|
||||
"Publish npm CLI package",
|
||||
"Verify npm CLI package metadata",
|
||||
"Generate and retain WinGet CLI manifest",
|
||||
),
|
||||
}
|
||||
for workflow_name, step_names in workflow_steps.items():
|
||||
source = (ROOT / ".github" / "workflows" / workflow_name).read_text(encoding="utf-8")
|
||||
for step_name in step_names:
|
||||
script = extract_workflow_run_script(source, step_name)
|
||||
result = subprocess.run(
|
||||
["bash", "-n"],
|
||||
input=script,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(
|
||||
result.returncode,
|
||||
0,
|
||||
f"invalid bash in {workflow_name} step {step_name!r}:\n{result.stderr}",
|
||||
)
|
||||
|
||||
def test_gui_manifest_input_never_contains_cli_assets(self) -> None:
|
||||
workflow_steps = {
|
||||
"release.yml": (
|
||||
"Validate CLI artifact staging",
|
||||
"Generate static update manifest (latest.json)",
|
||||
),
|
||||
"dev-build.yml": (
|
||||
"Validate dev CLI artifact staging",
|
||||
"Generate static update manifest (latest-dev.json)",
|
||||
),
|
||||
}
|
||||
for workflow_name, (staging_step, manifest_step) in workflow_steps.items():
|
||||
source = (ROOT / ".github" / "workflows" / workflow_name).read_text(encoding="utf-8")
|
||||
staging_script = extract_workflow_run_script(source, staging_step)
|
||||
manifest_script = extract_workflow_run_script(source, manifest_step)
|
||||
|
||||
self.assertIn("cli-assets", staging_script)
|
||||
self.assertNotIn("release-assets", staging_script)
|
||||
self.assertIn("--assets-dir release-assets", manifest_script)
|
||||
self.assertNotIn("cli-assets", manifest_script)
|
||||
|
||||
def test_checksum_generation_keeps_cli_staging_separate(self) -> None:
|
||||
cases = (
|
||||
(
|
||||
"release.yml",
|
||||
"Generate CLI checksums",
|
||||
"1.2.3",
|
||||
{"GITHUB_REF_NAME": "v1.2.3"},
|
||||
),
|
||||
(
|
||||
"dev-build.yml",
|
||||
"Generate dev CLI checksums",
|
||||
"dev-a1b2c3d",
|
||||
{"GITHUB_SHA": "a1b2c3d4567890"},
|
||||
),
|
||||
)
|
||||
for workflow_name, checksum_step, version, extra_env in cases:
|
||||
source = (ROOT / ".github" / "workflows" / workflow_name).read_text(encoding="utf-8")
|
||||
checksum_script = extract_workflow_run_script(source, checksum_step)
|
||||
global_script = extract_workflow_run_script(source, "Generate SHA256SUMS")
|
||||
archives = (
|
||||
f"gonavi-cli_{version}_darwin_amd64.tar.gz",
|
||||
f"gonavi-cli_{version}_darwin_arm64.tar.gz",
|
||||
f"gonavi-cli_{version}_linux_amd64.tar.gz",
|
||||
f"gonavi-cli_{version}_linux_arm64.tar.gz",
|
||||
f"gonavi-cli_{version}_windows_amd64.zip",
|
||||
f"gonavi-cli_{version}_windows_arm64.zip",
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
gui_dir = root / "release-assets"
|
||||
cli_dir = root / "cli-assets"
|
||||
gui_dir.mkdir()
|
||||
cli_dir.mkdir()
|
||||
(gui_dir / f"GoNavi-{version}-Linux-Amd64.tar.gz").write_bytes(b"gui")
|
||||
for archive in archives:
|
||||
(cli_dir / archive).write_bytes(archive.encode("ascii"))
|
||||
|
||||
environment = os.environ.copy()
|
||||
environment.update(extra_env)
|
||||
for script in (checksum_script, global_script):
|
||||
result = subprocess.run(
|
||||
["bash"],
|
||||
input=script,
|
||||
cwd=root,
|
||||
env=environment,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(
|
||||
result.returncode,
|
||||
0,
|
||||
f"failed to execute {workflow_name} checksum script:\n{result.stderr}",
|
||||
)
|
||||
|
||||
cli_checksum_name = f"gonavi-cli_{version}_checksums.txt"
|
||||
self.assertTrue((cli_dir / cli_checksum_name).is_file())
|
||||
self.assertFalse((gui_dir / cli_checksum_name).exists())
|
||||
self.assertFalse(any(path.name.startswith("gonavi-cli_") for path in gui_dir.iterdir()))
|
||||
global_names = {
|
||||
line.split(maxsplit=1)[1]
|
||||
for line in (gui_dir / "SHA256SUMS").read_text(encoding="ascii").splitlines()
|
||||
}
|
||||
self.assertEqual(
|
||||
global_names,
|
||||
{
|
||||
f"GoNavi-{version}-Linux-Amd64.tar.gz",
|
||||
cli_checksum_name,
|
||||
*archives,
|
||||
},
|
||||
)
|
||||
|
||||
def test_stable_workflow_requires_notarized_production_signing(self) -> None:
|
||||
source = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
|
||||
for token in (
|
||||
"MACOS_SIGNING_CERTIFICATE_P12",
|
||||
"MACOS_SIGNING_IDENTITY",
|
||||
"Developer ID Application",
|
||||
"APPLE_TEAM_ID",
|
||||
"verify_team_identifier",
|
||||
"TeamIdentifier",
|
||||
"codesign --force --deep --options runtime --timestamp",
|
||||
"CFBundleShortVersionString",
|
||||
"CFBundleVersion",
|
||||
'codesign --force --timestamp --sign "$MACOS_SIGNING_IDENTITY" "$DMG_NAME"',
|
||||
'codesign --verify --deep --strict --verbose=4 "$APP_NAME"',
|
||||
'codesign --verify --verbose=4 "$DMG_NAME"',
|
||||
"xcrun notarytool submit",
|
||||
"--output-format json",
|
||||
'status != "Accepted"',
|
||||
"xcrun stapler staple",
|
||||
"xcrun stapler validate",
|
||||
'spctl -a -t exec -vv "$PACKAGED_APP"',
|
||||
"spctl --assess",
|
||||
'APP_PATH="./GoNavi.app"',
|
||||
'PACKAGED_APP="$VERIFY_MOUNT_DIR/GoNavi.app"',
|
||||
'PACKAGED_INFO_PLIST="$PACKAGED_APP/Contents/Info.plist"',
|
||||
):
|
||||
self.assertIn(token, source)
|
||||
self.assertLess(
|
||||
source.index('Set :CFBundleShortVersionString ${VERSION}'),
|
||||
source.index('codesign --force --deep --options runtime --timestamp'),
|
||||
)
|
||||
self.assertLess(
|
||||
source.index('codesign --force --timestamp --sign "$MACOS_SIGNING_IDENTITY" "$DMG_NAME"'),
|
||||
source.index("xcrun notarytool submit"),
|
||||
)
|
||||
self.assertLess(
|
||||
source.index("xcrun stapler validate"),
|
||||
source.rindex('codesign --verify --verbose=4 "$DMG_NAME"'),
|
||||
)
|
||||
|
||||
def test_dev_workflow_requires_fixed_dmg_bundle_name(self) -> None:
|
||||
source = (ROOT / ".github" / "workflows" / "dev-build.yml").read_text(encoding="utf-8")
|
||||
self.assertIn('APP_PATH="./GoNavi.app"', source)
|
||||
self.assertIn('PACKAGED_APP="$VERIFY_MOUNT_DIR/GoNavi.app"', source)
|
||||
self.assertIn("tools/validate-gui-update-manifest.py", source)
|
||||
self.assertIn("--channel dev", source)
|
||||
|
||||
def test_wails_config_has_a_non_default_product_version(self) -> None:
|
||||
config = json.loads((ROOT / "wails.json").read_text(encoding="utf-8"))
|
||||
product_version = config["info"]["productVersion"]
|
||||
self.assertRegex(product_version, r"^\d+\.\d+\.\d+$")
|
||||
self.assertNotEqual(product_version, "1.0.0")
|
||||
|
||||
def test_publish_workflow_keeps_cli_separate_from_gui_manifest(self) -> None:
|
||||
source = (ROOT / ".github" / "workflows" / "publish-release.yml").read_text(encoding="utf-8")
|
||||
self.assertIn("gonavi-cli_${version}_darwin_amd64.tar.gz", source)
|
||||
self.assertIn("gonavi-cli_${version}_checksums.txt", source)
|
||||
self.assertIn("CLI checksums disagree", source)
|
||||
self.assertIn("Release assets are not an exact contract", source)
|
||||
self.assertIn("CLI checksum file is missing from the GitHub Release", source)
|
||||
self.assertIn("CLI checksum file contents are invalid", source)
|
||||
self.assertIn("tools/validate-gui-update-manifest.py", source)
|
||||
self.assertIn("--channel stable", source)
|
||||
self.assertIn("const requiredAssets = [", source)
|
||||
self.assertIn("const optionalAssets = [", source)
|
||||
self.assertIn("GoNavi-${version}-Linux-Amd64.AppImage", source)
|
||||
self.assertIn("GoNavi-${version}-Linux-Amd64-WebKit41.AppImage", source)
|
||||
self.assertIn("const allowedAssetNames = new Set([...requiredAssets, ...optionalAssets])", source)
|
||||
self.assertNotIn("release.assets.length !== expectedAssets.length", source)
|
||||
self.assertIn("ref: ${{ steps.validate.outputs.tag }}", source)
|
||||
self.assertIn("NPM_TOKEN secret is required", source)
|
||||
self.assertIn("Validate npm publication credentials", source)
|
||||
self.assertIn("npm publish npm/gonavi-cli --access public --ignore-scripts", source)
|
||||
self.assertIn('npm view "@syngnat/gonavi-cli@${version}" --json', source)
|
||||
self.assertIn("Upload WinGet CLI manifest artifact", source)
|
||||
self.assertIn("actions/upload-artifact@v6", source)
|
||||
self.assertIn("winget-cli-manifest-${{ steps.validate.outputs.tag }}", source)
|
||||
self.assertLess(
|
||||
source.index("Verify GitHub release is published and latest"),
|
||||
source.index("Publish npm CLI package"),
|
||||
)
|
||||
self.assertLess(
|
||||
source.index("Verify npm CLI package metadata"),
|
||||
source.index("Mirror stable release to Gatewaysentry"),
|
||||
)
|
||||
|
||||
def test_stable_release_validates_npm_cli_version_before_build(self) -> None:
|
||||
source = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
|
||||
self.assertIn(
|
||||
'python3 tools/validate-npm-cli-package-version.py --tag "$GITHUB_REF_NAME"',
|
||||
source,
|
||||
)
|
||||
|
||||
def test_cli_container_uses_data_volume_and_help_entrypoint(self) -> None:
|
||||
dockerfile = (ROOT / "Dockerfile.cli").read_text(encoding="utf-8")
|
||||
compose = (ROOT / "docker-compose.cli.yml").read_text(encoding="utf-8")
|
||||
workflow = (ROOT / ".github" / "workflows" / "docker-images.yml").read_text(encoding="utf-8")
|
||||
self.assertIn('ENTRYPOINT ["/usr/local/bin/gonavi"]', dockerfile)
|
||||
self.assertIn('VOLUME ["/data"]', dockerfile)
|
||||
self.assertIn("GONAVI_DATA_ROOT=/data", dockerfile)
|
||||
self.assertIn("ARG VERSION=dev", dockerfile)
|
||||
self.assertIn('ARG TARGETOS', dockerfile)
|
||||
self.assertIn('ARG TARGETARCH', dockerfile)
|
||||
self.assertIn(
|
||||
'./tools/generate-driver-agent-revisions.sh --platform "${TARGETOS}/${TARGETARCH}"',
|
||||
dockerfile,
|
||||
)
|
||||
self.assertIn("GoNavi-Wails/internal/cli.Version=${VERSION}", dockerfile)
|
||||
self.assertIn("GONAVI_DATA_ROOT: /data", compose)
|
||||
self.assertIn("GONAVI_LOG_DIR: /data/logs", compose)
|
||||
self.assertIn("HOME: /data", compose)
|
||||
self.assertIn("GONAVI_CONTAINER_UID", compose)
|
||||
self.assertIn("GONAVI_CONTAINER_GID", compose)
|
||||
self.assertIn("VERSION=${{ steps.prep.outputs.version }}", workflow)
|
||||
self.assertIn('--user "$(id -u):$(id -g)"', workflow)
|
||||
self.assertIn("-e GONAVI_LOG_DIR=/data/logs", workflow)
|
||||
self.assertIn('> "$data_dir/connections.json"', workflow)
|
||||
self.assertIn('"id":"cli-docker-smoke"', workflow)
|
||||
self.assertNotIn("--data-root /data list-connections", workflow)
|
||||
self.assertNotIn('chmod 0777 "$data_dir"', workflow)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -47,6 +47,27 @@ SKIP_NAMES = {
|
||||
".DS_Store",
|
||||
}
|
||||
|
||||
# GitHub release assets share one flat namespace. Keep the desktop updater's
|
||||
# manifest deliberately narrow so headless CLI archives cannot become update
|
||||
# candidates merely because they were uploaded beside the GUI packages.
|
||||
GUI_ASSET_PATTERNS = (
|
||||
re.compile(r"^GoNavi-[A-Za-z0-9][A-Za-z0-9._-]*-MacOS-(?:Amd64|Arm64)\.dmg$"),
|
||||
re.compile(
|
||||
r"^GoNavi-[A-Za-z0-9][A-Za-z0-9._-]*-Windows-"
|
||||
r"(?:Amd64|Arm64)-(?:Installer\.msi|Portable\.(?:exe|zip))$"
|
||||
),
|
||||
re.compile(
|
||||
r"^GoNavi-[A-Za-z0-9][A-Za-z0-9._-]*-Linux-"
|
||||
r"(?:Amd64(?:-WebKit41)?\.(?:tar\.gz|AppImage)|Arm64\.tar\.gz)$"
|
||||
),
|
||||
)
|
||||
CLI_ASSET_PATTERNS = (
|
||||
re.compile(
|
||||
r"^gonavi-cli_[A-Za-z0-9][A-Za-z0-9.-]*_(?:darwin|linux)_(?:amd64|arm64)\.tar\.gz$"
|
||||
),
|
||||
re.compile(r"^gonavi-cli_[A-Za-z0-9][A-Za-z0-9.-]*_windows_(?:amd64|arm64)\.zip$"),
|
||||
)
|
||||
|
||||
|
||||
def load_release_notes(path: Path | None) -> str:
|
||||
if path is None:
|
||||
@@ -112,7 +133,29 @@ def collect_assets(
|
||||
hashes: dict[str, str],
|
||||
download_base_url: str = "",
|
||||
download_tag: str = "",
|
||||
component: str = "gui",
|
||||
version: str = "",
|
||||
) -> list[dict]:
|
||||
if component not in {"gui", "cli"}:
|
||||
raise ValueError(f"unsupported manifest component: {component}")
|
||||
# The release directory can contain assets from more than one build or a
|
||||
# stale file left by a previous job. The manifest must only describe the
|
||||
# exact version it was generated for, in addition to the component
|
||||
# allowlist.
|
||||
normalized_version = normalize_version(version) or normalize_version(tag)
|
||||
if not normalized_version:
|
||||
raise ValueError("manifest asset version is required")
|
||||
escaped_version = re.escape(normalized_version)
|
||||
if component == "gui":
|
||||
patterns = tuple(
|
||||
re.compile(pattern.pattern.replace("[A-Za-z0-9][A-Za-z0-9._-]*", escaped_version))
|
||||
for pattern in GUI_ASSET_PATTERNS
|
||||
)
|
||||
else:
|
||||
patterns = tuple(
|
||||
re.compile(pattern.pattern.replace("[A-Za-z0-9][A-Za-z0-9.-]*", escaped_version))
|
||||
for pattern in CLI_ASSET_PATTERNS
|
||||
)
|
||||
assets: list[dict] = []
|
||||
for path in sorted(assets_dir.iterdir()):
|
||||
if not path.is_file():
|
||||
@@ -122,6 +165,8 @@ def collect_assets(
|
||||
continue
|
||||
if name.startswith("."):
|
||||
continue
|
||||
if not any(pattern.fullmatch(name) for pattern in patterns):
|
||||
continue
|
||||
github_url = browser_download_url(tag, name)
|
||||
item = {
|
||||
"name": name,
|
||||
@@ -150,16 +195,26 @@ def build_manifest(
|
||||
download_base_url: str = "",
|
||||
download_tag: str = "",
|
||||
release_notes: str = "",
|
||||
component: str = "gui",
|
||||
) -> dict:
|
||||
hashes = parse_sha256sums(assets_dir / "SHA256SUMS")
|
||||
tag = tag.strip() or f"v{normalize_version(version)}"
|
||||
version = normalize_version(version) or normalize_version(tag)
|
||||
assets = collect_assets(assets_dir, tag, hashes, download_base_url, download_tag)
|
||||
assets = collect_assets(
|
||||
assets_dir,
|
||||
tag,
|
||||
hashes,
|
||||
download_base_url,
|
||||
download_tag,
|
||||
component,
|
||||
version,
|
||||
)
|
||||
if not assets:
|
||||
raise SystemExit(f"no release assets found under {assets_dir}")
|
||||
|
||||
payload = {
|
||||
"schemaVersion": SCHEMA_VERSION,
|
||||
"component": component,
|
||||
"channel": channel,
|
||||
"tagName": tag,
|
||||
"version": version,
|
||||
@@ -185,6 +240,12 @@ def main() -> int:
|
||||
default="latest",
|
||||
help="Update channel (default: latest)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--component",
|
||||
choices=("gui", "cli"),
|
||||
default="gui",
|
||||
help="Asset component to include (default: gui; CLI is not consumed by the desktop updater)",
|
||||
)
|
||||
parser.add_argument("--name", default="", help="Release display name")
|
||||
parser.add_argument("--published-at", default="", help="ISO8601 published time")
|
||||
parser.add_argument(
|
||||
@@ -237,6 +298,7 @@ def main() -> int:
|
||||
download_base_url=args.download_base_url,
|
||||
download_tag=args.download_tag,
|
||||
release_notes=release_notes,
|
||||
component=args.component,
|
||||
)
|
||||
output.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
notes_hint = f", notes={len(manifest.get('releaseNotes', ''))} chars" if manifest.get("releaseNotes") else ""
|
||||
|
||||
@@ -96,6 +96,107 @@ class GenerateUpdateLatestManifestTest(unittest.TestCase):
|
||||
self.assertNotIn("LICENSE", [a["name"] for a in data["assets"]])
|
||||
self.assertNotIn("NOTICE", [a["name"] for a in data["assets"]])
|
||||
|
||||
def test_gui_manifest_excludes_cli_archives_and_unrecognized_files(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
assets = Path(tmp)
|
||||
gui_name = "GoNavi-1.2.3-MacOS-Arm64.dmg"
|
||||
cli_name = "gonavi-cli_1.2.3_darwin_arm64.tar.gz"
|
||||
unexpected_name = "GoNavi-1.2.3-Linux-Amd64-unknown.bin"
|
||||
for name in (gui_name, cli_name, unexpected_name):
|
||||
(assets / name).write_bytes(name.encode("ascii"))
|
||||
(assets / "SHA256SUMS").write_text(
|
||||
f"{'a' * 64} {gui_name}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
out = assets / "latest.json"
|
||||
subprocess.check_call(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--assets-dir",
|
||||
str(assets),
|
||||
"--version",
|
||||
"1.2.3",
|
||||
"--tag",
|
||||
"v1.2.3",
|
||||
"--channel",
|
||||
"latest",
|
||||
"--component",
|
||||
"gui",
|
||||
"--output",
|
||||
str(out),
|
||||
],
|
||||
cwd=str(ROOT),
|
||||
)
|
||||
data = json.loads(out.read_text(encoding="utf-8"))
|
||||
self.assertEqual(data["component"], "gui")
|
||||
self.assertEqual([asset["name"] for asset in data["assets"]], [gui_name])
|
||||
self.assertEqual(data["assets"][0]["sha256"], "a" * 64)
|
||||
|
||||
def test_gui_manifest_excludes_assets_from_other_versions(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
assets = Path(tmp)
|
||||
current = "GoNavi-1.2.3-MacOS-Arm64.dmg"
|
||||
stale = "GoNavi-1.2.2-MacOS-Arm64.dmg"
|
||||
(assets / current).write_bytes(b"current")
|
||||
(assets / stale).write_bytes(b"stale")
|
||||
out = assets / "latest.json"
|
||||
subprocess.check_call(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--assets-dir",
|
||||
str(assets),
|
||||
"--version",
|
||||
"1.2.3",
|
||||
"--tag",
|
||||
"v1.2.3",
|
||||
"--channel",
|
||||
"latest",
|
||||
"--output",
|
||||
str(out),
|
||||
],
|
||||
cwd=str(ROOT),
|
||||
)
|
||||
data = json.loads(out.read_text(encoding="utf-8"))
|
||||
self.assertEqual(data["component"], "gui")
|
||||
self.assertEqual([asset["name"] for asset in data["assets"]], [current])
|
||||
|
||||
def test_cli_manifest_accepts_only_cli_archive_contract(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
assets = Path(tmp)
|
||||
cli_names = (
|
||||
"gonavi-cli_1.2.3_darwin_amd64.tar.gz",
|
||||
"gonavi-cli_1.2.3_windows_arm64.zip",
|
||||
)
|
||||
for name in cli_names:
|
||||
(assets / name).write_bytes(name.encode("ascii"))
|
||||
(assets / "gonavi-cli_1.2.3_linux_amd64.exe").write_bytes(b"invalid")
|
||||
(assets / "SHA256SUMS").write_text("", encoding="ascii")
|
||||
out = assets / "latest-cli.json"
|
||||
subprocess.check_call(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--assets-dir",
|
||||
str(assets),
|
||||
"--version",
|
||||
"1.2.3",
|
||||
"--tag",
|
||||
"v1.2.3",
|
||||
"--channel",
|
||||
"latest",
|
||||
"--component",
|
||||
"cli",
|
||||
"--output",
|
||||
str(out),
|
||||
],
|
||||
cwd=str(ROOT),
|
||||
)
|
||||
data = json.loads(out.read_text(encoding="utf-8"))
|
||||
self.assertEqual(data["component"], "cli")
|
||||
self.assertEqual([asset["name"] for asset in data["assets"]], list(cli_names))
|
||||
|
||||
def test_dev_manifest_keeps_github_tag_but_uses_unique_mirror_tag(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
assets = Path(tmp)
|
||||
|
||||
120
tools/generate-winget-cli-manifest.py
Normal file
120
tools/generate-winget-cli-manifest.py
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a WinGet manifest for the standalone GoNavi CLI.
|
||||
|
||||
The input is the independent CLI checksum file published with a stable
|
||||
release. No platform hash is accepted from command-line text, which keeps
|
||||
the generated manifest tied to the release's signed asset contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CLI_ASSETS = {
|
||||
"x64": "gonavi-cli_{version}_windows_amd64.zip",
|
||||
"arm64": "gonavi-cli_{version}_windows_arm64.zip",
|
||||
}
|
||||
CHECKSUM_NAME = "gonavi-cli_{version}_checksums.txt"
|
||||
BINARY_NAME = "gonavi.exe"
|
||||
VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$")
|
||||
CHECKSUM_RE = re.compile(r"^([0-9a-fA-F]{64})\s+\*?(.+)$")
|
||||
|
||||
|
||||
def load_checksums(path: Path, version: str) -> dict[str, str]:
|
||||
expected_checksum_name = CHECKSUM_NAME.format(version=version)
|
||||
if path.name != expected_checksum_name:
|
||||
raise ValueError(f"checksum file must be named {expected_checksum_name}")
|
||||
hashes: dict[str, str] = {}
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
match = CHECKSUM_RE.fullmatch(line)
|
||||
if not match:
|
||||
raise ValueError(f"invalid checksum line: {raw_line!r}")
|
||||
name = Path(match.group(2).strip()).name
|
||||
if name in hashes:
|
||||
raise ValueError(f"duplicate checksum entry: {name}")
|
||||
hashes[name] = match.group(1).lower()
|
||||
|
||||
required = {pattern.format(version=version) for pattern in CLI_ASSETS.values()}
|
||||
if set(hashes) != required | {
|
||||
f"gonavi-cli_{version}_darwin_amd64.tar.gz",
|
||||
f"gonavi-cli_{version}_darwin_arm64.tar.gz",
|
||||
f"gonavi-cli_{version}_linux_amd64.tar.gz",
|
||||
f"gonavi-cli_{version}_linux_arm64.tar.gz",
|
||||
}:
|
||||
raise ValueError("checksum file does not contain exactly the six CLI archives")
|
||||
return hashes
|
||||
|
||||
|
||||
def render_manifest(version: str, hashes: dict[str, str], repo: str) -> str:
|
||||
lines = [
|
||||
"# Generated by tools/generate-winget-cli-manifest.py; do not edit hashes by hand.",
|
||||
"PackageIdentifier: Syngnat.GoNavi.CLI",
|
||||
f"PackageVersion: {version}",
|
||||
"PackageLocale: en-US",
|
||||
"Publisher: Syngnat",
|
||||
"PublisherUrl: https://github.com/Syngnat",
|
||||
"PublisherSupportUrl: https://github.com/Syngnat/GoNavi/issues",
|
||||
"PackageName: GoNavi CLI",
|
||||
"PackageUrl: https://github.com/Syngnat/GoNavi",
|
||||
"License: Apache-2.0",
|
||||
"LicenseUrl: https://github.com/Syngnat/GoNavi/blob/main/LICENSE",
|
||||
"ShortDescription: Headless GoNavi database CLI",
|
||||
"Description: Run GoNavi queries, exports, batches, audit exports, and MCP without the desktop GUI.",
|
||||
"ReleaseNotesUrl: https://github.com/Syngnat/GoNavi/releases/tag/v" + version,
|
||||
"Installers:",
|
||||
]
|
||||
for architecture, pattern in CLI_ASSETS.items():
|
||||
asset = pattern.format(version=version)
|
||||
lines.extend(
|
||||
[
|
||||
f"- Architecture: {architecture}",
|
||||
" InstallerType: zip",
|
||||
" NestedInstallerType: portable",
|
||||
f" InstallerUrl: https://github.com/{repo}/releases/download/v{version}/{asset}",
|
||||
f" InstallerSha256: {hashes[asset]}",
|
||||
" NestedInstallerFiles:",
|
||||
f" - RelativeFilePath: {BINARY_NAME}",
|
||||
" PortableCommandAlias: gonavi",
|
||||
]
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
"ManifestType: singleton",
|
||||
"ManifestVersion: 1.9.0",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--version", required=True, help="stable semantic version, for example 0.9.3")
|
||||
parser.add_argument("--checksums", required=True, type=Path, help="independent CLI checksum file")
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
parser.add_argument("--repo", default="Syngnat/GoNavi")
|
||||
args = parser.parse_args()
|
||||
version = args.version.strip().removeprefix("v")
|
||||
if not VERSION_RE.fullmatch(version):
|
||||
parser.error(f"invalid stable version: {args.version}")
|
||||
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", args.repo):
|
||||
parser.error(f"invalid GitHub repository: {args.repo}")
|
||||
try:
|
||||
hashes = load_checksums(args.checksums, version)
|
||||
payload = render_manifest(version, hashes, args.repo)
|
||||
except (OSError, ValueError) as error:
|
||||
parser.error(str(error))
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(payload, encoding="utf-8")
|
||||
print(f"wrote {args.output} for GoNavi CLI {version}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
80
tools/generate-winget-cli-manifest.test.py
Normal file
80
tools/generate-winget-cli-manifest.test.py
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the checksum-driven WinGet CLI manifest generator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "tools" / "generate-winget-cli-manifest.py"
|
||||
SPEC = importlib.util.spec_from_file_location("generate_winget_cli_manifest", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class WinGetManifestTests(unittest.TestCase):
|
||||
version = "1.2.3"
|
||||
|
||||
def _write_checksums(self, directory: Path) -> Path:
|
||||
names = [
|
||||
f"gonavi-cli_{self.version}_darwin_amd64.tar.gz",
|
||||
f"gonavi-cli_{self.version}_darwin_arm64.tar.gz",
|
||||
f"gonavi-cli_{self.version}_linux_amd64.tar.gz",
|
||||
f"gonavi-cli_{self.version}_linux_arm64.tar.gz",
|
||||
f"gonavi-cli_{self.version}_windows_amd64.zip",
|
||||
f"gonavi-cli_{self.version}_windows_arm64.zip",
|
||||
]
|
||||
path = directory / f"gonavi-cli_{self.version}_checksums.txt"
|
||||
path.write_text("".join(f"{'a' * (64 - len(str(i)))}{i} {name}\n" for i, name in enumerate(names)), encoding="ascii")
|
||||
return path
|
||||
|
||||
def test_generates_both_windows_architectures_from_checksums(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
checksums = self._write_checksums(root)
|
||||
output = root / "Syngnat.GoNavi.CLI.yaml"
|
||||
result = subprocess.run(
|
||||
[
|
||||
"python3",
|
||||
str(SCRIPT),
|
||||
"--version",
|
||||
self.version,
|
||||
"--checksums",
|
||||
str(checksums),
|
||||
"--output",
|
||||
str(output),
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
manifest = output.read_text(encoding="utf-8")
|
||||
self.assertIn("PackageIdentifier: Syngnat.GoNavi.CLI", manifest)
|
||||
self.assertIn("PackageVersion: 1.2.3", manifest)
|
||||
self.assertIn("Architecture: x64", manifest)
|
||||
self.assertIn("Architecture: arm64", manifest)
|
||||
self.assertIn("gonavi-cli_1.2.3_windows_amd64.zip", manifest)
|
||||
self.assertIn("gonavi-cli_1.2.3_windows_arm64.zip", manifest)
|
||||
self.assertEqual(manifest.count("InstallerSha256:"), 2)
|
||||
self.assertEqual(manifest.count("NestedInstallerType: portable"), 2)
|
||||
self.assertIn("NestedInstallerFiles:", manifest)
|
||||
self.assertIn("PortableCommandAlias: gonavi", manifest)
|
||||
|
||||
def test_rejects_missing_or_extra_checksum_entries(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
checksums = self._write_checksums(root)
|
||||
checksums.write_text(checksums.read_text(encoding="ascii") + f"{'b' * 64} unexpected.zip\n", encoding="ascii")
|
||||
with self.assertRaises(ValueError):
|
||||
MODULE.load_checksums(checksums, self.version)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
118
tools/npm-cli-wrapper.test.py
Normal file
118
tools/npm-cli-wrapper.test.py
Normal file
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Contract tests for the npm GoNavi CLI wrapper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PACKAGE = ROOT / "npm" / "gonavi-cli"
|
||||
|
||||
|
||||
class NpmCLIWrapperTests(unittest.TestCase):
|
||||
def test_package_exposes_gonavi_and_verified_postinstall(self) -> None:
|
||||
package = json.loads((PACKAGE / "package.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(package["name"], "@syngnat/gonavi-cli")
|
||||
self.assertRegex(package["version"], r"^\d+\.\d+\.\d+$")
|
||||
self.assertEqual(package["bin"]["gonavi"], "bin/gonavi.js")
|
||||
self.assertEqual(package["scripts"]["postinstall"], "node install.js")
|
||||
|
||||
def test_installer_consumes_independent_checksums_and_fixed_archive(self) -> None:
|
||||
installer = (PACKAGE / "install.js").read_text(encoding="utf-8")
|
||||
launcher = (PACKAGE / "bin" / "gonavi.js").read_text(encoding="utf-8")
|
||||
for token in (
|
||||
"gonavi-cli_${version}_checksums.txt",
|
||||
"crypto.createHash('sha256')",
|
||||
"assertSha256(archive, expected, target.asset)",
|
||||
"archiveEntries(archivePath, target.extension, target.binary)",
|
||||
"expectedArchiveEntries(binary)",
|
||||
"'LICENSE'",
|
||||
"'NOTICE'",
|
||||
"GONAVI_CLI_RELEASE_BASE_URL",
|
||||
):
|
||||
self.assertIn(token, installer)
|
||||
self.assertIn("spawn(binaryPath", launcher)
|
||||
self.assertNotIn("SHA256SUMS", installer)
|
||||
|
||||
def test_node_sources_parse(self) -> None:
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
self.skipTest("node is not installed")
|
||||
for source in (PACKAGE / "install.js", PACKAGE / "bin" / "gonavi.js"):
|
||||
result = subprocess.run(
|
||||
[node, "--check", str(source)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_installer_rejects_symlink_and_hardlink_archive_members(self) -> None:
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
self.skipTest("node is not installed")
|
||||
if os.name == "nt":
|
||||
self.skipTest("creating symlinks is not consistently permitted on Windows runners")
|
||||
|
||||
script = r'''
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { validateExtractedArchive } = require(process.argv[1]);
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gonavi-cli-wrapper-test-'));
|
||||
const expected = ['gonavi', 'LICENSE', 'NOTICE'];
|
||||
|
||||
function populate(directory) {
|
||||
fs.mkdirSync(directory);
|
||||
for (const entry of expected) {
|
||||
fs.writeFileSync(path.join(directory, entry), entry);
|
||||
}
|
||||
}
|
||||
|
||||
function expectReject(directory, label) {
|
||||
try {
|
||||
validateExtractedArchive(directory, 'gonavi');
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`${label} archive member was accepted`);
|
||||
}
|
||||
|
||||
try {
|
||||
const regular = path.join(root, 'regular');
|
||||
populate(regular);
|
||||
validateExtractedArchive(regular, 'gonavi');
|
||||
|
||||
const symlink = path.join(root, 'symlink');
|
||||
populate(symlink);
|
||||
fs.rmSync(path.join(symlink, 'LICENSE'));
|
||||
fs.symlinkSync('gonavi', path.join(symlink, 'LICENSE'));
|
||||
expectReject(symlink, 'symlink');
|
||||
|
||||
const hardlink = path.join(root, 'hardlink');
|
||||
populate(hardlink);
|
||||
fs.rmSync(path.join(hardlink, 'LICENSE'));
|
||||
fs.linkSync(path.join(hardlink, 'gonavi'), path.join(hardlink, 'LICENSE'));
|
||||
expectReject(hardlink, 'hardlink');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
'''
|
||||
result = subprocess.run(
|
||||
[node, "-e", script, str(PACKAGE / "install.js")],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
278
tools/validate-gui-update-manifest.py
Normal file
278
tools/validate-gui-update-manifest.py
Normal file
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate a desktop GUI update manifest before it is published or mirrored.
|
||||
|
||||
The update manifest shares a GitHub Release namespace with the standalone CLI.
|
||||
This validator deliberately uses the generator's GUI filename allowlist again
|
||||
at the publication boundary, so an unexpected non-CLI asset cannot become a
|
||||
desktop updater candidate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
from urllib.parse import quote, urlsplit
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GENERATOR_PATH = ROOT / "tools" / "generate-update-latest-manifest.py"
|
||||
SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
|
||||
TAG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
STABLE_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+$")
|
||||
DEV_TAG_RE = re.compile(r"^dev-[0-9a-f]{7,40}$")
|
||||
REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
DEFAULT_MIRROR_BASES = {
|
||||
"stable": "https://download.syngnat.top/gonavi/releases/download",
|
||||
"dev": "https://download.syngnat.top/gonavi/dev/releases/download",
|
||||
}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate a GoNavi GUI update manifest and its local assets"
|
||||
)
|
||||
parser.add_argument("--channel", choices=("stable", "dev"), required=True)
|
||||
parser.add_argument("--app-tag", required=True)
|
||||
parser.add_argument("--app-dir", type=Path, required=True)
|
||||
parser.add_argument("--manifest", type=Path, required=True)
|
||||
parser.add_argument(
|
||||
"--github-repository",
|
||||
default="Syngnat/GoNavi",
|
||||
help="GitHub owner/repository used for manifest URLs",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mirror-base",
|
||||
default="",
|
||||
help="Override the expected mirror download base URL",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def load_object(path: Path, label: str) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
fail(f"unable to read {label} {path}: {exc}")
|
||||
if not isinstance(value, dict):
|
||||
fail(f"{label} must be a JSON object: {path}")
|
||||
return value
|
||||
|
||||
|
||||
def load_generator() -> ModuleType:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"gonavi_update_manifest_generator", GENERATOR_PATH
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
fail(f"unable to load GUI asset allowlist from {GENERATOR_PATH}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def normalize_version(generator: ModuleType, version: str) -> str:
|
||||
normalizer = getattr(generator, "normalize_version", None)
|
||||
if not callable(normalizer):
|
||||
fail("update manifest generator has no version normalizer")
|
||||
normalized = normalizer(version)
|
||||
if not isinstance(normalized, str) or not normalized:
|
||||
fail(f"invalid normalized GUI version: {version!r}")
|
||||
return normalized
|
||||
|
||||
|
||||
def gui_patterns_for_version(generator: ModuleType, version: str) -> tuple[re.Pattern[str], ...]:
|
||||
raw_patterns = getattr(generator, "GUI_ASSET_PATTERNS", None)
|
||||
if not isinstance(raw_patterns, tuple) or not raw_patterns:
|
||||
fail("update manifest generator has no GUI asset allowlist")
|
||||
escaped_version = re.escape(normalize_version(generator, version))
|
||||
placeholder = "[A-Za-z0-9][A-Za-z0-9._-]*"
|
||||
patterns: list[re.Pattern[str]] = []
|
||||
for raw_pattern in raw_patterns:
|
||||
source = getattr(raw_pattern, "pattern", None)
|
||||
if not isinstance(source, str) or placeholder not in source:
|
||||
fail("update manifest generator has an invalid GUI asset allowlist")
|
||||
patterns.append(re.compile(source.replace(placeholder, escaped_version)))
|
||||
return tuple(patterns)
|
||||
|
||||
|
||||
def validate_tag(channel: str, value: str) -> str:
|
||||
if not TAG_RE.fullmatch(value) or value in {".", ".."}:
|
||||
fail(f"invalid app tag: {value!r}")
|
||||
if channel == "stable" and not STABLE_TAG_RE.fullmatch(value):
|
||||
fail(f"invalid stable app tag: {value!r}")
|
||||
if channel == "dev" and not DEV_TAG_RE.fullmatch(value):
|
||||
fail(f"invalid dev app tag: {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def validate_repository(value: str) -> str:
|
||||
if not REPOSITORY_RE.fullmatch(value):
|
||||
fail(f"invalid GitHub repository: {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def validate_https_base(value: str, label: str) -> str:
|
||||
base = value.strip().rstrip("/")
|
||||
parsed = urlsplit(base)
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not parsed.netloc
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
fail(f"invalid {label}: {value!r}")
|
||||
return base
|
||||
|
||||
|
||||
def is_nonnegative_int(value: Any) -> bool:
|
||||
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
except OSError as exc:
|
||||
fail(f"unable to read GUI manifest asset {path}: {exc}")
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def validate_asset_name(
|
||||
name: Any,
|
||||
*,
|
||||
patterns: tuple[re.Pattern[str], ...],
|
||||
) -> str:
|
||||
if not isinstance(name, str) or not name:
|
||||
fail(f"invalid GUI manifest asset name: {name!r}")
|
||||
if Path(name).name != name or name in {".", ".."} or "/" in name or "\\" in name:
|
||||
fail(f"invalid GUI manifest asset name: {name!r}")
|
||||
if not any(pattern.fullmatch(name) for pattern in patterns):
|
||||
fail(f"not an allowed GUI release asset: {name}")
|
||||
return name
|
||||
|
||||
|
||||
def validate_manifest(
|
||||
*,
|
||||
channel: str,
|
||||
app_tag: str,
|
||||
app_dir: Path,
|
||||
manifest_name: str,
|
||||
manifest: dict[str, Any],
|
||||
repository: str,
|
||||
mirror_base: str,
|
||||
) -> int:
|
||||
generator = load_generator()
|
||||
version = normalize_version(generator, app_tag) if channel == "stable" else app_tag
|
||||
expected_channel = "latest" if channel == "stable" else "dev"
|
||||
expected_tag_name = app_tag if channel == "stable" else "dev-latest"
|
||||
expected_manifest_name = "latest.json" if channel == "stable" else "latest-dev.json"
|
||||
|
||||
if manifest_name != expected_manifest_name:
|
||||
fail(f"{channel} GUI manifest must be named {expected_manifest_name!r}")
|
||||
|
||||
if manifest.get("schemaVersion") != 1:
|
||||
fail("GUI manifest schemaVersion must be 1")
|
||||
if manifest.get("component") != "gui":
|
||||
fail("manifest component must be 'gui'")
|
||||
if manifest.get("channel") != expected_channel:
|
||||
if channel == "stable":
|
||||
fail("stable GUI manifest channel must be 'latest'")
|
||||
fail("dev GUI manifest channel must be 'dev'")
|
||||
if manifest.get("tagName") != expected_tag_name:
|
||||
fail(f"GUI manifest tagName must be {expected_tag_name!r}")
|
||||
if manifest.get("version") != version:
|
||||
fail(f"GUI manifest version must be {version!r}")
|
||||
|
||||
expected_html_url = f"https://github.com/{repository}/releases/tag/{expected_tag_name}"
|
||||
if manifest.get("htmlUrl") != expected_html_url:
|
||||
fail("GUI manifest htmlUrl does not match its release tag")
|
||||
|
||||
assets = manifest.get("assets")
|
||||
if not isinstance(assets, list) or not assets:
|
||||
fail("GUI manifest assets must be a non-empty array")
|
||||
if not app_dir.is_dir():
|
||||
fail(f"GUI manifest app directory does not exist: {app_dir}")
|
||||
|
||||
patterns = gui_patterns_for_version(generator, version)
|
||||
expected_url_prefix = f"{mirror_base}/{quote(app_tag, safe='')}/"
|
||||
expected_api_url_prefix = (
|
||||
f"https://github.com/{repository}/releases/download/"
|
||||
f"{quote(expected_tag_name, safe='')}/"
|
||||
)
|
||||
seen: set[str] = set()
|
||||
for entry in assets:
|
||||
if not isinstance(entry, dict):
|
||||
fail("GUI manifest asset entries must be objects")
|
||||
name = validate_asset_name(entry.get("name"), patterns=patterns)
|
||||
normalized_name = name.casefold()
|
||||
if normalized_name in seen:
|
||||
fail(f"duplicate GUI manifest asset: {name}")
|
||||
seen.add(normalized_name)
|
||||
|
||||
expected_url = expected_url_prefix + quote(name, safe="")
|
||||
if entry.get("url") != expected_url:
|
||||
fail(f"GUI manifest asset URL is invalid: {name}")
|
||||
expected_api_url = expected_api_url_prefix + quote(name, safe="")
|
||||
if entry.get("apiUrl") != expected_api_url:
|
||||
fail(f"GUI manifest asset API URL is invalid: {name}")
|
||||
|
||||
expected_size = entry.get("size")
|
||||
if not is_nonnegative_int(expected_size) or expected_size == 0:
|
||||
fail(f"GUI manifest asset size must be positive: {name}")
|
||||
expected_sha = entry.get("sha256")
|
||||
if not isinstance(expected_sha, str) or not SHA256_RE.fullmatch(expected_sha):
|
||||
fail(f"invalid GUI manifest asset sha256: {name}")
|
||||
|
||||
source = app_dir / name
|
||||
if not source.is_file():
|
||||
fail(f"GUI manifest asset is missing: {name}")
|
||||
if source.stat().st_size != expected_size:
|
||||
fail(f"GUI manifest asset size mismatch: {name}")
|
||||
if sha256_file(source) != expected_sha.lower():
|
||||
fail(f"GUI manifest asset sha256 mismatch: {name}")
|
||||
return len(assets)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
app_tag = validate_tag(args.channel, args.app_tag)
|
||||
repository = validate_repository(args.github_repository)
|
||||
mirror_base = validate_https_base(
|
||||
args.mirror_base or DEFAULT_MIRROR_BASES[args.channel],
|
||||
"mirror base URL",
|
||||
)
|
||||
manifest = load_object(args.manifest, "GUI manifest")
|
||||
asset_count = validate_manifest(
|
||||
channel=args.channel,
|
||||
app_tag=app_tag,
|
||||
app_dir=args.app_dir,
|
||||
manifest_name=args.manifest.name,
|
||||
manifest=manifest,
|
||||
repository=repository,
|
||||
mirror_base=mirror_base,
|
||||
)
|
||||
except ValueError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(
|
||||
f"validated {asset_count} GUI manifest asset(s) for "
|
||||
f"{args.channel} {args.app_tag}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
226
tools/validate-gui-update-manifest.test.py
Normal file
226
tools/validate-gui-update-manifest.test.py
Normal file
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "tools" / "validate-gui-update-manifest.py"
|
||||
PUBLISH_WORKFLOW = ROOT / ".github" / "workflows" / "publish-release.yml"
|
||||
DEV_WORKFLOW = ROOT / ".github" / "workflows" / "dev-build.yml"
|
||||
MIRROR_ACTION = ROOT / ".github" / "actions" / "publish-vps-mirror" / "action.yml"
|
||||
MIRROR_BASES = {
|
||||
"stable": "https://download.syngnat.top/gonavi/releases/download",
|
||||
"dev": "https://download.syngnat.top/gonavi/dev/releases/download",
|
||||
}
|
||||
GITHUB_BASE = "https://github.com/Syngnat/GoNavi/releases/download"
|
||||
|
||||
|
||||
def sha256(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
class ValidateGUIUpdateManifestTest(unittest.TestCase):
|
||||
def write_manifest(
|
||||
self,
|
||||
root: Path,
|
||||
*,
|
||||
channel: str,
|
||||
app_tag: str,
|
||||
asset_name: str,
|
||||
asset_bytes: bytes,
|
||||
) -> tuple[Path, Path]:
|
||||
app_dir = root / "app-assets"
|
||||
app_dir.mkdir()
|
||||
(app_dir / asset_name).write_bytes(asset_bytes)
|
||||
|
||||
if channel == "stable":
|
||||
manifest_channel = "latest"
|
||||
tag_name = app_tag
|
||||
version = app_tag.removeprefix("v")
|
||||
else:
|
||||
manifest_channel = "dev"
|
||||
tag_name = "dev-latest"
|
||||
version = app_tag
|
||||
|
||||
manifest = {
|
||||
"schemaVersion": 1,
|
||||
"component": "gui",
|
||||
"channel": manifest_channel,
|
||||
"tagName": tag_name,
|
||||
"version": version,
|
||||
"htmlUrl": f"https://github.com/Syngnat/GoNavi/releases/tag/{tag_name}",
|
||||
"assets": [
|
||||
{
|
||||
"name": asset_name,
|
||||
"url": f"{MIRROR_BASES[channel]}/{app_tag}/{asset_name}",
|
||||
"apiUrl": f"{GITHUB_BASE}/{tag_name}/{asset_name}",
|
||||
"size": len(asset_bytes),
|
||||
"sha256": sha256(asset_bytes),
|
||||
}
|
||||
],
|
||||
}
|
||||
manifest_path = root / ("latest.json" if channel == "stable" else "latest-dev.json")
|
||||
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
return app_dir, manifest_path
|
||||
|
||||
def run_validator(
|
||||
self,
|
||||
*,
|
||||
channel: str,
|
||||
app_tag: str,
|
||||
app_dir: Path,
|
||||
manifest_path: Path,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--channel",
|
||||
channel,
|
||||
"--app-tag",
|
||||
app_tag,
|
||||
"--app-dir",
|
||||
str(app_dir),
|
||||
"--manifest",
|
||||
str(manifest_path),
|
||||
"--github-repository",
|
||||
"Syngnat/GoNavi",
|
||||
],
|
||||
cwd=str(ROOT),
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
def test_accepts_stable_gui_manifest(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
app_dir, manifest_path = self.write_manifest(
|
||||
root,
|
||||
channel="stable",
|
||||
app_tag="v1.2.3",
|
||||
asset_name="GoNavi-1.2.3-MacOS-Arm64.dmg",
|
||||
asset_bytes=b"signed-dmg",
|
||||
)
|
||||
|
||||
result = self.run_validator(
|
||||
channel="stable",
|
||||
app_tag="v1.2.3",
|
||||
app_dir=app_dir,
|
||||
manifest_path=manifest_path,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_accepts_dev_gui_manifest(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
app_dir, manifest_path = self.write_manifest(
|
||||
root,
|
||||
channel="dev",
|
||||
app_tag="dev-a1b2c3d",
|
||||
asset_name="GoNavi-dev-a1b2c3d-Linux-Amd64.tar.gz",
|
||||
asset_bytes=b"linux-tarball",
|
||||
)
|
||||
|
||||
result = self.run_validator(
|
||||
channel="dev",
|
||||
app_tag="dev-a1b2c3d",
|
||||
app_dir=app_dir,
|
||||
manifest_path=manifest_path,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_rejects_stable_manifest_without_latest_channel(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
app_dir, manifest_path = self.write_manifest(
|
||||
root,
|
||||
channel="stable",
|
||||
app_tag="v1.2.3",
|
||||
asset_name="GoNavi-1.2.3-MacOS-Amd64.dmg",
|
||||
asset_bytes=b"signed-dmg",
|
||||
)
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest["channel"] = "dev"
|
||||
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
|
||||
result = self.run_validator(
|
||||
channel="stable",
|
||||
app_tag="v1.2.3",
|
||||
app_dir=app_dir,
|
||||
manifest_path=manifest_path,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("stable GUI manifest channel must be 'latest'", result.stderr)
|
||||
|
||||
def test_rejects_non_cli_asset_outside_gui_allowlist(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
app_dir, manifest_path = self.write_manifest(
|
||||
root,
|
||||
channel="stable",
|
||||
app_tag="v1.2.3",
|
||||
asset_name="GoNavi-1.2.3-SupportBundle.tar.gz",
|
||||
asset_bytes=b"not-a-desktop-package",
|
||||
)
|
||||
|
||||
result = self.run_validator(
|
||||
channel="stable",
|
||||
app_tag="v1.2.3",
|
||||
app_dir=app_dir,
|
||||
manifest_path=manifest_path,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("not an allowed GUI release asset", result.stderr)
|
||||
|
||||
def test_rejects_local_asset_hash_mismatch(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
app_dir, manifest_path = self.write_manifest(
|
||||
root,
|
||||
channel="stable",
|
||||
app_tag="v1.2.3",
|
||||
asset_name="GoNavi-1.2.3-Windows-Amd64-Portable.exe",
|
||||
asset_bytes=b"original-binary",
|
||||
)
|
||||
(app_dir / "GoNavi-1.2.3-Windows-Amd64-Portable.exe").write_bytes(
|
||||
b"tampered-binary"
|
||||
)
|
||||
|
||||
result = self.run_validator(
|
||||
channel="stable",
|
||||
app_tag="v1.2.3",
|
||||
app_dir=app_dir,
|
||||
manifest_path=manifest_path,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("GUI manifest asset sha256 mismatch", result.stderr)
|
||||
|
||||
def test_release_and_mirror_paths_run_the_validator(self) -> None:
|
||||
publish = PUBLISH_WORKFLOW.read_text(encoding="utf-8")
|
||||
dev = DEV_WORKFLOW.read_text(encoding="utf-8")
|
||||
mirror = MIRROR_ACTION.read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("tools/validate-gui-update-manifest.py", publish)
|
||||
self.assertIn("--channel stable", publish)
|
||||
self.assertIn("tools/validate-gui-update-manifest.py", dev)
|
||||
self.assertIn("--channel dev", dev)
|
||||
self.assertIn("tools/validate-gui-update-manifest.py", mirror)
|
||||
self.assertIn('--channel "${MIRROR_CHANNEL}"', mirror)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
65
tools/validate-npm-cli-package-version.py
Normal file
65
tools/validate-npm-cli-package-version.py
Normal file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate that a stable tag matches the npm CLI package version."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PACKAGE_NAME = "@syngnat/gonavi-cli"
|
||||
STABLE_TAG_RE = re.compile(r"^v(\d+\.\d+\.\d+)$")
|
||||
VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$")
|
||||
DEFAULT_PACKAGE_JSON = Path(__file__).resolve().parents[1] / "npm" / "gonavi-cli" / "package.json"
|
||||
|
||||
|
||||
def stable_version(tag: str) -> str:
|
||||
"""Return the semantic version represented by a stable ``v`` tag."""
|
||||
match = STABLE_TAG_RE.fullmatch(tag)
|
||||
if not match:
|
||||
raise ValueError(f"stable tag must match vX.Y.Z exactly: {tag!r}")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def validate_package_version(tag: str, package_json: Path = DEFAULT_PACKAGE_JSON) -> str:
|
||||
"""Validate the package identity and return its version."""
|
||||
expected_version = stable_version(tag)
|
||||
try:
|
||||
package = json.loads(package_json.read_text(encoding="utf-8"))
|
||||
except OSError as error:
|
||||
raise ValueError(f"cannot read npm package metadata: {package_json}: {error}") from error
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError(f"npm package metadata is not valid JSON: {package_json}: {error}") from error
|
||||
|
||||
if not isinstance(package, dict) or package.get("name") != PACKAGE_NAME:
|
||||
actual_name = package.get("name") if isinstance(package, dict) else None
|
||||
raise ValueError(f"npm package name must be {PACKAGE_NAME!r}, got {actual_name!r}")
|
||||
actual_version = package.get("version")
|
||||
if not isinstance(actual_version, str) or not VERSION_RE.fullmatch(actual_version):
|
||||
raise ValueError(f"npm package version must be X.Y.Z, got {actual_version!r}")
|
||||
if actual_version != expected_version:
|
||||
raise ValueError(
|
||||
f"npm package version {actual_version} does not match stable tag {tag} ({expected_version})"
|
||||
)
|
||||
return actual_version
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--tag", required=True, help="stable release tag, for example v0.9.3")
|
||||
parser.add_argument("--package-json", type=Path, default=DEFAULT_PACKAGE_JSON)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
version = validate_package_version(args.tag, args.package_json)
|
||||
except ValueError as error:
|
||||
print(f"npm CLI package version validation failed: {error}", file=sys.stderr)
|
||||
return 2
|
||||
print(f"npm CLI package {PACKAGE_NAME} matches stable tag {args.tag}: {version}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
53
tools/validate-npm-cli-package-version.test.py
Normal file
53
tools/validate-npm-cli-package-version.test.py
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the stable tag/npm CLI package version contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "tools" / "validate-npm-cli-package-version.py"
|
||||
SPEC = importlib.util.spec_from_file_location("validate_npm_cli_package_version", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class ValidateNpmCLIPackageVersionTests(unittest.TestCase):
|
||||
def test_current_package_matches_the_first_stable_cli_tag(self) -> None:
|
||||
package_json = ROOT / "npm" / "gonavi-cli" / "package.json"
|
||||
self.assertEqual(MODULE.validate_package_version("v0.9.3", package_json), "0.9.3")
|
||||
|
||||
def test_rejects_version_drift(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
package_json = Path(temporary) / "package.json"
|
||||
package_json.write_text(
|
||||
json.dumps({"name": MODULE.PACKAGE_NAME, "version": "0.9.2"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "does not match stable tag"):
|
||||
MODULE.validate_package_version("v0.9.3", package_json)
|
||||
|
||||
def test_rejects_non_stable_tags(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "stable tag must match"):
|
||||
MODULE.validate_package_version("dev-a1b2c3d", ROOT / "npm" / "gonavi-cli" / "package.json")
|
||||
|
||||
def test_cli_reports_failure_for_invalid_tag(self) -> None:
|
||||
result = subprocess.run(
|
||||
["python3", str(SCRIPT), "--tag", "v0.9.3-rc1"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("stable tag must match", result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user