diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4088cea1..e1b774da 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -218,6 +218,23 @@ jobs: - run: make install-py - run: make ci-check-file-size + # Dry-run the release build on every PR — same `uv build --all-packages` + # invocation the release workflow runs, so any packaging regression + # (force-include going outside the package, missing template, broken sdist→ + # wheel rebuild, etc.) fails here instead of only when someone clicks + # "Run workflow" on Releases. No deps install needed: build isolation + # provisions each package's build backend on its own. + package-build: + name: Package build (release dry-run) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.0.0 + with: + enable-cache: true + cache-dependency-glob: ${{ env.UV_CACHE_GLOB }} + - run: make ci-build-packages + # Single required status check for branch protection. # Protect `main` with this one check and every leaf job is required transitively. pr-checks: @@ -233,6 +250,7 @@ jobs: - js-build - e2e-smoke - file-size-check + - package-build if: always() steps: - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') diff --git a/Makefile b/Makefile index 1ab28bc4..14b8b1c0 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-memray lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size worker beat worker-docker +.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-memray lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings ci-build-packages worker beat worker-docker # Install install: @@ -119,6 +119,15 @@ ci-check-file-size: ci-check-hardcoded-strings: uv run python scripts/check_hardcoded_strings.py +# Dry-run the release build: build sdists + wheels for every workspace member +# the same way release.yml does. Catches packaging regressions at PR time +# (e.g. force-include paths that crash the sdist→wheel rebuild) instead of +# only when someone clicks "Run workflow" on Releases. +ci-build-packages: + rm -rf dist-py-ci + uv build --all-packages --out-dir dist-py-ci + rm -rf dist-py-ci + # Diagnostics doctor: uv run python -m simple_module_core diff --git a/framework/cli/pyproject.toml b/framework/cli/pyproject.toml index 95d3a0f7..974f03df 100644 --- a/framework/cli/pyproject.toml +++ b/framework/cli/pyproject.toml @@ -38,17 +38,14 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["simple_module_cli"] -# The skills directory lives at repo root (so `npx skills add` from +# The canonical ``skills/`` lives at the repo root (so `npx skills add` from # vercel-labs/skills picks it up) and is symlinked into -# ``simple_module_cli/skills`` for editable installs. Exclude the symlink -# from the package so Hatch doesn't pull it in, then ``force-include`` the -# real source path for the wheel — that gives the wheel real files at -# ``simple_module_cli/skills/`` without double-packaging. -exclude = ["simple_module_cli/skills"] +# ``simple_module_cli/skills`` for editable installs and packaging. +# Hatchling follows that symlink during sdist + wheel builds, so the real +# files land inside the package — no force-include needed (and force-include +# with ``../../skills`` breaks the standard sdist→wheel rebuild because the +# unpacked sdist has no parent ``skills/``). +packages = ["simple_module_cli"] [tool.hatch.build.targets.wheel.shared-data] "simple_module_cli/templates" = "simple_module_cli/templates" - -[tool.hatch.build.targets.wheel.force-include] -"../../skills" = "simple_module_cli/skills" diff --git a/framework/cli/tests/test_build_packaging.py b/framework/cli/tests/test_build_packaging.py new file mode 100644 index 00000000..d0bd4e51 --- /dev/null +++ b/framework/cli/tests/test_build_packaging.py @@ -0,0 +1,87 @@ +"""Regression test: ``simple_module_cli`` must build cleanly via ``uv build``. + +The release workflow runs ``uv build --all-packages`` which builds the sdist +first and then re-builds the wheel from the unpacked sdist. Any +``force-include`` that points outside the package (e.g. ``../../skills``) +breaks that rebuild because the sdist's parent directory has no such files. + +This test catches that class of regression at unit-test time so it can never +slip past PR checks again. +""" + +from __future__ import annotations + +import shutil +import subprocess +import zipfile +from pathlib import Path + +import pytest + +CLI_PROJECT = Path(__file__).resolve().parents[1] + + +@pytest.fixture(scope="module") +def built_artifacts(tmp_path_factory) -> tuple[Path, Path]: + """Build sdist + wheel via ``uv build`` exactly as the release workflow does.""" + if shutil.which("uv") is None: + pytest.skip("uv not available on PATH") + out = tmp_path_factory.mktemp("dist") + subprocess.run( + ["uv", "build", "--package", "simple_module_cli", "--out-dir", str(out)], + check=True, + capture_output=True, + ) + sdist = next(out.glob("simple_module_cli-*.tar.gz"), None) + wheel = next(out.glob("simple_module_cli-*-py3-none-any.whl"), None) + assert sdist is not None, f"sdist not produced; got: {list(out.iterdir())}" + assert wheel is not None, f"wheel not produced; got: {list(out.iterdir())}" + return sdist, wheel + + +def test_wheel_contains_bundled_skills(built_artifacts: tuple[Path, Path]) -> None: + """The wheel must ship the agent skill packs that ``sm skills`` depends on. + + Regression: ``[tool.hatch.build.targets.wheel.force-include]`` with + ``../../skills`` made the wheel re-build from sdist crash with + ``FileNotFoundError: Forced include not found``. + """ + _, wheel = built_artifacts + with zipfile.ZipFile(wheel) as zf: + names = zf.namelist() + skill_files = [n for n in names if n.startswith("simple_module_cli/skills/")] + assert skill_files, ( + f"wheel {wheel.name} does not ship simple_module_cli/skills/* " + f"(top-level entries: {sorted({n.split('/', 1)[0] for n in names})})" + ) + skill_dirs = {n.split("/")[2] for n in skill_files if n.count("/") >= 2 and n.split("/")[2]} + assert "simple-module-creating" in skill_dirs, ( + f"expected the simple-module-creating skill in the wheel; got dirs: {sorted(skill_dirs)}" + ) + + +def test_wheel_contains_templates(built_artifacts: tuple[Path, Path]) -> None: + """``sm new`` reads from ``simple_module_cli/templates`` — must ship in the wheel.""" + _, wheel = built_artifacts + with zipfile.ZipFile(wheel) as zf: + names = zf.namelist() + template_files = [n for n in names if n.startswith("simple_module_cli/templates/")] + assert template_files, "wheel does not ship simple_module_cli/templates/*" + + +def test_sdist_is_self_contained(built_artifacts: tuple[Path, Path]) -> None: + """The sdist must include skills + templates; without them the sdist→wheel + rebuild that ``uv build --all-packages`` performs would fail. + """ + sdist, _ = built_artifacts + import tarfile + + with tarfile.open(sdist) as tf: + names = tf.getnames() + assert any("simple_module_cli/skills/" in n for n in names), ( + "sdist does not include simple_module_cli/skills/* — " + "wheel rebuild from sdist will fail in CI" + ) + assert any("simple_module_cli/templates/" in n for n in names), ( + "sdist does not include simple_module_cli/templates/*" + )