diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ad6459..68a4f16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,7 @@ jobs: steps: - uses: actions/checkout@v7 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - uses: actions/setup-python@v7 with: @@ -27,3 +28,28 @@ jobs: /tmp/pubskill-wheel-venv/bin/python -m pip install --no-deps /tmp/pubskill-wheel/pubskill_lib-*.whl cd /tmp /tmp/pubskill-wheel-venv/bin/python -c "from pubskill_lib import evidence; assert evidence._comment_markers()['.py'] == '#'" + - name: Reproduce release artifacts and replay installed wheel + run: | + python -m pip install uv==0.11.18 + uv venv --managed-python --python 3.11.15 /tmp/pubskill-build-venv + uv pip install --python /tmp/pubskill-build-venv/bin/python -r requirements-build.txt + ( + umask 022 + /tmp/pubskill-build-venv/bin/python tools/build_release.py --out /tmp/pubskill-release-a + ) + ( + umask 077 + /tmp/pubskill-build-venv/bin/python tools/build_release.py --out /tmp/pubskill-release-b + ) + diff /tmp/pubskill-release-a/SHA256SUMS /tmp/pubskill-release-b/SHA256SUMS + /tmp/pubskill-wheel-venv/bin/python -m pip install --no-deps --force-reinstall /tmp/pubskill-release-a/*.whl + mkdir /tmp/pubskill-replay + tar -xzf /tmp/pubskill-release-a/*.tar.gz -C /tmp/pubskill-replay + cd /tmp/pubskill-replay/pubskill_lib-0.2.0 + /tmp/pubskill-wheel-venv/bin/python -m unittest discover -s tests + /tmp/pubskill-wheel-venv/bin/python -m pubskill_lib.audit examples/neglected-repo --out /tmp/release-findings.json + /tmp/pubskill-wheel-venv/bin/python -c "import json; from pubskill_lib.audit import _read_source_pin; assert json.load(open('/tmp/release-findings.json'))['source_pin'] == _read_source_pin() != 'hmmm'" + python -m venv /tmp/pubskill-sdist-venv + /tmp/pubskill-sdist-venv/bin/python -m pip install --no-deps /tmp/pubskill-release-a/*.tar.gz + /tmp/pubskill-sdist-venv/bin/python -m unittest discover -s tests + /tmp/pubskill-sdist-venv/bin/python -c "from pubskill_lib.audit import _read_source_pin; assert _read_source_pin() != 'hmmm'" diff --git a/.gitignore b/.gitignore index 78d3764..253fb17 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ __pycache__/ *.pyc *.egg-info/ +build/ +dist/ .env .env.* !.env.example diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..94e107f --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,8 @@ +include AGENTS.md SOURCE.md HANDOFF.md HANDOFF.vm.md .env.example +include .gitignore +include requirements-build.txt +graft .agents/skills +graft examples +graft tests +graft tools +global-exclude __pycache__ *.py[cod] diff --git a/README.md b/README.md index 8cea28e..0ff80cd 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,14 @@ Clone this repo when you want a command that inspects a local repository and wri ## Status -The inspect CLI implementation passes the repository gate; the `v0.2.0` release tag is not published yet. +The inspect CLI version is `0.2.0`. Published versions and their immutable +artifacts are listed on [GitHub Releases](https://github.com/The-Interdependency/pubskill-lib/releases). | Claim | State | |---|---| | Canon | `The-Interdependency/skill-lib` | | This repo | distribution + public CLI + fixtures | -| Clone / run / findings | **implementation ready** — release pending | +| Clone / run / findings | Source and built-artifact gates described below | | VM populate | `HANDOFF.vm.md` | | Source pin | `SOURCE.md` | @@ -31,6 +32,36 @@ python -m pubskill_lib.audit examples/neglected-repo --out /tmp/findings.json Those commands are the definition of done for the first utility tag (`v0.2.0`). They run in GitHub CI from a clean checkout; publish the tag only after the release gate is explicitly completed. +## Reproduce release artifacts + +From the release's exact Git commit, install the pinned build tools and build +into two empty directories: + +```bash +python -m pip install uv==0.11.18 +uv venv --managed-python --python 3.11.15 /tmp/pubskill-build-env +. /tmp/pubskill-build-env/bin/activate +uv pip install --python /tmp/pubskill-build-env/bin/python -r requirements-build.txt +python tools/build_release.py --out /tmp/pubskill-build-a +python tools/build_release.py --out /tmp/pubskill-build-b +diff /tmp/pubskill-build-a/SHA256SUMS /tmp/pubskill-build-b/SHA256SUMS +``` + +The builder uses only committed source, normalizes source and wheel archive +headers, ordering, and permissions, and +requires zlib 1.3.1 at compile time and runtime, and records its identity along +with source, doctrine, toolchain, and artifact digests in `release-manifest.json`. +CI compares builds under both 022 and 077 file-creation masks; wheel payloads +and their RECORD hashes remain unchanged by archive normalization. +It does not publish. Before publication, install the exact wheel in a fresh venv, +run the tests and fixture from the extracted sdist, and inspect a real consumer. +The wheel retains its canonical skill-lib source pin without requiring a checkout. + +Download the wheel, source archive, manifest, and `SHA256SUMS` from the chosen +release. Verify the downloaded files with `sha256sum -c SHA256SUMS`, then install +the verified wheel with `python -m pip install --no-deps ./pubskill_lib-0.2.0-py3-none-any.whl`. +Checksums establish byte identity; they are not a signature or a blanket health claim. + ## Inspect CLI `v0.2` inspects one **local repository path** without executing the target repository: diff --git a/pyproject.toml b/pyproject.toml index ecfd183..ed03fd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=68"] +requires = ["setuptools==84.0.0", "wheel==0.48.0"] build-backend = "setuptools.build_meta" [project] @@ -16,3 +16,6 @@ pubskill-examine = "pubskill_lib.examine:main" [tool.setuptools.packages.find] where = ["src"] + +[tool.setuptools.package-data] +pubskill_lib = ["_source.json"] diff --git a/requirements-build.txt b/requirements-build.txt new file mode 100644 index 0000000..67e6895 --- /dev/null +++ b/requirements-build.txt @@ -0,0 +1,5 @@ +build==1.6.1 +packaging==26.3 +pyproject-hooks==1.2.0 +setuptools==84.0.0 +wheel==0.48.0 diff --git a/src/pubskill_lib/_source.json b/src/pubskill_lib/_source.json new file mode 100644 index 0000000..a4e6110 --- /dev/null +++ b/src/pubskill_lib/_source.json @@ -0,0 +1,6 @@ +{ + "schema": "pubskill-lib.source", + "version": 1, + "repository": "The-Interdependency/skill-lib", + "commit": "8de4f12d0f31ff94f41e4a0196c447c0cbe20faf" +} diff --git a/src/pubskill_lib/audit.py b/src/pubskill_lib/audit.py index e0ad99b..65a1c5c 100644 --- a/src/pubskill_lib/audit.py +++ b/src/pubskill_lib/audit.py @@ -9,6 +9,7 @@ import argparse import json +from importlib.resources import files import re import shlex from urllib.parse import unquote, urlsplit @@ -491,10 +492,9 @@ def _check_package_scripts(target, sink, unresolved): def _read_source_pin(): - root = Path(__file__).resolve().parents[2] - text = _read_text(root / "SOURCE.md") or "" - match = PIN_PATTERN.search(text) - return match.group(1) if match else "hmmm" + """Read the canonical identity shipped with both source and wheel installs.""" + data = json.loads(files("pubskill_lib").joinpath("_source.json").read_text(encoding="utf-8")) + return data["commit"] def audit_path(target_path, source_pin=None): diff --git a/tests/test_provenance.py b/tests/test_provenance.py index 79202c7..3172e2d 100644 --- a/tests/test_provenance.py +++ b/tests/test_provenance.py @@ -17,6 +17,10 @@ def _source_pin() -> str: class PublicationProvenanceTests(unittest.TestCase): + def test_installed_source_pin_matches_publication_pin(self): + from pubskill_lib.audit import _read_source_pin + self.assertEqual(_source_pin(), _read_source_pin()) + def test_source_pin_matches_vendored_skill_manifest(self): vendored = (REPO / ".agents" / "skills" / "README.md").read_text(encoding="utf-8") vendored_pin = PIN_RE.search(vendored) @@ -42,10 +46,10 @@ def test_local_secret_files_are_ignored(self): self.assertIn(".env.*", ignore) self.assertIn("*.egg-info/", ignore) - def test_readme_does_not_claim_unpublished_v020_tag(self): + def test_readme_exposes_reproducible_release_gate(self): readme = (REPO / "README.md").read_text(encoding="utf-8") - self.assertIn("release pending", readme) - self.assertNotIn("**shipped** — `v0.2`", readme) + self.assertIn("tools/build_release.py", readme) + self.assertIn("sha256sum -c SHA256SUMS", readme) if __name__ == "__main__": diff --git a/tests/test_release_compressor.py b/tests/test_release_compressor.py new file mode 100644 index 0000000..2385b40 --- /dev/null +++ b/tests/test_release_compressor.py @@ -0,0 +1,14 @@ +"""Usage: python -m unittest discover -s tests. Reject an unqualified compressor.""" +import unittest +from unittest.mock import patch +from tools.build_release import check_compressor + + +class CompressorTest(unittest.TestCase): + def test_compressor_identity_is_enforced(self): + with patch("tools.build_release.zlib.ZLIB_VERSION", "1.3.1"), patch("tools.build_release.zlib.ZLIB_RUNTIME_VERSION", "1.3.1"): + self.assertEqual(check_compressor()["runtime_version"], "1.3.1") + for compile_version, runtime_version in (("1.3", "1.3.1"), ("1.3.1", "1.3")): + with patch("tools.build_release.zlib.ZLIB_VERSION", compile_version), patch("tools.build_release.zlib.ZLIB_RUNTIME_VERSION", runtime_version): + with self.assertRaisesRegex(RuntimeError, "require zlib"): + check_compressor() diff --git a/tools/build_release.py b/tools/build_release.py new file mode 100644 index 0000000..60d7dfa --- /dev/null +++ b/tools/build_release.py @@ -0,0 +1,146 @@ +# === MODULE_BUILD === +# id: pubskill_release_builder +# module_name: build_release +# module_kind: instrument +# summary: builds normalized immutable wheel and sdist artifacts from a clean exact Git commit +# owner: The Interdependency +# public_surface: python tools/build_release.py --out DIRECTORY +# internal_surface: normalize_sdist, normalize_wheel, main +# auth_boundary: none +# storage_boundary: write +# storage_notes: temporary build directory and explicit output directory +# network_boundary: none +# network_notes: build dependencies must already be installed +# user_data_boundary: none +# admin_only: false +# tests: clean-install repository suite and two-build digest comparison documented in README +# rollout: explicit release build command +# rollback: return to previous published immutable release +# === END MODULE_BUILD === +# === CONTRACTS === +# id: release_build_binds_exact_source +# given: a clean source checkout and the pinned build toolchain +# then: artifacts derive only from Git HEAD; the manifest records source, doctrine, toolchain and output digests +# class: provenance +# === END CONTRACTS === + +"""Usage: install requirements-build.txt, then run with --out /tmp/release. + +Run twice into separate empty directories and compare wheel/sdist SHA-256 values. +The builder performs no publication. Clean-install and consumer gates are required +before publishing these bytes. Archive headers, order, and permissions are +normalized to the commit timestamp. Wheel payloads and RECORD are unchanged. +""" +from __future__ import annotations + +import argparse +import gzip +import hashlib +import importlib.metadata +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tarfile +import tempfile +import time +import zipfile +import zlib + + +def check_compressor() -> dict[str, str]: + expected = "1.3.1" + actual = {"implementation": "zlib", "compile_version": zlib.ZLIB_VERSION, "runtime_version": zlib.ZLIB_RUNTIME_VERSION} + if actual["compile_version"] != expected or actual["runtime_version"] != expected: + raise RuntimeError(f"release builds require zlib {expected} at compile time and runtime: {actual}") + return actual + + +def normalize_sdist(path: Path, destination: Path, epoch: int) -> None: + with path.open("rb") as raw, tarfile.open(fileobj=raw, mode="r:gz") as source: + with destination.open("wb") as output, gzip.GzipFile(filename="", mode="wb", fileobj=output, mtime=epoch) as compressed: + with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as target: + for member in sorted(source.getmembers(), key=lambda item: item.name): + if not (member.isfile() or member.isdir()): + raise ValueError(f"unexpected sdist member: {member.name}") + member.uid = member.gid = 0 + member.uname = member.gname = "" + member.mtime = epoch + member.pax_headers = {} + member.mode = 0o755 if member.isdir() or member.mode & 0o111 else 0o644 + if member.isfile(): + with source.extractfile(member) as stream: + target.addfile(member, stream) + else: + target.addfile(member) + + +def normalize_wheel(path: Path, destination: Path, epoch: int) -> None: + with zipfile.ZipFile(path) as source, zipfile.ZipFile(destination, "w") as target: + for member in sorted(source.infolist(), key=lambda item: item.filename): + normalized = zipfile.ZipInfo(member.filename, time.gmtime(epoch)[:6]) + normalized.create_system = 3 + mode = 0o40755 if member.is_dir() else 0o100755 if (member.external_attr >> 16) & 0o111 else 0o100644 + normalized.external_attr = (mode << 16) | (0x10 if member.is_dir() else 0) + target.writestr(normalized, source.read(member), compress_type=zipfile.ZIP_DEFLATED, compresslevel=9) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, required=True) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + def git(*args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + if git("status", "--porcelain"): + raise SystemExit("release build requires a clean Git checkout") + compressor = check_compressor() + commit = git("rev-parse", "HEAD") + epoch = int(git("show", "-s", "--format=%ct", commit)) + out = args.out.resolve() + out.mkdir(parents=True, exist_ok=True) + if any(out.iterdir()): + raise SystemExit("release output directory must be empty") + versions = {} + for requirement in git("show", f"{commit}:requirements-build.txt").splitlines(): + name, version = requirement.split("==") + versions[name] = importlib.metadata.version(name) + if versions[name] != version: + raise SystemExit(f"build toolchain mismatch: {name}") + with tempfile.TemporaryDirectory(prefix="pubskill-release-") as directory: + temporary = Path(directory) + source = temporary / "source" + source.mkdir() + archive = subprocess.check_output(["git", "-C", str(root), "archive", commit]) + if not hasattr(tarfile, "data_filter"): + raise SystemExit("release builds require Python with tarfile.data_filter support") + with tarfile.open(fileobj=io.BytesIO(archive)) as tree: + for member in tree.getmembers(): + if member.name.startswith("/") or ".." in Path(member.name).parts or not (member.isfile() or member.isdir()): + raise ValueError("unsafe source archive") + tree.extractall(source, filter="data") + environment = dict(os.environ, SOURCE_DATE_EPOCH=str(epoch), PYTHONHASHSEED="0") + environment.pop("PYTHONPATH", None) + subprocess.run([sys.executable, "-m", "build", "--no-isolation", "--outdir", str(temporary / "dist"), str(source)], check=True, env=environment) + for artifact in sorted((temporary / "dist").iterdir()): + if artifact.name.endswith(".tar.gz"): + normalize_sdist(artifact, out / artifact.name, epoch) + elif artifact.suffix == ".whl": + normalize_wheel(artifact, out / artifact.name, epoch) + else: + raise ValueError(f"unexpected build artifact: {artifact.name}") + hashes = {path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted(out.iterdir())} + doctrine = json.loads(git("show", f"{commit}:src/pubskill_lib/_source.json")) + manifest = {"schema": "pubskill-lib.release-manifest", "version": 1, "source_commit": commit, "source_tree": git("rev-parse", f"{commit}^{{tree}}"), "source_date_epoch": epoch, "skill_lib_commit": doctrine["commit"], "build_toolchain": versions, "build_python": sys.version, "compressor": compressor, "artifacts_sha256": hashes} + receipt = out / "release-manifest.json" + receipt.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") + hashes = dict(hashes) + hashes[receipt.name] = hashlib.sha256(receipt.read_bytes()).hexdigest() + (out / "SHA256SUMS").write_text("".join(f"{digest} {name}\n" for name, digest in sorted(hashes.items()))) + print(json.dumps(manifest, indent=2)) + + +if __name__ == "__main__": + main()