diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 950efd7..31ff7e4 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -20,12 +20,17 @@ jobs: - uses: actions/setup-python@v6.2.0 with: python-version: "3.11" + - uses: actions/setup-node@v4 + with: + node-version: "20" - name: Install package run: python -m pip install -e . pytest build - name: Check version consistency run: python scripts/check_versions.py - name: Validate marketplace manifest run: python scripts/validate_codex_marketplace.py + - name: Inspect npm package contents + run: python scripts/inspect_npm_package.py - name: Run tests run: pytest -q - name: Build sdist + wheel diff --git a/scripts/inspect_npm_package.py b/scripts/inspect_npm_package.py new file mode 100644 index 0000000..2e59294 --- /dev/null +++ b/scripts/inspect_npm_package.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Inspect a packed npm tarball and verify it ships the contents users need. + +The packed tarball (``npm pack`` output) is the interface under test: this +checks what gets published, independent of any registry. Run with no argument +to pack the repo and inspect it, or pass a ``.tgz`` to inspect directly. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tarfile +from pathlib import Path + +# Files every consumer of the npm artifact relies on. +REQUIRED_FILES = ( + "bin/install.js", + "package.json", + "install.sh", + "install.ps1", + "skills-lock.json", +) +# Console-script entrypoints package.json must expose. +REQUIRED_BINS = ("codeforerunner", "codeforerunner-install") + + +def _strip_prefix(name: str) -> str: + """Drop the leading ``package/`` dir npm wraps every member in.""" + parts = name.split("/", 1) + return parts[1] if len(parts) == 2 and parts[0] == "package" else name + + +def _read_members(tar: tarfile.TarFile) -> set[str]: + return {_strip_prefix(m.name) for m in tar.getmembers() if m.isfile()} + + +def _read_text(tar: tarfile.TarFile, member: str) -> str | None: + for m in tar.getmembers(): + if _strip_prefix(m.name) == member and m.isfile(): + f = tar.extractfile(m) + if f is not None: + return f.read().decode("utf-8") + return None + + +def inspect_package(tarball_path: Path) -> list[str]: + """Return a list of content problems; empty means the artifact is good.""" + problems: list[str] = [] + with tarfile.open(tarball_path, "r:*") as tar: + members = _read_members(tar) + + for required in REQUIRED_FILES: + if required not in members: + problems.append(f"missing required file: {required}") + + if not any(m.startswith("skills/") for m in members): + problems.append("missing skill payloads: skills/ is empty") + + problems.extend(_check_bins(tar, members)) + problems.extend(_check_locked_skills(tar, members)) + + return problems + + +def _check_locked_skills(tar: tarfile.TarFile, members: set[str]) -> list[str]: + """Every skill the lock declares must actually ship in the tarball.""" + if "skills-lock.json" not in members: + return [] # already reported as a missing required file + raw = _read_text(tar, "skills-lock.json") + try: + skills = json.loads(raw or "").get("skills", {}) + except json.JSONDecodeError: + return ["skills-lock.json is not valid JSON"] + problems = [] + for entry in skills.values(): + path = entry.get("skillPath") + if path and path not in members: + problems.append(f"skill declared in skills-lock.json but absent from package: {path}") + return problems + + +def _check_bins(tar: tarfile.TarFile, members: set[str]) -> list[str]: + if "package.json" not in members: + return [] # already reported as a missing required file + raw = _read_text(tar, "package.json") + try: + bins = json.loads(raw or "").get("bin", {}) + except json.JSONDecodeError: + return ["package.json is not valid JSON"] + return [ + f"missing bin entrypoint in package.json: {name}" + for name in REQUIRED_BINS + if name not in bins + ] + + +def main(argv: list[str]) -> int: + if argv: + tarball = Path(argv[0]) + created = False + else: + tarball = _npm_pack() + created = True + try: + problems = inspect_package(tarball) + finally: + if created: + tarball.unlink(missing_ok=True) + + if problems: + print("npm package contents FAILED validation:", file=sys.stderr) + for p in problems: + print(f" - {p}", file=sys.stderr) + return 1 + print(f"npm package contents OK ({tarball.name})") + return 0 + + +def _npm_pack() -> Path: + root = Path(__file__).resolve().parent.parent + out = subprocess.run( + ["npm", "pack", "--json"], + cwd=root, + check=True, + capture_output=True, + text=True, + ) + filename = json.loads(out.stdout)[0]["filename"] + return root / filename + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/test_inspect_npm_package.py b/tests/test_inspect_npm_package.py new file mode 100644 index 0000000..cc666db --- /dev/null +++ b/tests/test_inspect_npm_package.py @@ -0,0 +1,122 @@ +"""Behavior tests for scripts/inspect_npm_package.py. + +The interface under test is the packed npm tarball: each test builds a tarball +shaped like ``npm pack`` output (every member under a ``package/`` prefix) and +asserts what the inspector reports about its contents. +""" + +from __future__ import annotations + +import importlib.util +import io +import json +import shutil +import sys +import tarfile +from pathlib import Path + +import pytest + +SCRIPT_PATH = Path(__file__).resolve().parent.parent / "scripts" / "inspect_npm_package.py" + + +def _load_inspector(): + spec = importlib.util.spec_from_file_location("inspect_npm_package", SCRIPT_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +inspect_npm_package = _load_inspector() + + +# --- fixture builder ------------------------------------------------------ + +_SKILLS_LOCK = { + "version": 1, + "skills": { + "codeforerunner": {"skillPath": "skills/codeforerunner/SKILL.md"}, + "forerunner-scan": {"skillPath": "skills/forerunner-scan/SKILL.md"}, + }, +} + +_PACKAGE_JSON = { + "name": "codeforerunner", + "version": "0.4.5", + "bin": { + "codeforerunner": "bin/install.js", + "codeforerunner-install": "bin/install.js", + }, +} + + +def _well_formed_members() -> dict[str, str]: + """Member-path → text content for a complete, valid package.""" + return { + "bin/install.js": "// installer\n", + "package.json": json.dumps(_PACKAGE_JSON), + "install.sh": "#!/bin/sh\n", + "install.ps1": "# ps1\n", + "skills-lock.json": json.dumps(_SKILLS_LOCK), + "skills/codeforerunner/SKILL.md": "# codeforerunner\n", + "skills/forerunner-scan/SKILL.md": "# scan\n", + } + + +def _write_tarball(tmp_path: Path, members: dict[str, str]) -> Path: + """Pack ``members`` into an npm-style tarball (``package/`` prefix).""" + tarball = tmp_path / "codeforerunner-0.4.5.tgz" + with tarfile.open(tarball, "w:gz") as tar: + for name, content in members.items(): + raw = content.encode("utf-8") + info = tarfile.TarInfo(name=f"package/{name}") + info.size = len(raw) + tar.addfile(info, io.BytesIO(raw)) + return tarball + + +# --- tests ---------------------------------------------------------------- + + +def test_well_formed_package_has_no_problems(tmp_path): + tarball = _write_tarball(tmp_path, _well_formed_members()) + assert inspect_npm_package.inspect_package(tarball) == [] + + +def test_missing_required_file_is_reported(tmp_path): + members = _well_formed_members() + del members["bin/install.js"] + tarball = _write_tarball(tmp_path, members) + problems = inspect_npm_package.inspect_package(tarball) + assert any("bin/install.js" in p for p in problems) + + +def test_missing_bin_entrypoint_is_reported(tmp_path): + members = _well_formed_members() + pkg = dict(_PACKAGE_JSON) + pkg["bin"] = {"codeforerunner": "bin/install.js"} # drop codeforerunner-install + members["package.json"] = json.dumps(pkg) + tarball = _write_tarball(tmp_path, members) + problems = inspect_npm_package.inspect_package(tarball) + assert any("codeforerunner-install" in p for p in problems) + + +def test_skill_declared_in_lock_but_absent_is_reported(tmp_path): + members = _well_formed_members() + del members["skills/forerunner-scan/SKILL.md"] # lock still declares it + tarball = _write_tarball(tmp_path, members) + problems = inspect_npm_package.inspect_package(tarball) + assert any("forerunner-scan/SKILL.md" in p for p in problems) + + +@pytest.mark.skipif(shutil.which("npm") is None, reason="npm not available") +def test_real_repo_npm_pack_passes_inspection(): + # End-to-end against actual `npm pack` output: the published artifact this + # repo produces today must satisfy the inspector. + tarball = inspect_npm_package._npm_pack() + try: + assert inspect_npm_package.inspect_package(tarball) == [] + finally: + tarball.unlink(missing_ok=True) diff --git a/tests/test_workflows_yaml.py b/tests/test_workflows_yaml.py index 73523b5..9f4653b 100644 --- a/tests/test_workflows_yaml.py +++ b/tests/test_workflows_yaml.py @@ -156,6 +156,8 @@ def test_release_pr_workflow_requires_release_signal_and_uploads_artifacts(): assert "actions/upload-artifact" in steps_text assert "scripts/check_versions.py" in steps_text assert "scripts/validate_codex_marketplace.py" in steps_text + # npm artifact contents are validated before any tagged publish. + assert "scripts/inspect_npm_package.py" in steps_text def test_forerunner_check_workflow_always_runs():