Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/release-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
135 changes: 135 additions & 0 deletions scripts/inspect_npm_package.py
Original file line number Diff line number Diff line change
@@ -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:]))
122 changes: 122 additions & 0 deletions tests/test_inspect_npm_package.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions tests/test_workflows_yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down