diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 0000000..edaf972 --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,80 @@ +name: publish-pypi + +# A second way to reach the same bytes. The release is the trusted path; this +# is convenience, and it says so in the package metadata. +# +# Manual until all seven projects exist on the index: PyPI allows only three +# pending publishers at a time, so the wave order is register, publish, +# register the next. + +on: + workflow_dispatch: + inputs: + tag: + description: the release tag whose assets become wheels + required: true + type: string + +permissions: {} + +# One publication at a time per tag. A second run of the same tag would build +# the same wheels and meet PyPI's refusal to overwrite a file, which is the +# right refusal in the wrong place: better to queue than to race. +concurrency: + group: publish-pypi-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + publish: + name: wheels + runs-on: ubuntu-24.04 + timeout-minutes: 20 + # The environment the pending publisher names. Trusted publishing binds + # the exchange to it, so a workflow that forgets it is refused by PyPI + # rather than publishing from an unexpected place. + environment: pypi + permissions: + contents: read # read the repository and this tag's release assets; nothing here writes + id-token: write # the OIDC token PyPI exchanges for a short-lived upload right bound to this repository, workflow and environment + steps: + - name: Check out exact source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Take the release assets this tag published + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + mkdir -p assets + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir assets + # Six binaries and a checksum file, or the release is not one this + # can pack. Named here rather than discovered, so a partial release + # stops the job instead of publishing five platforms out of six. + test "$(find assets -type f | wc -l)" -eq 7 + + - name: The bytes must be the ones the release signed for + run: | + set -euo pipefail + cd assets + sha256sum --check --ignore-missing SHA256SUMS + + - name: Build one wheel per platform + env: + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + python3 tools/build_wheels.py --self-check + python3 tools/build_wheels.py --harness opencode --version "$TAG" --assets assets --out dist + ls -l dist + + - name: Publish to PyPI as opencode-setup-system + uses: pypa/gh-action-pypi-publish@a892a5a61159132606e93a2fa6f4358831b04d26 # v1.14.2 + with: + # Attestations are what make this channel checkable at all: PyPI + # keeps a PEP 740 bundle naming this repository, this workflow and + # this environment, which is the same triple the GitHub path proves + # a different way. + attestations: true diff --git a/crates/harness-runtime/src/lib.rs b/crates/harness-runtime/src/lib.rs index ce089fa..c2f7575 100644 --- a/crates/harness-runtime/src/lib.rs +++ b/crates/harness-runtime/src/lib.rs @@ -552,8 +552,11 @@ mod tests { #[test] fn the_reported_counts_come_from_a_marker_and_not_from_prose() { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - if !root.join("tools").is_dir() { - return; // A published tree ships neither tool nor workflow. + if !root.join(".github/workflows/conformance.yml").is_file() { + // Public trees ship neither half of this private report lane. They + // now do ship one unrelated tool (`build_wheels.py`), which proved + // that a directory was never a valid proxy for this subject. + return; } // The list is the source of the count below, rather than a number diff --git a/tools/build_wheels.py b/tools/build_wheels.py new file mode 100644 index 0000000..8958da7 --- /dev/null +++ b/tools/build_wheels.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 +"""Pack each released provider binary into a platform wheel, and nothing else. + +The seven are Rust programs and their trusted path is a GitHub release whose +build attestation the consumer verifies before running the bytes. This is a +*second* path, decided by the owner on 2026-09-02 and shaped with the consumer +in their `ADR-0141`: the same binary, installable with `pip`, named after the +repository it comes from. + +**A wheel here carries the executable and nothing else.** The first form we +agreed put the consumer's `release.json` in beside it; reading their code +killed that idea before it was built. Their manifest is a closed eighteen-field +document whose last three fields are `signing_key`, `signature_subject` and +`signature`, minted by an offline Ed25519 key that is *theirs* -- and for these +seven providers the consumer already writes the manifest itself, out of what an +attestation proved (`attested_bind`: `signing_key="attested"`, `signature=""`). +So shipping one here would have been a second source of truth, and would have +dragged a question about keys into a question about packaging. The wheel is the +payload; provenance is the index's to state and the consumer's to check. + +**Every platform tag is measured, not assumed.** The linux binaries are +dynamically linked and their highest required symbol version is read out of the +ELF itself -- `GLIBC_2.34` on both architectures, not the 2.39 of the runner +that built them, which is what an assumption would have written. The macOS +minimums come from the Mach-O load commands: `10.12` on x86_64 +(`LC_VERSION_MIN_MACOSX`), `11.0` on arm64 (`LC_BUILD_VERSION`). Windows needs +no floor. A tag claiming less than the truth installs on a machine the program +cannot run on, and the failure lands far from here. + +**The executable mode is part of the archive member's type.** A ZIP entry with +permission bits `0755` but no POSIX regular-file bit looks executable when the +archive is inspected directly, yet pip 25.1.1 installs it as `0664`. Each member +therefore records a complete `st_mode`, and the self-check installs and runs a +probe wheel without repairing it first. + +Usage: + python3 tools/build_wheels.py --harness claude --version 0.0.58 \ + --assets --out + python3 tools/build_wheels.py --self-check +""" + +from __future__ import annotations + +import argparse +import base64 +import csv +import hashlib +import io +import json +import os +import stat +import struct +import subprocess +import sys +import tempfile +import zipfile +from dataclasses import dataclass +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +#: `harness id -> (distribution name, provider id)`. The distribution is named +#: after the repository, which is the owner's instruction and also removes a +#: translation: the consumer's own map from harness to repository has this +#: shape already. +HARNESSES: dict[str, str] = { + "claude": "claude-setup-system", + "codex": "codex-setup-system", + "grok": "grok-setup-system", + "pi": "pi-setup-system", + "cursor": "cursor-setup-system", + "opencode": "opencode-setup-system", + "antigravity": "antigravity-setup-system", +} + +#: `release asset suffix -> wheel platform tag`. Each floor is read from the +#: bytes; see the module docstring. `manylinux_2_34` is what the ELF requires, +#: and a wheel may always claim an *older* floor than the machine that built it +#: only when the symbols agree -- which is why this is measured per release +#: rather than written once. +PLATFORMS: dict[str, str] = { + "x86_64-unknown-linux-gnu": "manylinux_2_34_x86_64", + "aarch64-unknown-linux-gnu": "manylinux_2_34_aarch64", + "x86_64-apple-darwin": "macosx_10_12_x86_64", + "aarch64-apple-darwin": "macosx_11_0_arm64", + "x86_64-pc-windows-msvc.exe": "win_amd64", + "aarch64-pc-windows-msvc.exe": "win_arm64", +} + +SUMMARY = "Install, select, back up and restore complete {product} setups." + + +@dataclass(frozen=True) +class Built: + """One wheel, with the facts a caller checks it by.""" + + path: Path + tag: str + member: str + digest: str + + +def _urlsafe(digest: bytes) -> str: + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +def glibc_floor(binary: bytes) -> str | None: + """The highest `GLIBC_x.y` an ELF asks for, or None when it is not an ELF.""" + if binary[:4] != b"\x7fELF": + return None + found: list[tuple[int, int]] = [] + marker = b"GLIBC_" + start = 0 + while (at := binary.find(marker, start)) != -1: + end = at + len(marker) + while end < len(binary) and binary[end : end + 1] in b"0123456789.": + end += 1 + text = binary[at + len(marker) : end].decode("ascii", "replace") + parts = text.split(".") + if len(parts) >= 2 and all(part.isdigit() for part in parts[:2]): + found.append((int(parts[0]), int(parts[1]))) + start = end + if not found: + return None + major, minor = max(found) + return f"{major}.{minor}" + + +def macos_minimum(binary: bytes) -> str | None: + """The minimum macOS version a Mach-O declares, or None when it is not one.""" + if len(binary) < 32: + return None + magic = struct.unpack(" len(binary): + return None + command, size = struct.unpack("> 16}.{(minimum >> 8) & 0xFF}" + if command == 0x24 and offset + 12 <= len(binary): # LC_VERSION_MIN_MACOSX + minimum = struct.unpack("> 16}.{(minimum >> 8) & 0xFF}" + offset += size + return None + + +def measured_tag(suffix: str, binary: bytes) -> str: + """The declared tag, refused when the bytes disagree with the table. + + The table is a claim; these two readings are the evidence. A release whose + binaries move to a newer glibc or a later macOS minimum stops the build + here rather than shipping a wheel that installs where it cannot run. + """ + declared = PLATFORMS[suffix] + if "manylinux" in declared: + floor = glibc_floor(binary) + if floor is None: + raise SystemExit(f"{suffix}: declared {declared} and the file is not an ELF") + if f"manylinux_{floor.replace('.', '_')}_" not in declared: + raise SystemExit( + f"{suffix}: the binary requires GLIBC_{floor} and the tag says " + f"{declared}; measure the release and update PLATFORMS" + ) + if "macosx" in declared: + minimum = macos_minimum(binary) + if minimum is None: + raise SystemExit(f"{suffix}: declared {declared} and the file is not a Mach-O") + if f"macosx_{minimum.replace('.', '_')}_" not in declared: + raise SystemExit( + f"{suffix}: the binary's minimum is macOS {minimum} and the tag says " + f"{declared}; measure the release and update PLATFORMS" + ) + return declared + + +def wheel_bytes( + *, distribution: str, version: str, tag: str, member: str, payload: bytes, summary: str +) -> bytes: + """One wheel, deterministic: fixed order, fixed timestamps, no extras.""" + package = distribution.replace("-", "_") + dist_info = f"{package}-{version}.dist-info" + metadata = "\n".join( + [ + "Metadata-Version: 2.4", + f"Name: {distribution}", + f"Version: {version}", + f"Summary: {summary}", + "License-Expression: AGPL-3.0-or-later", + "Requires-Python: >=3.9", + f"Project-URL: Repository, https://github.com/NDDev-OpenNetwork/{distribution}", + "Classifier: Operating System :: MacOS", + "Classifier: Operating System :: Microsoft :: Windows", + "Classifier: Operating System :: POSIX :: Linux", + "", + f"The {distribution} provider binary, the same bytes its GitHub release", + "carries at this version. The wheel is a delivery path, not a different", + "program: the trusted path remains the release, whose build attestation", + "an `ai-stp` consumer verifies before running anything.", + "", + ] + ) + wheel = "\n".join( + [ + "Wheel-Version: 1.0", + f"Generator: nddev-setup-systems {version}", + "Root-Is-Purelib: false", + f"Tag: py3-none-{tag}", + "", + ] + ) + entries: list[tuple[str, bytes, int]] = [ + (f"{package}/__init__.py", b"", 0o644), + (f"{package}/bin/{member}", payload, 0o755), + (f"{dist_info}/METADATA", metadata.encode(), 0o644), + (f"{dist_info}/WHEEL", wheel.encode(), 0o644), + ] + record = io.StringIO() + writer = csv.writer(record, lineterminator="\n") + for name, data, _ in entries: + writer.writerow([name, f"sha256={_urlsafe(hashlib.sha256(data).digest())}", len(data)]) + writer.writerow([f"{dist_info}/RECORD", "", ""]) + entries.append((f"{dist_info}/RECORD", record.getvalue().encode(), 0o644)) + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for name, data, mode in entries: + info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + # A POSIX mode without the file type is not a complete `st_mode`. + # `zipfile`'s extractor happens to read the permission bits, while + # pip treats such a member as an ordinary non-executable file. A + # regular-file marker is therefore part of the wheel's contract, + # not archive decoration. + info.external_attr = (stat.S_IFREG | mode) << 16 + info.create_system = 3 + archive.writestr(info, data) + return buffer.getvalue() + + +def check_pip_preserves_executable() -> None: + """Install a wheel without repair and prove its payload remains runnable.""" + if os.name == "nt": + return + payload = b"#!/bin/sh\nprintf 'wheel-mode-ok\\n'\n" + wheel_name = "mode_probe-0.0.0-py3-none-any.whl" + with tempfile.TemporaryDirectory(prefix="setup-wheel-mode-") as temporary: + root = Path(temporary) + wheel = root / wheel_name + wheel.write_bytes( + wheel_bytes( + distribution="mode-probe", + version="0.0.0", + tag="any", + member="mode-probe", + payload=payload, + summary="Wheel executable-mode probe.", + ) + ) + target = root / "installed" + subprocess.run( + [ + sys.executable, + "-m", + "pip", + "install", + "--quiet", + "--no-deps", + "--target", + str(target), + str(wheel), + ], + check=True, + ) + installed = target / "mode_probe" / "bin" / "mode-probe" + if installed.read_bytes() != payload: + raise SystemExit("pip changed the provider payload bytes") + mode = installed.stat().st_mode + if not mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH): + raise SystemExit(f"pip installed the provider payload as {mode:o}, not executable") + completed = subprocess.run( + [str(installed)], check=True, capture_output=True, text=True + ) + if completed.stdout != "wheel-mode-ok\n": + raise SystemExit(f"the installed wheel payload answered {completed.stdout!r}") + + +def build(harness: str, version: str, assets: Path, out: Path) -> list[Built]: + """Every platform wheel for one harness, from its release assets.""" + distribution = HARNESSES[harness] + product = distribution.removesuffix("-setup-system") + out.mkdir(parents=True, exist_ok=True) + built: list[Built] = [] + for suffix, _ in PLATFORMS.items(): + asset = assets / f"{distribution}-{suffix}" + if not asset.is_file(): + raise SystemExit(f"{asset.name} is not in {assets}; the release is incomplete") + payload = asset.read_bytes() + tag = measured_tag(suffix, payload) + member = distribution + (".exe" if suffix.endswith(".exe") else "") + data = wheel_bytes( + distribution=distribution, + version=version, + tag=tag, + member=member, + payload=payload, + summary=SUMMARY.format(product=product), + ) + package = distribution.replace("-", "_") + path = out / f"{package}-{version}-py3-none-{tag}.whl" + path.write_bytes(data) + built.append( + Built(path, tag, member, "sha256:" + hashlib.sha256(payload).hexdigest()) + ) + print(f" {path.name} {member} {len(payload)} byte(s)") + return built + + +def self_check() -> int: + """Prove the reader discriminates before it is trusted with a release. + + Three planted defects and three truths, in one run: an ELF asking for a + newer glibc than the tag claims must be refused, a Mach-O with a later + minimum must be refused, the honest readings must come back exactly, and + pip must preserve and run an executable archived with its complete mode. + """ + elf = b"\x7fELF" + b"\x00" * 60 + b"GLIBC_2.34\x00GLIBC_2.17\x00" + assert glibc_floor(elf) == "2.34", glibc_floor(elf) + newer = elf.replace(b"GLIBC_2.34", b"GLIBC_2.41") + assert glibc_floor(newer) == "2.41" + try: + measured_tag("x86_64-unknown-linux-gnu", newer) + except SystemExit as refusal: + assert "requires GLIBC_2.41" in str(refusal), refusal + else: + raise SystemExit("a newer glibc was not refused") + assert glibc_floor(b"not an elf") is None + # The other half, and the sweep's planted defect found it missing: a check + # that only refuses proves nothing about a table that has been loosened. + # A tag claiming an older floor than the bytes require passes every + # refusal test and installs on a machine the program cannot run on. + honest = measured_tag("x86_64-unknown-linux-gnu", elf) + assert honest == PLATFORMS["x86_64-unknown-linux-gnu"], honest + assert "manylinux_2_34_" in honest, honest + header = struct.pack(" int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--self-check", action="store_true") + parser.add_argument("--harness", choices=sorted(HARNESSES)) + parser.add_argument("--version") + parser.add_argument("--assets", type=Path) + parser.add_argument("--out", type=Path, default=ROOT / "target" / "wheels") + parsed = parser.parse_args(argv) + if parsed.self_check: + return self_check() + if not (parsed.harness and parsed.version and parsed.assets): + parser.error("--harness, --version and --assets are required") + print(f"{HARNESSES[parsed.harness]} {parsed.version}") + built = build(parsed.harness, parsed.version, parsed.assets, parsed.out) + print(f"RESULT wheels={len(built)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:]))