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
18 changes: 18 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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')
Expand Down
11 changes: 10 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
Expand Down
17 changes: 7 additions & 10 deletions framework/cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
87 changes: 87 additions & 0 deletions framework/cli/tests/test_build_packaging.py
Original file line number Diff line number Diff line change
@@ -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/*"
)
Loading