feat(cli): 新增独立命令行与发布链

- 新增无头运行时及连接、查询、导出、批处理、审计和 MCP 命令
- 复用活动数据根、密文存储与跨进程锁,落实写入安全和取消语义
- 增加六平台 CLI 归档、独立校验和、Docker、npm 与 WinGet 分发
- 隔离 GUI/CLI 更新资产并强化 macOS 签名与公证门禁
- 补充并发、审计、事务及发布契约回归测试

Refs #902
This commit is contained in:
Syngnat
2026-08-11 10:34:58 +08:00
parent ff61a64179
commit 4d5c0e6bb9
87 changed files with 9905 additions and 517 deletions

View 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()

View File

@@ -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 ""

View File

@@ -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)

View 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())

View 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()

View 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()

View 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())

View 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()

View 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())

View 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()