diff --git a/.gitignore b/.gitignore index 7b3bbb7..812c333 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ __pycache__/ *.pyc *.pyo *.egg-info/ +dist/ .coverage .pytest_cache/ .mypy_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c2bf8c9..fe87947 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ All notable changes to Meti are documented here. Versions follow [Semantic Versi - Release readiness gate: `python scripts/check_release.py` validates version synchronization, plugin/marketplace metadata, private-path hygiene, wheel artifact contents, distribution docs, and no-network smoke behavior. +- Versioned release workflow: `release.json`, `scripts/release.py`, complete + GitHub Release artifacts, `scripts/install.sh`, and `meti update` support + latest stable or explicit `--version vX.Y.Z` installs with `--yes` for + reviewed automation. - Distribution docs for Claude Code local plugin install, future marketplace install, OpenClaw skill install, and reinstall-first upgrade guidance. - Marketplace submission packet covering descriptions, tags, install notes, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4e3d694..b54aa17 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,6 +77,22 @@ no-network smoke flow. It is draft-safe and account-free by default: it does not create live platform drafts, open a browser, read real credentials, or submit marketplace data. +`release.json` is the canonical publication source for versioned releases. +Use the semi-automatic release workflow so Python package metadata, SKILL +frontmatter, plugin metadata, marketplace metadata, artifacts, changelog, tag, +and GitHub Release stay in sync: + +```bash +python scripts/release.py prepare --version X.Y.Z --dry-run +python scripts/release.py build --dry-run +python scripts/release.py publish --version X.Y.Z --dry-run +``` + +After reviewing the dry-run output, run the same commands without `--dry-run`. +`publish` prints the target version, tag, artifact paths, `SHA256SUMS`, and +`gh release create` command before confirmation. Use `--yes` only in CI or an +already-reviewed scripted release. + ## What kind of changes are most welcome In rough priority order: @@ -121,7 +137,7 @@ platform artifacts in tracked files. - **Commit messages**: imperative present tense, scope-prefixed when it helps (`feat(substack):`, `fix(wechat-image):`, `docs:`, `refactor(browser):`). The recent git log is a good style reference. - **PRs** target `main`. Squash-merge by default. PR description should answer: what, why, how-verified. - **Tests required** for any code change. New providers ship with both unit tests and a real-account verification note. -- **Releases**: maintainers tag `vMAJOR.MINOR.PATCH` on `main` after CI green and `python scripts/check_release.py` passes; `pyproject.toml` version bump lands in the same PR as the user-visible feature. +- **Releases**: maintainers publish `vMAJOR.MINOR.PATCH` from `main` after CI green and `python scripts/check_release.py` passes; the `release.json` bump lands in the same PR as the user-visible feature. ### Prefer reinstall @@ -135,6 +151,19 @@ only; do not delete `~/.config/meti`, `~/.config/meti/credentials.json.age`, or Host-native update commands such as `git pull` are acceptable only when the local install path is known, clean, and verified. +For versioned installs, prefer: + +```bash +scripts/install.sh --latest --target ~/.openclaw/skills/meti +scripts/install.sh --version vX.Y.Z --target ~/.openclaw/skills/meti --yes +meti update --latest +meti update --version vX.Y.Z +``` + +`meti update` confirms interactively by default; `--yes` is for CI or reviewed +automation. Normal reinstall/update preserves `~/.config/meti`, +`~/.config/meti/credentials.json.age`, and `~/.config/meti/age-key.txt`. + ## Reporting bugs Open an issue with: diff --git a/README.md b/README.md index b89c4fd..37729d2 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,23 @@ The Claude Code marketplace submission is pending. Once Meti is listed, the inst git clone https://github.com/Nowhitestar/meti.git ~/.openclaw/skills/meti ``` +**Versioned release install / update** + +```bash +# latest stable from GitHub Releases +scripts/install.sh --latest --target ~/.openclaw/skills/meti + +# pinned install or rollback +scripts/install.sh --version vX.Y.Z --target ~/.openclaw/skills/meti --yes + +# from an existing checkout/install +meti update --latest +meti update --version vX.Y.Z +``` + +Normal reinstall/update preserves `~/.config/meti`, +`~/.config/meti/credentials.json.age`, and `~/.config/meti/age-key.txt`. + **Direct CLI** (Python ≥ 3.10) ```bash diff --git a/core/release.py b/core/release.py new file mode 100644 index 0000000..2ac3144 --- /dev/null +++ b/core/release.py @@ -0,0 +1,86 @@ +"""Release manifest helpers for Meti. + +This module intentionally avoids provider imports and credential reads. It only +parses the checked-in release manifest used by release tooling. +""" + +from __future__ import annotations + +import json +import re +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +RELEASE_MANIFEST = "release.json" +REQUIRED_ARTIFACT_KINDS = { + "wheel", + "sdist", + "claude-plugin-zip", + "openclaw-skill-zip", + "checksums", + "release-manifest", +} +REQUIRED_HOSTS = {"cli", "claude-plugin", "openclaw-skill"} +STRICT_SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") + + +def load_release_manifest(path: str | Path = RELEASE_MANIFEST) -> dict[str, Any]: + """Load a release manifest from a file or project directory.""" + + manifest_path = Path(path) + if manifest_path.is_dir(): + manifest_path = manifest_path / RELEASE_MANIFEST + return json.loads(manifest_path.read_text(encoding="utf-8")) + + +def validate_release_manifest(manifest: Mapping[str, Any]) -> list[str]: + """Return validation errors for the canonical release manifest.""" + + errors: list[str] = [] + version = manifest.get("version") + tag = manifest.get("tag") + channel = manifest.get("channel") + semver_policy = manifest.get("semver_policy") + + if not isinstance(version, str) or not STRICT_SEMVER_RE.match(version): + errors.append("version must be strict SemVer X.Y.Z") + if isinstance(version, str) and tag != f"v{version}": + errors.append("tag must equal v") + if channel != "stable": + errors.append("channel must be stable") + if semver_policy != "strict": + errors.append("semver_policy must be strict") + + compatibility = manifest.get("compatibility") + if not isinstance(compatibility, dict): + errors.append("compatibility must be an object") + else: + if compatibility.get("python") != ">=3.10": + errors.append("compatibility.python must be >=3.10") + for host in sorted(REQUIRED_HOSTS): + if not isinstance(compatibility.get(host), str) or not compatibility.get(host): + errors.append(f"compatibility.{host} must be set") + + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, list) or not artifacts: + errors.append("artifacts must be a non-empty list") + else: + seen: set[str] = set() + for index, artifact in enumerate(artifacts): + if not isinstance(artifact, dict): + errors.append(f"artifacts[{index}] must be an object") + continue + kind = artifact.get("kind") + filename = artifact.get("filename") + if not isinstance(kind, str) or not kind: + errors.append(f"artifacts[{index}].kind must be set") + else: + seen.add(kind) + if not isinstance(filename, str) or not filename: + errors.append(f"artifacts[{index}].filename must be set") + missing = sorted(REQUIRED_ARTIFACT_KINDS - seen) + if missing: + errors.append(f"artifacts missing kinds: {', '.join(missing)}") + + return errors diff --git a/docs/distribution.md b/docs/distribution.md index a0c5038..6ad08ae 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -46,6 +46,37 @@ python3 -m venv .venv Use a virtualenv by default. It keeps runtime dependencies such as `pyrage` and `tomli_w` isolated from system Python package-management rules. +## Versioned release install + +Versioned installs use GitHub Release artifacts. Default updates target the +latest stable release; use `--version vX.Y.Z` for pinning, rollback, or a +controlled rollout. + +```bash +# latest stable +scripts/install.sh --latest --target ~/.openclaw/skills/meti + +# explicit version +scripts/install.sh --version vX.Y.Z --target ~/.openclaw/skills/meti --yes +``` + +The installer downloads the versioned release asset, `release.json`, and +`SHA256SUMS` from GitHub Releases, verifies the selected artifact checksum, and +replaces only the install target files. It does not delete `~/.config/meti`, +`~/.config/meti/credentials.json.age`, or `~/.config/meti/age-key.txt`. + +Existing installs can use the CLI wrapper: + +```bash +meti update --latest +meti update --version vX.Y.Z +meti update --version vX.Y.Z --yes +``` + +Without `--yes`, `meti update` prints the current version, target version, +install path, planned `scripts/install.sh` command, and preserved config paths +before asking for confirmation. + ## Prefer reinstall For existing Claude Code plugin or OpenClaw skill installs, Prefer reinstall @@ -95,11 +126,23 @@ marketplace submission material: python scripts/check_release.py ``` -The release gate is draft-safe and account-free by default. It checks version -synchronization, plugin and marketplace metadata, tracked private paths, -generated wheel contents, install docs, and the no-network smoke flow. It does -not create live platform drafts, open a browser, read real credentials, submit -marketplace data, or publish anything publicly. +The release gate is draft-safe and account-free by default. It checks +`release.json` version synchronization, plugin and marketplace metadata, tracked +private paths, generated wheel/release bundle contents, install docs, and the +no-network smoke flow. It does not create live platform drafts, open a browser, +read real credentials, submit marketplace data, or publish anything publicly. + +Release maintainers build and publish with dry-run-first commands: + +```bash +python scripts/release.py prepare --version X.Y.Z --dry-run +python scripts/release.py build --dry-run +python scripts/release.py publish --version X.Y.Z --dry-run +``` + +Confirmed publish creates the `vX.Y.Z` tag and published GitHub Release only +after the maintainer approves the printed target version, artifact list, +checksums, and `gh release create` command. ## Private artifacts diff --git a/pyproject.toml b/pyproject.toml index 9a7565d..bda59b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ dev = [ # See docs/browser-connectors.md for details. [tool.setuptools.packages.find] -include = ["core*", "providers*"] +include = ["core*", "providers*", "scripts*"] [tool.ruff] line-length = 100 diff --git a/release.json b/release.json new file mode 100644 index 0000000..daef6c7 --- /dev/null +++ b/release.json @@ -0,0 +1,38 @@ +{ + "version": "0.4.3", + "tag": "v0.4.3", + "channel": "stable", + "semver_policy": "strict", + "compatibility": { + "python": ">=3.10", + "cli": ">=0.4.3 <1.0.0", + "claude-plugin": ">=0.4.3 <1.0.0", + "openclaw-skill": ">=0.4.3 <1.0.0" + }, + "artifacts": [ + { + "kind": "wheel", + "filename": "meti-0.4.3-py3-none-any.whl" + }, + { + "kind": "sdist", + "filename": "meti-0.4.3.tar.gz" + }, + { + "kind": "claude-plugin-zip", + "filename": "meti-claude-plugin-v0.4.3.zip" + }, + { + "kind": "openclaw-skill-zip", + "filename": "meti-openclaw-skill-v0.4.3.zip" + }, + { + "kind": "checksums", + "filename": "SHA256SUMS" + }, + { + "kind": "release-manifest", + "filename": "release.json" + } + ] +} diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..1be08b1 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Meti helper scripts packaged for the console entry point.""" diff --git a/scripts/check_release.py b/scripts/check_release.py index b20e73f..4f93566 100644 --- a/scripts/check_release.py +++ b/scripts/check_release.py @@ -10,6 +10,7 @@ import shutil import subprocess import sys +import tarfile import tempfile import zipfile from dataclasses import dataclass @@ -23,6 +24,7 @@ VERSION_FILES = { + "release": Path("release.json"), "pyproject": Path("pyproject.toml"), "skill": Path("SKILL.md"), "plugin": Path(".claude-plugin/plugin.json"), @@ -54,6 +56,7 @@ DENIED_PATH_PATTERNS = ( ".env", ".env.*", + ".DS_Store", "*.age", "*.local.md", "*.pyc", @@ -61,6 +64,7 @@ ) REQUIRED_GITIGNORE_ENTRIES = ( + "dist/", "runs/", ".env", ".env.*", @@ -116,15 +120,22 @@ "README.md": ( "Claude Code marketplace submission is pending", "/plugin install meti", + "meti update --version vX.Y.Z", ), "CONTRIBUTING.md": ( "Prefer reinstall", "python scripts/check_release.py", + "release.json", + "scripts/release.py", + "meti update --version vX.Y.Z", ), "docs/distribution.md": ( "git clone https://github.com/Nowhitestar/meti.git ~/.openclaw/skills/meti", "Prefer reinstall", "python scripts/check_release.py", + "scripts/install.sh --version vX.Y.Z", + "meti update --version vX.Y.Z", + "~/.config/meti/credentials.json.age", ), "docs/marketplace-submission.md": ( "Short description", @@ -197,6 +208,7 @@ def load_versions(project_root: Path) -> dict[str, str]: """Load all public distribution version surfaces.""" root = project_root.resolve() + release = _read_json(root / VERSION_FILES["release"]) pyproject = tomllib.loads((root / VERSION_FILES["pyproject"]).read_text(encoding="utf-8")) plugin = _read_json(root / VERSION_FILES["plugin"]) marketplace = _read_json(root / VERSION_FILES["marketplace"]) @@ -205,6 +217,7 @@ def load_versions(project_root: Path) -> dict[str, str]: except (KeyError, IndexError, TypeError) as exc: raise ValueError(".claude-plugin/marketplace.json plugins[0].version missing") from exc return { + "release": str(release["version"]), "pyproject": str(pyproject["project"]["version"]), "skill": _skill_frontmatter_version(root / VERSION_FILES["skill"]), "plugin": str(plugin["version"]), @@ -218,17 +231,17 @@ def check_version_sync(project_root: Path) -> CheckResult: except Exception as exc: return CheckResult("version-sync", False, str(exc)) - canonical = versions["pyproject"] + canonical = versions["release"] drift = [ f"{VERSION_FILES[name]}={version}" for name, version in versions.items() - if version != canonical + if name != "release" and version != canonical ] if drift: return CheckResult( "version-sync", False, - f"pyproject.toml={canonical}; drift: {', '.join(drift)}", + f"release.json={canonical}; drift: {', '.join(drift)}", ) surfaces = ", ".join(f"{name}={version}" for name, version in versions.items()) return CheckResult("version-sync", True, surfaces) @@ -406,7 +419,9 @@ def _python_can_build(candidate: Path) -> bool: def _build_python_candidates() -> list[Path]: candidates: list[Path] = [Path(sys.executable)] for raw in ( + shutil.which("python"), shutil.which("python3"), + "/opt/anaconda3/bin/python", "/opt/homebrew/opt/python@3.12/bin/python3.12", "/opt/homebrew/opt/python@3.11/bin/python3.11", "/opt/homebrew/opt/python@3.10/bin/python3.10", @@ -427,6 +442,33 @@ def _build_python_candidates() -> list[Path]: return unique +def _python_can_smoke(candidate: Path) -> bool: + proc = subprocess.run( + [ + str(candidate), + "-c", + ( + "import importlib.util, sys; " + "ok = sys.version_info >= (3, 10) " + "and importlib.util.find_spec('yaml') " + "and importlib.util.find_spec('pyrage'); " + "raise SystemExit(0 if ok else 1)" + ), + ], + capture_output=True, + text=True, + check=False, + ) + return proc.returncode == 0 + + +def _smoke_python() -> Path | None: + for candidate in _build_python_candidates(): + if _python_can_smoke(candidate): + return candidate + return None + + def build_wheel(project_root: Path, wheel_dir: Path) -> Path: """Build one wheel into wheel_dir and return its path.""" @@ -476,9 +518,52 @@ def scan_archive_members(members: list[str]) -> list[str]: return denied_paths(members) +def archive_member_names(archive_path: Path) -> list[str]: + """Read member names from supported release archives.""" + + name = archive_path.name + if name.endswith((".whl", ".zip")): + with zipfile.ZipFile(archive_path) as archive: + return archive.namelist() + if name.endswith((".tar.gz", ".tgz")): + with tarfile.open(archive_path, "r:gz") as archive: + return archive.getnames() + return [] + + +def scan_archive_path(archive_path: Path) -> list[str]: + return scan_archive_members(archive_member_names(archive_path)) + + def scan_wheel_for_private_paths(wheel_path: Path) -> list[str]: + return scan_archive_path(wheel_path) + + +def wheel_has_console_script_target(wheel_path: Path) -> bool: with zipfile.ZipFile(wheel_path) as archive: - return scan_archive_members(archive.namelist()) + return "scripts/meti.py" in archive.namelist() + + +def scan_release_bundle_for_private_paths(bundle_dir: Path) -> dict[str, list[str]]: + """Scan every release bundle artifact for denied paths.""" + + denied_by_artifact: dict[str, list[str]] = {} + if not bundle_dir.exists(): + return denied_by_artifact + + for path in sorted(bundle_dir.iterdir()): + if path.name == "SHA256SUMS": + continue + if path.is_dir(): + continue + denied: list[str] + if path.name.endswith((".whl", ".zip", ".tar.gz", ".tgz")): + denied = scan_archive_path(path) + else: + denied = [path.name] if path_is_denied(path.name) else [] + if denied: + denied_by_artifact[path.name] = denied + return denied_by_artifact def check_artifact_hygiene(project_root: Path) -> CheckResult: @@ -486,11 +571,30 @@ def check_artifact_hygiene(project_root: Path) -> CheckResult: with tempfile.TemporaryDirectory(prefix="meti-release-wheel-") as tmp: wheel = build_wheel(project_root, Path(tmp)) denied = scan_wheel_for_private_paths(wheel) + has_console_target = wheel_has_console_script_target(wheel) except Exception as exc: return CheckResult("artifacts", False, str(exc)) if denied: return CheckResult("artifacts", False, f"wheel includes private paths: {', '.join(denied)}") - return CheckResult("artifacts", True, "wheel artifact private-path scan ok") + if not has_console_target: + return CheckResult("artifacts", False, "wheel missing scripts/meti.py console target") + + release_root = project_root / "dist" / "releases" + bundle_failures: dict[str, list[str]] = {} + if release_root.exists(): + for bundle_dir in sorted(path for path in release_root.iterdir() if path.is_dir()): + for artifact, paths in scan_release_bundle_for_private_paths(bundle_dir).items(): + bundle_failures[f"{bundle_dir.name}/{artifact}"] = paths + if bundle_failures: + details = "; ".join( + f"{artifact}: {', '.join(paths)}" for artifact, paths in bundle_failures.items() + ) + return CheckResult("artifacts", False, f"release bundle includes private paths: {details}") + return CheckResult( + "artifacts", + True, + "wheel and release bundle artifact private-path scans ok", + ) def check_docs_install(project_root: Path) -> CheckResult: @@ -513,6 +617,9 @@ def check_smoke(project_root: Path) -> CheckResult: script = project_root / "scripts" / "test_local.py" if not script.exists(): return CheckResult("smoke", False, "scripts/test_local.py missing") + python = _smoke_python() + if python is None: + return CheckResult("smoke", False, "no local Python has yaml and pyrage installed") try: with tempfile.TemporaryDirectory(prefix="meti-release-smoke-") as tmp: tmp_path = Path(tmp) @@ -521,11 +628,10 @@ def check_smoke(project_root: Path) -> CheckResult: { "METI_RUNS_DIR": str(tmp_path / "runs"), "XDG_CONFIG_HOME": str(tmp_path / "xdg"), - "HOME": str(tmp_path / "home"), } ) proc = subprocess.run( - [sys.executable, str(script)], + [str(python), str(script)], cwd=project_root, env=env, capture_output=True, diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..de2d085 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO="${METI_RELEASE_REPO:-Nowhitestar/meti}" +VERSION="" +LATEST=1 +TARGET="$(pwd)" +YES=0 +DRY_RUN=0 +VERSION_RE='^v[0-9]+\.[0-9]+\.[0-9]+$' + +usage() { + cat <<'EOF' +Usage: scripts/install.sh [options] + +Options: + --version vX.Y.Z Install an explicit GitHub Release version. + --latest Install the latest stable GitHub Release (default). + --target DIR Install into DIR (default: current directory). + --yes Skip confirmation. + --dry-run Print the plan without downloading or modifying files. + --help Show this help. + +Normal reinstall/update preserves: + ~/.config/meti + ~/.config/meti/credentials.json.age + ~/.config/meti/age-key.txt +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --version) + VERSION="${2:-}" + LATEST=0 + shift 2 + ;; + --latest) + VERSION="" + LATEST=1 + shift + ;; + --target) + TARGET="${2:-}" + shift 2 + ;; + --yes) + YES=1 + shift + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "ERROR: unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [ -z "$TARGET" ]; then + echo "ERROR: --target requires a directory" >&2 + exit 2 +fi + +TARGET="$(python3 -c 'import pathlib, sys; print(pathlib.Path(sys.argv[1]).expanduser().resolve(strict=False))' "$TARGET")" +HOME_DIR="$(python3 -c 'import pathlib; print(pathlib.Path.home().resolve(strict=False))')" +case "$TARGET" in + "/"|"${HOME_DIR}"|"${HOME_DIR}/.config"|"${HOME_DIR}/.config/meti") + echo "ERROR: refusing unsafe install target: ${TARGET}" >&2 + exit 2 + ;; +esac + +resolve_latest() { + curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | + python3 -c 'import json, sys; print(json.load(sys.stdin)["tag_name"])' +} + +if [ "$LATEST" -eq 1 ]; then + if [ "$DRY_RUN" -eq 1 ]; then + TARGET_VERSION="latest stable" + RELEASE_URL="https://github.com/${REPO}/releases/latest/download" + ASSET="meti-claude-plugin-vX.Y.Z.zip" + else + TARGET_VERSION="$(resolve_latest)" + RELEASE_URL="https://github.com/${REPO}/releases/download/${TARGET_VERSION}" + ASSET="meti-claude-plugin-${TARGET_VERSION}.zip" + fi +else + TARGET_VERSION="$VERSION" + if ! printf '%s\n' "$TARGET_VERSION" | grep -Eq "$VERSION_RE"; then + echo "ERROR: --version must be vX.Y.Z, got: ${TARGET_VERSION}" >&2 + exit 2 + fi + RELEASE_URL="https://github.com/${REPO}/releases/download/${TARGET_VERSION}" + ASSET="meti-claude-plugin-${TARGET_VERSION}.zip" +fi + +echo "Meti installer" +echo "target version: ${TARGET_VERSION}" +echo "install target: ${TARGET}" +echo "release asset: ${RELEASE_URL}/${ASSET}" +echo "checksums: ${RELEASE_URL}/SHA256SUMS" +echo "release manifest: ${RELEASE_URL}/release.json" +echo "preserve user config: ~/.config/meti, ~/.config/meti/credentials.json.age, ~/.config/meti/age-key.txt" + +if [ "$DRY_RUN" -eq 1 ]; then + echo "dry-run: no files will be downloaded or modified" + exit 0 +fi + +if [ "$YES" -ne 1 ]; then + printf "Install Meti %s into %s? Type 'yes' to continue: " "$TARGET_VERSION" "$TARGET" + read -r ANSWER + if [ "$ANSWER" != "yes" ]; then + echo "Install cancelled." + exit 1 + fi +fi + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +cd "$TMP_DIR" +curl -fsSLO "${RELEASE_URL}/SHA256SUMS" +curl -fsSLO "${RELEASE_URL}/release.json" +curl -fsSLO "${RELEASE_URL}/${ASSET}" +grep " ${ASSET}$" SHA256SUMS | shasum -a 256 -c - + +STAGING="${TMP_DIR}/staging" +mkdir -p "$STAGING" +unzip -q "$ASSET" -d "$STAGING" + +mkdir -p "$TARGET" +for item in assets core providers scripts docs examples .claude-plugin pyproject.toml SKILL.md README.md CHANGELOG.md CONTRIBUTING.md CODE_OF_CONDUCT.md LICENSE release.json; do + rm -rf "${TARGET:?}/${item}" + if [ -e "${STAGING}/${item}" ]; then + cp -R "${STAGING}/${item}" "${TARGET}/${item}" + fi +done + +echo "OK: installed Meti ${TARGET_VERSION} into ${TARGET}" +echo "User config preserved under ~/.config/meti" diff --git a/scripts/meti.py b/scripts/meti.py index 15a1edd..799404b 100755 --- a/scripts/meti.py +++ b/scripts/meti.py @@ -8,6 +8,8 @@ import argparse import json +import re +import subprocess import sys from pathlib import Path from typing import Any @@ -15,6 +17,8 @@ ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) +VERSION_TAG_RE = re.compile(r"^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") + def _build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(prog="meti", description="Meti — one manifest, every platform") @@ -61,6 +65,13 @@ def _build_parser() -> argparse.ArgumentParser: help="For browser-backed drafts, attempt one bounded bind/open/retry readiness recovery.", ) + sub_update = sub.add_parser("update", help="Update this Meti install from GitHub Releases") + target = sub_update.add_mutually_exclusive_group() + target.add_argument("--version", help="Install an explicit version, e.g. v0.4.3") + target.add_argument("--latest", action="store_true", help="Install the latest stable release") + sub_update.add_argument("--yes", action="store_true", help="Skip confirmation") + sub_update.add_argument("--dry-run", action="store_true", help="Print the plan only") + sub.add_parser("doctor", help="Self-check: vault, providers, health") sub_browser = sub.add_parser( @@ -661,6 +672,55 @@ def cmd_doctor(args: argparse.Namespace) -> int: return 0 +def _update_target_label(args: argparse.Namespace) -> str: + if args.version: + return args.version + return "latest stable" + + +def _installer_command(args: argparse.Namespace, *, display: bool = False) -> list[str]: + installer = "scripts/install.sh" if display else str(ROOT / "scripts" / "install.sh") + command = [installer, "--target", str(ROOT)] + if args.version: + command.extend(["--version", args.version]) + else: + command.append("--latest") + if args.yes: + command.append("--yes") + if args.dry_run: + command.append("--dry-run") + return command + + +def cmd_update(args: argparse.Namespace) -> int: + from core import __version__ + + if args.version and not VERSION_TAG_RE.match(args.version): + print(f"ERROR --version must be vX.Y.Z, got: {args.version}", file=sys.stderr) + return 2 + + display_command = _installer_command(args, display=True) + command = _installer_command(args) + print(f"current version: {__version__}") + print(f"target version: {_update_target_label(args)}") + print(f"install path: {ROOT}") + print( + "preserve user config: ~/.config/meti, " + "~/.config/meti/credentials.json.age, ~/.config/meti/age-key.txt" + ) + print("installer command: " + " ".join(display_command)) + if args.dry_run: + print("dry-run: no files will be modified") + return 0 + if not args.yes: + answer = input("Run installer now? Type 'yes' to continue: ") + if answer != "yes": + print("Update cancelled.") + return 1 + proc = subprocess.run(command, check=False) + return proc.returncode + + def cmd_wizard(args: argparse.Namespace) -> int: if args.dump_context: from core.wizard.context import build_context @@ -793,6 +853,7 @@ def cmd_browser(args: argparse.Namespace) -> int: "list": cmd_list, "providers": cmd_providers, "resume": cmd_resume, + "update": cmd_update, "doctor": cmd_doctor, "wizard": cmd_wizard, "browser": cmd_browser, diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..384c65c --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,472 @@ +#!/usr/bin/env python3 +"""Build and publish Meti release artifacts.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from core.release import load_release_manifest, validate_release_manifest # noqa: E402 +from scripts import check_release # noqa: E402 + +SOURCE_DIRS = ( + "assets", + "core", + "providers", + "scripts", + "docs", + "examples", + ".claude-plugin", +) +SOURCE_FILES = ( + "pyproject.toml", + "README.md", + "SKILL.md", + "CHANGELOG.md", + "CONTRIBUTING.md", + "CODE_OF_CONDUCT.md", + "LICENSE", + "release.json", +) +CLAUDE_PLUGIN_FILES = SOURCE_FILES +OPENCLAW_SKILL_DIRS = ("core", "providers", "scripts", "docs", "examples") +OPENCLAW_SKILL_FILES = ("SKILL.md", "README.md", "CHANGELOG.md", "release.json") +STRICT_SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") + + +def _artifact_map(manifest: dict[str, object]) -> dict[str, str]: + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, list): + raise ValueError("release.json artifacts must be a list") + result: dict[str, str] = {} + for artifact in artifacts: + if not isinstance(artifact, dict): + continue + kind = artifact.get("kind") + filename = artifact.get("filename") + if isinstance(kind, str) and isinstance(filename, str): + result[kind] = filename + return result + + +def validate_target_version(version: str, manifest: dict[str, object]) -> None: + if not STRICT_SEMVER_RE.match(version): + raise ValueError(f"{version} is not strict SemVer X.Y.Z") + if manifest.get("channel") == "stable" and version != version.strip("v"): + raise ValueError("stable releases use bare X.Y.Z versions") + + +def artifact_filename(kind: str, version: str) -> str: + tag = f"v{version}" + names = { + "wheel": f"meti-{version}-py3-none-any.whl", + "sdist": f"meti-{version}.tar.gz", + "claude-plugin-zip": f"meti-claude-plugin-{tag}.zip", + "openclaw-skill-zip": f"meti-openclaw-skill-{tag}.zip", + "checksums": "SHA256SUMS", + "release-manifest": "release.json", + } + return names[kind] + + +def _updated_manifest(manifest: dict[str, object], version: str) -> dict[str, object]: + updated = json.loads(json.dumps(manifest)) + updated["version"] = version + updated["tag"] = f"v{version}" + compatibility = updated.get("compatibility") + if isinstance(compatibility, dict): + for host in ("cli", "claude-plugin", "openclaw-skill"): + compatibility[host] = f">={version} <1.0.0" + artifacts = updated.get("artifacts") + if isinstance(artifacts, list): + for artifact in artifacts: + if isinstance(artifact, dict) and isinstance(artifact.get("kind"), str): + artifact["filename"] = artifact_filename(str(artifact["kind"]), version) + return updated + + +def _iter_release_files( + project_root: Path, + *, + dirs: tuple[str, ...] = SOURCE_DIRS, + files: tuple[str, ...] = SOURCE_FILES, +) -> list[tuple[Path, str]]: + selected: list[tuple[Path, str]] = [] + for rel_file in files: + path = project_root / rel_file + if path.exists() and not check_release.path_is_denied(rel_file): + selected.append((path, rel_file)) + + for rel_dir in dirs: + root = project_root / rel_dir + if not root.exists(): + continue + for path in sorted(p for p in root.rglob("*") if p.is_file()): + rel = path.relative_to(project_root).as_posix() + if check_release.path_is_denied(rel): + continue + if any(part in {".git", "build", "dist"} for part in path.parts): + continue + if path.name.endswith(".egg-info"): + continue + selected.append((path, rel)) + return selected + + +def _write_zip(path: Path, files: list[tuple[Path, str]]) -> None: + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for source, arcname in files: + archive.write(source, arcname) + + +def _write_sdist(path: Path, version: str, files: list[tuple[Path, str]]) -> None: + prefix = f"meti-{version}" + with tarfile.open(path, "w:gz") as archive: + for source, arcname in files: + archive.add(source, arcname=f"{prefix}/{arcname}") + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _write_checksums(output_dir: Path, artifacts: list[Path]) -> Path: + checksums = output_dir / "SHA256SUMS" + lines = [f"{_sha256(path)} {path.name}" for path in artifacts] + checksums.write_text("\n".join(lines) + "\n", encoding="utf-8") + return checksums + + +def _replace_project_version(text: str, version: str) -> str: + return re.sub(r'(?m)^version = "([^"]+)"$', f'version = "{version}"', text, count=1) + + +def _replace_skill_version(text: str, version: str) -> str: + return re.sub(r"(?m)^version:\s*[^\n]+$", f"version: {version}", text, count=1) + + +def _move_unreleased_to_version(text: str, version: str) -> str: + heading = f"## {version}" + if heading in text: + return text + marker = "## Unreleased" + start = text.find(marker) + if start == -1: + return ( + text.rstrip() + + f"\n\n## {version} - {dt.date.today().isoformat()}\n\n- Release prepared.\n" + ) + body_start = start + len(marker) + next_heading = text.find("\n## ", body_start) + if next_heading == -1: + next_heading = len(text) + unreleased_body = text[body_start:next_heading].strip() + version_body = unreleased_body or "- Release prepared." + return ( + text[:start] + + "## Unreleased\n\n" + + f"## {version} - {dt.date.today().isoformat()}\n\n{version_body}\n\n" + + text[next_heading:].lstrip("\n") + ) + + +def _validate_artifacts(paths: list[Path]) -> None: + failures: list[str] = [] + for path in paths: + if path.name.endswith((".whl", ".zip", ".tar.gz", ".tgz")): + denied = check_release.scan_archive_path(path) + else: + denied = [path.name] if check_release.path_is_denied(path.name) else [] + if denied: + failures.append(f"{path.name}: {', '.join(denied)}") + if failures: + raise RuntimeError("release bundle includes private paths: " + "; ".join(failures)) + + +def release_output_dir(project_root: Path, tag: str) -> Path: + return project_root / "dist" / "releases" / tag + + +def prepare_release(project_root: Path, version: str, *, dry_run: bool = False) -> list[str]: + manifest_path = project_root / "release.json" + manifest = load_release_manifest(manifest_path) + validate_target_version(version, manifest) + updated_manifest = _updated_manifest(manifest, version) + errors = validate_release_manifest(updated_manifest) + if errors: + raise ValueError("invalid prepared release.json: " + "; ".join(errors)) + + updates = [ + "release.json", + "pyproject.toml", + "SKILL.md", + ".claude-plugin/plugin.json", + ".claude-plugin/marketplace.json", + "CHANGELOG.md", + ] + if dry_run: + return updates + + manifest_path.write_text( + json.dumps(updated_manifest, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + pyproject = project_root / "pyproject.toml" + pyproject.write_text( + _replace_project_version(pyproject.read_text(encoding="utf-8"), version), + encoding="utf-8", + ) + + skill = project_root / "SKILL.md" + skill.write_text( + _replace_skill_version(skill.read_text(encoding="utf-8"), version), encoding="utf-8" + ) + + plugin_path = project_root / ".claude-plugin" / "plugin.json" + plugin = json.loads(plugin_path.read_text(encoding="utf-8")) + plugin["version"] = version + plugin_path.write_text( + json.dumps(plugin, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + + marketplace_path = project_root / ".claude-plugin" / "marketplace.json" + marketplace = json.loads(marketplace_path.read_text(encoding="utf-8")) + marketplace["plugins"][0]["version"] = version + marketplace_path.write_text( + json.dumps(marketplace, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + changelog = project_root / "CHANGELOG.md" + changelog.write_text( + _move_unreleased_to_version(changelog.read_text(encoding="utf-8"), version), + encoding="utf-8", + ) + return updates + + +def build_release_bundle(project_root: Path, *, dry_run: bool = False) -> list[Path]: + manifest = load_release_manifest(project_root) + errors = validate_release_manifest(manifest) + if errors: + raise ValueError("invalid release.json: " + "; ".join(errors)) + + version = str(manifest["version"]) + tag = str(manifest["tag"]) + artifact_names = _artifact_map(manifest) + expected = [ + "wheel", + "sdist", + "claude-plugin-zip", + "openclaw-skill-zip", + "release-manifest", + "checksums", + ] + missing = [kind for kind in expected if kind not in artifact_names] + if missing: + raise ValueError("release.json missing artifacts: " + ", ".join(missing)) + + output_dir = release_output_dir(project_root, tag) + planned = [output_dir / artifact_names[kind] for kind in expected] + if dry_run: + return planned + + output_dir.mkdir(parents=True, exist_ok=True) + produced: list[Path] = [] + with tempfile.TemporaryDirectory(prefix="meti-release-build-") as tmp: + wheel = check_release.build_wheel(project_root, Path(tmp)) + wheel_dest = output_dir / artifact_names["wheel"] + shutil.copy2(wheel, wheel_dest) + produced.append(wheel_dest) + + source_files = _iter_release_files(project_root) + sdist = output_dir / artifact_names["sdist"] + _write_sdist(sdist, version, source_files) + produced.append(sdist) + + claude_files = _iter_release_files( + project_root, + dirs=SOURCE_DIRS, + files=CLAUDE_PLUGIN_FILES, + ) + claude_zip = output_dir / artifact_names["claude-plugin-zip"] + _write_zip(claude_zip, claude_files) + produced.append(claude_zip) + + openclaw_files = _iter_release_files( + project_root, + dirs=OPENCLAW_SKILL_DIRS, + files=OPENCLAW_SKILL_FILES, + ) + openclaw_zip = output_dir / artifact_names["openclaw-skill-zip"] + _write_zip(openclaw_zip, openclaw_files) + produced.append(openclaw_zip) + + manifest_dest = output_dir / artifact_names["release-manifest"] + manifest_dest.write_text( + json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + produced.append(manifest_dest) + + _validate_artifacts(produced) + checksums = _write_checksums(output_dir, produced) + return produced + [checksums] + + +def cmd_build(args: argparse.Namespace) -> int: + artifacts = build_release_bundle(args.project_root.resolve(), dry_run=args.dry_run) + heading = "DRY RUN release bundle" if args.dry_run else "Built release bundle" + print(f"{heading}:") + for artifact in artifacts: + print(f"- {artifact}") + return 0 + + +def _release_gate_command() -> list[str]: + return ["python3", "scripts/check_release.py"] + + +def publish_commands(tag: str, artifacts: list[Path]) -> list[list[str]]: + artifact_args = [str(path) for path in artifacts if path.name != "SHA256SUMS"] + artifact_args.append(str(next(path for path in artifacts if path.name == "SHA256SUMS"))) + return [ + ["git", "tag", tag], + [ + "gh", + "release", + "create", + tag, + *artifact_args, + "--title", + tag, + "--notes-file", + "CHANGELOG.md", + ], + ] + + +def run_commands(commands: list[list[str]], *, cwd: Path, dry_run: bool) -> None: + for command in commands: + print("$ " + " ".join(command)) + if not dry_run: + subprocess.run(command, cwd=cwd, check=True) + + +def _print_publish_plan(version: str, tag: str, artifacts: list[Path]) -> None: + checksums = next(path for path in artifacts if path.name == "SHA256SUMS") + print(f"target version: {version}") + print(f"target tag: {tag}") + print(f"changelog heading: ## {version}") + print("release bundle artifacts:") + for artifact in artifacts: + print(f"- {artifact}") + print(f"checksum file: {checksums}") + + +def cmd_prepare(args: argparse.Namespace) -> int: + updates = prepare_release(args.project_root.resolve(), args.version, dry_run=args.dry_run) + heading = "DRY RUN prepare updates" if args.dry_run else "Prepared release updates" + print(f"{heading} for {args.version}:") + for rel_path in updates: + print(f"- {rel_path}") + return 0 + + +def cmd_publish(args: argparse.Namespace) -> int: + project_root = args.project_root.resolve() + manifest = load_release_manifest(project_root) + validate_target_version(args.version, manifest) + if manifest.get("version") != args.version: + raise ValueError( + f"release.json version is {manifest.get('version')}; run prepare --version {args.version} first" + ) + tag = f"v{args.version}" + gate_command = _release_gate_command() + print("release gate command:") + print("$ " + " ".join(gate_command)) + if not args.dry_run: + subprocess.run(gate_command, cwd=project_root, check=True) + + artifacts = build_release_bundle(project_root, dry_run=args.dry_run) + _print_publish_plan(args.version, tag, artifacts) + commands = publish_commands(tag, artifacts) + print("publish commands:") + for command in commands: + print("$ " + " ".join(command)) + + if args.dry_run: + return 0 + if not args.yes: + answer = input(f"Publish GitHub Release {tag}? Type 'yes' to continue: ") + if answer != "yes": + print("Release publish cancelled.") + return 1 + run_commands(commands, cwd=project_root, dry_run=False) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--project-root", + type=Path, + default=ROOT, + help=argparse.SUPPRESS, + ) + subparsers = parser.add_subparsers(dest="command", required=True) + build = subparsers.add_parser("build", help="Build the complete release bundle.") + build.add_argument("--dry-run", action="store_true", help="Print planned artifacts only.") + build.set_defaults(func=cmd_build) + prepare = subparsers.add_parser("prepare", help="Prepare a version bump.") + prepare.add_argument( + "--version", required=True, help="Strict SemVer target version, e.g. 0.4.4." + ) + prepare.add_argument("--dry-run", action="store_true", help="Print intended updates only.") + prepare.set_defaults(func=cmd_prepare) + publish = subparsers.add_parser("publish", help="Publish a confirmed GitHub Release.") + publish.add_argument( + "--version", required=True, help="Strict SemVer target version, e.g. 0.4.4." + ) + publish.add_argument( + "--dry-run", action="store_true", help="Print intended release actions only." + ) + publish.add_argument("--yes", action="store_true", help="Skip the confirmation prompt.") + publish.set_defaults(func=cmd_publish) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return args.func(args) + except subprocess.CalledProcessError as exc: + print(f"ERROR: command failed: {' '.join(exc.cmd)}", file=sys.stderr) + return exc.returncode + except (RuntimeError, ValueError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/integration/test_update_cli.py b/tests/integration/test_update_cli.py new file mode 100644 index 0000000..9889a75 --- /dev/null +++ b/tests/integration/test_update_cli.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def run_cli(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(ROOT / "scripts" / "meti.py"), *args], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + + +def test_update_version_dry_run_prints_plan() -> None: + proc = run_cli("update", "--version", "v0.4.3", "--dry-run") + + assert proc.returncode == 0 + assert "current version" in proc.stdout + assert "target version" in proc.stdout + assert "install path" in proc.stdout + assert "scripts/install.sh" in proc.stdout + assert "~/.config/meti" in proc.stdout + + +def test_update_latest_dry_run_reports_latest_stable() -> None: + proc = run_cli("update", "--latest", "--dry-run") + + assert proc.returncode == 0 + assert "target version: latest stable" in proc.stdout + assert "dry-run: no files will be modified" in proc.stdout + + +def test_update_rejects_invalid_version() -> None: + proc = run_cli("update", "--version", "0.4.3", "--dry-run") + + assert proc.returncode == 2 + assert "--version must be vX.Y.Z" in proc.stderr + + +def test_install_script_help_and_dry_run(tmp_path: Path) -> None: + installer = ROOT / "scripts" / "install.sh" + + assert os.access(installer, os.X_OK) + help_proc = subprocess.run( + [str(installer), "--help"], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + assert help_proc.returncode == 0 + for flag in ("--version", "--latest", "--target", "--yes"): + assert flag in help_proc.stdout + + dry_run = subprocess.run( + [ + str(installer), + "--version", + "v0.4.3", + "--target", + str(tmp_path), + "--dry-run", + ], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert dry_run.returncode == 0 + assert "https://github.com/Nowhitestar/meti/releases/download/v0.4.3" in dry_run.stdout + assert "meti-claude-plugin-v0.4.3.zip" in dry_run.stdout + assert list(tmp_path.iterdir()) == [] + + invalid = subprocess.run( + [str(installer), "--version", "0.4.3", "--target", str(tmp_path), "--dry-run"], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + assert invalid.returncode == 2 + assert "--version must be vX.Y.Z" in invalid.stderr diff --git a/tests/test_check_release.py b/tests/test_check_release.py index 269d3ad..c65c1e8 100644 --- a/tests/test_check_release.py +++ b/tests/test_check_release.py @@ -3,6 +3,7 @@ import importlib.util import json import sys +import zipfile from pathlib import Path from types import ModuleType @@ -23,6 +24,37 @@ def write_minimal_project(root: Path, *, version: str = "0.4.3") -> None: (root / ".claude-plugin").mkdir(exist_ok=True) (root / "docs").mkdir(exist_ok=True) (root / "scripts").mkdir(exist_ok=True) + (root / "release.json").write_text( + json.dumps( + { + "version": version, + "tag": f"v{version}", + "channel": "stable", + "semver_policy": "strict", + "compatibility": { + "python": ">=3.10", + "cli": f">={version} <1.0.0", + "claude-plugin": f">={version} <1.0.0", + "openclaw-skill": f">={version} <1.0.0", + }, + "artifacts": [ + {"kind": "wheel", "filename": f"meti-{version}-py3-none-any.whl"}, + {"kind": "sdist", "filename": f"meti-{version}.tar.gz"}, + { + "kind": "claude-plugin-zip", + "filename": f"meti-claude-plugin-v{version}.zip", + }, + { + "kind": "openclaw-skill-zip", + "filename": f"meti-openclaw-skill-v{version}.zip", + }, + {"kind": "checksums", "filename": "SHA256SUMS"}, + {"kind": "release-manifest", "filename": "release.json"}, + ], + } + ), + encoding="utf-8", + ) (root / "pyproject.toml").write_text( f'[project]\nname = "meti"\nversion = "{version}"\n', encoding="utf-8", @@ -73,17 +105,26 @@ def write_minimal_project(root: Path, *, version: str = "0.4.3") -> None: encoding="utf-8", ) (root / "README.md").write_text( - "Claude Code marketplace submission is pending\n/plugin install meti\n", + "Claude Code marketplace submission is pending\n" + "/plugin install meti\n" + "meti update --version vX.Y.Z\n", encoding="utf-8", ) (root / "CONTRIBUTING.md").write_text( - "Prefer reinstall\npython scripts/check_release.py\n", + "Prefer reinstall\n" + "python scripts/check_release.py\n" + "release.json\n" + "scripts/release.py\n" + "meti update --version vX.Y.Z\n", encoding="utf-8", ) (root / "docs" / "distribution.md").write_text( "git clone https://github.com/Nowhitestar/meti.git ~/.openclaw/skills/meti\n" "Prefer reinstall\n" - "python scripts/check_release.py\n", + "python scripts/check_release.py\n" + "scripts/install.sh --version vX.Y.Z\n" + "meti update --version vX.Y.Z\n" + "~/.config/meti/credentials.json.age\n", encoding="utf-8", ) (root / "docs" / "marketplace-submission.md").write_text( @@ -94,6 +135,7 @@ def write_minimal_project(root: Path, *, version: str = "0.4.3") -> None: (root / ".gitignore").write_text( "\n".join( [ + "dist/", "runs/", ".env", ".env.*", @@ -121,6 +163,7 @@ def test_load_versions_reads_all_surfaces(tmp_path: Path) -> None: versions = check_release.load_versions(tmp_path) + assert versions["release"] == "0.4.3" assert versions["pyproject"] == "0.4.3" assert versions["skill"] == "0.4.3" assert versions["plugin"] == "0.4.3" @@ -141,6 +184,21 @@ def test_version_sync_reports_skill_drift(tmp_path: Path) -> None: assert "SKILL.md" in result.message +def test_version_sync_reports_pyproject_drift_from_release_manifest(tmp_path: Path) -> None: + check_release = load_module() + write_minimal_project(tmp_path) + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "meti"\nversion = "9.9.9"\n', + encoding="utf-8", + ) + + result = check_release.check_version_sync(tmp_path) + + assert not result.ok + assert "release.json" in result.message + assert "pyproject.toml" in result.message + + def test_metadata_requires_keywords_and_marketplace_tags(tmp_path: Path) -> None: check_release = load_module() write_minimal_project(tmp_path) @@ -169,6 +227,7 @@ def test_private_path_denylist() -> None: assert check_release.path_is_denied(".planning/ROADMAP.md") assert check_release.path_is_denied("runs/20260507-001255-mmp/result.json") assert check_release.path_is_denied(".env.production") + assert check_release.path_is_denied("docs/.DS_Store") assert check_release.path_is_denied("docs/HANDOFF.md") assert not check_release.path_is_denied("README.md") @@ -180,6 +239,28 @@ def test_archive_hygiene_rejects_private_members() -> None: assert not check_release.scan_archive_members(["core/__init__.py"]) +def test_bundle_scan_rejects_private_planning_zip_member(tmp_path: Path) -> None: + check_release = load_module() + archive = tmp_path / "meti-claude-plugin-v0.4.3.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr(".planning/STATE.md", "{}") + + failures = check_release.scan_release_bundle_for_private_paths(tmp_path) + + assert failures[archive.name] == [".planning/STATE.md"] + + +def test_bundle_scan_rejects_private_runs_zip_member(tmp_path: Path) -> None: + check_release = load_module() + archive = tmp_path / "meti-openclaw-skill-v0.4.3.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("runs/example/result.json", "{}") + + failures = check_release.scan_release_bundle_for_private_paths(tmp_path) + + assert failures[archive.name] == ["runs/example/result.json"] + + def test_docs_install_reports_missing_required_strings(tmp_path: Path) -> None: check_release = load_module() write_minimal_project(tmp_path) diff --git a/tests/test_release_manifest.py b/tests/test_release_manifest.py new file mode 100644 index 0000000..447b41c --- /dev/null +++ b/tests/test_release_manifest.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +SCRIPT = ROOT / "core" / "release.py" +RELEASE_SCRIPT = ROOT / "scripts" / "release.py" + + +def load_module() -> ModuleType: + spec = importlib.util.spec_from_file_location("release", SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules["release"] = module + spec.loader.exec_module(module) + return module + + +def load_release_script() -> ModuleType: + spec = importlib.util.spec_from_file_location("release_script", RELEASE_SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules["release_script"] = module + spec.loader.exec_module(module) + return module + + +def test_release_manifest_loads_and_validates_current_tree() -> None: + release = load_module() + + manifest = release.load_release_manifest(ROOT) + + assert manifest["version"] == "0.4.3" + assert manifest["tag"] == "v0.4.3" + assert manifest["channel"] == "stable" + assert release.validate_release_manifest(manifest) == [] + + +def test_release_manifest_requires_strict_stable_artifact_contract(tmp_path: Path) -> None: + release = load_module() + manifest = release.load_release_manifest(ROOT) + manifest["version"] = "0.4" + manifest["tag"] = "v0.4" + manifest["artifacts"] = [ + artifact for artifact in manifest["artifacts"] if artifact["kind"] != "wheel" + ] + manifest_path = tmp_path / "release.json" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + errors = release.validate_release_manifest(release.load_release_manifest(manifest_path)) + + assert "version must be strict SemVer X.Y.Z" in errors + assert any("wheel" in error for error in errors) + + +def test_prepare_dry_run_reports_all_public_version_surfaces( + capsys: pytest.CaptureFixture[str], +) -> None: + release_script = load_release_script() + before = (ROOT / "release.json").read_text(encoding="utf-8") + + exit_code = release_script.main( + ["--project-root", str(ROOT), "prepare", "--version", "0.4.4", "--dry-run"] + ) + + out = capsys.readouterr().out + assert exit_code == 0 + for rel_path in ( + "release.json", + "pyproject.toml", + "SKILL.md", + ".claude-plugin/plugin.json", + ".claude-plugin/marketplace.json", + "CHANGELOG.md", + ): + assert rel_path in out + assert (ROOT / "release.json").read_text(encoding="utf-8") == before + + +def test_publish_dry_run_reports_gate_artifacts_and_release_commands( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + release_script = load_release_script() + + def fail_run(*_args: object, **_kwargs: object) -> None: + raise AssertionError("dry-run must not call subprocess.run") + + monkeypatch.setattr(release_script.subprocess, "run", fail_run) + + exit_code = release_script.main( + ["--project-root", str(ROOT), "publish", "--version", "0.4.3", "--dry-run"] + ) + + out = capsys.readouterr().out + assert exit_code == 0 + assert "python3 scripts/check_release.py" in out + assert "meti-claude-plugin-v0.4.3.zip" in out + assert "meti-openclaw-skill-v0.4.3.zip" in out + assert "SHA256SUMS" in out + assert "git tag v0.4.3" in out + assert "gh release create v0.4.3" in out + + +def test_release_script_rejects_invalid_stable_versions() -> None: + release_script = load_release_script() + + with pytest.raises(ValueError): + release_script.validate_target_version("0.4", {"channel": "stable"}) + with pytest.raises(ValueError): + release_script.validate_target_version("0.4.4-dev", {"channel": "stable"}) + + +def test_publish_commands_use_subprocess_run( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + release_script = load_release_script() + artifacts = [ + tmp_path / "meti-0.4.3-py3-none-any.whl", + tmp_path / "meti-0.4.3.tar.gz", + tmp_path / "meti-claude-plugin-v0.4.3.zip", + tmp_path / "meti-openclaw-skill-v0.4.3.zip", + tmp_path / "release.json", + tmp_path / "SHA256SUMS", + ] + calls: list[list[str]] = [] + + def record_run(command: list[str], **_kwargs: object) -> None: + calls.append(command) + + monkeypatch.setattr(release_script.subprocess, "run", record_run) + + release_script.run_commands( + release_script.publish_commands("v0.4.3", artifacts), cwd=ROOT, dry_run=False + ) + + assert calls[0] == ["git", "tag", "v0.4.3"] + assert calls[1][:4] == ["gh", "release", "create", "v0.4.3"]