diff --git a/.github/workflows/release-candidate-validation.yml b/.github/workflows/release-candidate-validation.yml new file mode 100644 index 00000000..6daf0326 --- /dev/null +++ b/.github/workflows/release-candidate-validation.yml @@ -0,0 +1,273 @@ +name: Release Candidate Validation + +on: + workflow_dispatch: + inputs: + candidate_sha: + description: "Immutable 40-character commit SHA to validate" + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-candidate-${{ inputs.candidate_sha }} + cancel-in-progress: false + +jobs: + authorize: + name: Authorize immutable candidate request + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Require default-branch workflow and full SHA + env: + CANDIDATE_SHA: ${{ inputs.candidate_sha }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + WORKFLOW_REF: ${{ github.ref }} + run: | + test "${WORKFLOW_REF}" = "refs/heads/${DEFAULT_BRANCH}" + [[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]] + + build: + name: Build candidate artifacts + needs: authorize + runs-on: ubuntu-latest + timeout-minutes: 12 + steps: + - name: Checkout candidate + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ inputs.candidate_sha }} + path: candidate + persist-credentials: false + + - name: Bind checkout to requested SHA + env: + CANDIDATE_SHA: ${{ inputs.candidate_sha }} + run: | + actual_sha="$(git -C candidate rev-parse HEAD)" + test "${actual_sha}" = "${CANDIDATE_SHA}" + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + + - name: Install isolated build tools + run: | + python -m pip install --upgrade pip + python -m pip install build wheel "setuptools>=77" + + - name: Build wheel and source distribution + working-directory: candidate + run: | + python -m build --no-isolation --outdir dist + + - name: Upload untrusted build output + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: neural-release-build-${{ inputs.candidate_sha }} + path: candidate/dist/* + if-no-files-found: error + retention-days: 1 + + validate: + name: Validate source and artifact identity + needs: [authorize, build] + runs-on: ubuntu-latest + timeout-minutes: 8 + outputs: + version: ${{ steps.contract.outputs.version }} + steps: + - name: Checkout trusted validation harness + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.workflow_sha }} + path: harness + persist-credentials: false + + - name: Checkout candidate source without executing it + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ inputs.candidate_sha }} + path: candidate + persist-credentials: false + + - name: Download untrusted build output + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: neural-release-build-${{ inputs.candidate_sha }} + path: artifacts + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + + - name: Install trusted validation tools + run: | + python -m pip install --upgrade pip + python -m pip install packaging twine + + - name: Bind source checkout and validate public contacts + env: + CANDIDATE_SHA: ${{ inputs.candidate_sha }} + run: | + actual_sha="$(git -C candidate rev-parse HEAD)" + test "${actual_sha}" = "${CANDIDATE_SHA}" + test -z "$(git -C candidate status --short)" + python harness/scripts/validate_release_candidate.py validate-source candidate + + - name: Validate artifact contract + id: contract + run: | + version="$( + python harness/scripts/validate_release_candidate.py \ + project-version candidate/pyproject.toml + )" + echo "version=${version}" >> "${GITHUB_OUTPUT}" + python -m twine check --strict artifacts/* + python harness/scripts/validate_release_candidate.py validate-artifacts \ + --project-file candidate/pyproject.toml \ + artifacts/* + + - name: Record artifact digests + env: + CANDIDATE_SHA: ${{ inputs.candidate_sha }} + EXPECTED_VERSION: ${{ steps.contract.outputs.version }} + run: | + mkdir -p evidence + sha256sum artifacts/* | tee evidence/SHA256SUMS + { + echo "### Trusted release candidate validation" + echo "" + echo "- Candidate: \`${CANDIDATE_SHA}\`" + echo "- Version: \`${EXPECTED_VERSION}\`" + echo "- Publication performed: no" + echo "" + echo '```text' + cat evidence/SHA256SUMS + echo '```' + } >> "${GITHUB_STEP_SUMMARY}" + + - name: Upload contract-checked artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: neural-release-contract-${{ inputs.candidate_sha }} + path: | + artifacts/* + evidence/SHA256SUMS + if-no-files-found: error + retention-days: 1 + + wheel-smoke: + name: Validate installed wheel + needs: [authorize, validate] + runs-on: ubuntu-latest + timeout-minutes: 8 + steps: + - name: Checkout trusted smoke harness + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.workflow_sha }} + path: harness + persist-credentials: false + + - name: Download contract-checked artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: neural-release-contract-${{ inputs.candidate_sha }} + path: validated + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + + - name: Install and import wheel without dependency substitution + env: + EXPECTED_VERSION: ${{ needs.validate.outputs.version }} + run: | + python -m venv "${RUNNER_TEMP}/neural-release-wheel" + "${RUNNER_TEMP}/neural-release-wheel/bin/python" -m pip install \ + --no-deps validated/artifacts/*.whl + cd "${RUNNER_TEMP}" + "${RUNNER_TEMP}/neural-release-wheel/bin/python" \ + "${GITHUB_WORKSPACE}/harness/scripts/release_candidate_smoke.py" \ + --expected-version "${EXPECTED_VERSION}" + + sdist-smoke: + name: Validate installed source distribution + needs: [authorize, validate] + runs-on: ubuntu-latest + timeout-minutes: 8 + steps: + - name: Checkout trusted smoke harness + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.workflow_sha }} + path: harness + persist-credentials: false + + - name: Download contract-checked artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: neural-release-contract-${{ inputs.candidate_sha }} + path: validated + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + + - name: Install and import sdist without dependency substitution + env: + EXPECTED_VERSION: ${{ needs.validate.outputs.version }} + run: | + python -m venv "${RUNNER_TEMP}/neural-release-sdist" + "${RUNNER_TEMP}/neural-release-sdist/bin/python" -m pip install \ + "setuptools>=77" wheel + "${RUNNER_TEMP}/neural-release-sdist/bin/python" -m pip install \ + --no-build-isolation --no-deps validated/artifacts/*.tar.gz + cd "${RUNNER_TEMP}" + "${RUNNER_TEMP}/neural-release-sdist/bin/python" \ + "${GITHUB_WORKSPACE}/harness/scripts/release_candidate_smoke.py" \ + --expected-version "${EXPECTED_VERSION}" + + promote: + name: Promote fully validated evidence + needs: [authorize, validate, wheel-smoke, sdist-smoke] + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - name: Download contract-checked artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: neural-release-contract-${{ inputs.candidate_sha }} + path: validated + + - name: Record completed validation + env: + CANDIDATE_SHA: ${{ inputs.candidate_sha }} + EXPECTED_VERSION: ${{ needs.validate.outputs.version }} + run: | + { + echo "### Release candidate passed" + echo "" + echo "- Candidate: \`${CANDIDATE_SHA}\`" + echo "- Version: \`${EXPECTED_VERSION}\`" + echo "- Structural contract: passed" + echo "- Wheel install smoke: passed" + echo "- Source distribution install smoke: passed" + echo "- Publication performed: no" + } >> "${GITHUB_STEP_SUMMARY}" + + - name: Upload fully validated artifacts and digests + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: neural-release-validated-${{ inputs.candidate_sha }} + path: validated + if-no-files-found: error + retention-days: 14 diff --git a/pyproject.toml b/pyproject.toml index b5d25885..c5646362 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,7 @@ dev = [ "bump2version>=1.0.0", "build>=0.10.0", "twine>=4.0.0", + "tomli>=2.0.1; python_version < '3.11'", ] docs = [ "mkdocs>=1.5.0", diff --git a/scripts/release_candidate_smoke.py b/scripts/release_candidate_smoke.py new file mode 100644 index 00000000..4ab6e864 --- /dev/null +++ b/scripts/release_candidate_smoke.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Verify that an installed Neural artifact exposes its declared version.""" + +from __future__ import annotations + +import argparse +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path + + +class ReleaseSmokeError(AssertionError): + """An installed release artifact failed its smoke contract.""" + + +def validate_install( + expected_version: str, + distribution_version: str, + module_version: str | None, + module_file: Path, +) -> None: + """Validate version agreement and prove import came from an installed artifact.""" + if distribution_version != expected_version: + raise ReleaseSmokeError( + f"distribution version {distribution_version!r} != expected {expected_version!r}" + ) + if module_version != expected_version: + raise ReleaseSmokeError( + f"neural.__version__ {module_version!r} != expected {expected_version!r}" + ) + if not {"site-packages", "dist-packages"}.intersection(module_file.parts): + raise ReleaseSmokeError(f"neural imported outside an installed package: {module_file}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--expected-version", required=True) + args = parser.parse_args() + + try: + distribution_version = version("neural-sdk") + except PackageNotFoundError as exc: + raise ReleaseSmokeError("neural-sdk distribution is not installed") from exc + + import neural + + validate_install( + args.expected_version, + distribution_version, + getattr(neural, "__version__", None), + Path(neural.__file__).resolve(), + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_release_candidate.py b/scripts/validate_release_candidate.py new file mode 100644 index 00000000..9877fc58 --- /dev/null +++ b/scripts/validate_release_candidate.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""Trusted validation for immutable Neural release candidates.""" + +from __future__ import annotations + +import argparse +import re +import tarfile +import zipfile +from email.parser import BytesParser +from email.policy import default +from pathlib import Path +from typing import BinaryIO + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - exercised by Python 3.10 CI + import tomli as tomllib +from packaging.utils import ( + InvalidSdistFilename, + InvalidWheelFilename, + canonicalize_name, + parse_sdist_filename, + parse_wheel_filename, +) +from packaging.version import InvalidVersion, Version + +_COMMIT_SHA = re.compile(r"^[0-9a-f]{40}$") +_BLOCKED_DOMAINS = tuple(f"neural-sdk.{suffix}".encode("ascii") for suffix in ("dev", "com")) +_EXPECTED_METADATA = { + "Name": "neural-sdk", + "Author-email": "Hudson Aikins ", + "Maintainer-email": "Advanced Intellectual Labs LLC ", +} +_IGNORED_SOURCE_PARTS = { + ".git", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ".venv", + "__pycache__", + "build", + "dist", + "tests", + "venv", +} + + +class ReleaseCandidateError(ValueError): + """An immutable release candidate failed a deterministic safety check.""" + + +def validate_candidate_sha(value: str) -> str: + """Require an immutable full Git commit SHA.""" + if not _COMMIT_SHA.fullmatch(value): + raise ReleaseCandidateError("candidate_sha must be a lowercase 40-character commit SHA") + return value + + +def project_version(project_file: Path) -> str: + """Read the package version used to build release artifacts.""" + try: + with project_file.open("rb") as stream: + version = tomllib.load(stream)["project"]["version"] + except (OSError, KeyError, tomllib.TOMLDecodeError) as exc: + raise ReleaseCandidateError( + f"cannot read project version from {project_file}: {exc}" + ) from exc + if not isinstance(version, str) or not version: + raise ReleaseCandidateError(f"invalid project version in {project_file}") + try: + return str(Version(version)) + except InvalidVersion as exc: + raise ReleaseCandidateError( + f"invalid PEP 440 project version in {project_file}: {version!r}" + ) from exc + + +def _blocked_domains_in_stream(stream: BinaryIO) -> set[str]: + found: set[str] = set() + overlap = max(map(len, _BLOCKED_DOMAINS)) - 1 + tail = b"" + + while chunk := stream.read(64 * 1024): + normalized = (tail + chunk).lower() + found.update(domain.decode("ascii") for domain in _BLOCKED_DOMAINS if domain in normalized) + tail = normalized[-overlap:] + + return found + + +def _source_files(root: Path): + for path in root.rglob("*"): + if path.is_symlink(): + relative = path.relative_to(root) + raise ReleaseCandidateError(f"source symlink is not allowed: {relative}") + if not path.is_file(): + continue + relative = path.relative_to(root) + if any(part in _IGNORED_SOURCE_PARTS for part in relative.parts): + continue + yield relative, path + + +def validate_source(root: Path) -> None: + """Reject uncontrolled contact domains from public source surfaces.""" + if root.is_symlink() or not root.is_dir(): + raise ReleaseCandidateError(f"candidate source must be a directory: {root}") + + violations = [] + for relative, path in _source_files(root): + with path.open("rb") as stream: + domains = _blocked_domains_in_stream(stream) + violations.extend(f"{relative}: {domain}" for domain in sorted(domains)) + + if violations: + raise ReleaseCandidateError( + "uncontrolled contact domains in source: " + ", ".join(violations) + ) + + +def _validate_wheel(path: Path) -> list[str]: + violations = [] + try: + with zipfile.ZipFile(path) as archive: + for member in archive.infolist(): + if member.is_dir(): + continue + with archive.open(member) as stream: + domains = _blocked_domains_in_stream(stream) + violations.extend( + f"{path.name}:{member.filename}: {domain}" for domain in sorted(domains) + ) + except (OSError, zipfile.BadZipFile) as exc: + raise ReleaseCandidateError(f"invalid wheel {path}: {exc}") from exc + return violations + + +def _validate_sdist(path: Path) -> list[str]: + violations = [] + try: + with tarfile.open(path, mode="r:*") as archive: + for member in archive: + if not member.isfile(): + continue + stream = archive.extractfile(member) + if stream is None: + continue + with stream: + domains = _blocked_domains_in_stream(stream) + violations.extend( + f"{path.name}:{member.name}: {domain}" for domain in sorted(domains) + ) + except (OSError, tarfile.TarError) as exc: + raise ReleaseCandidateError(f"invalid sdist {path}: {exc}") from exc + return violations + + +def _wheel_metadata(path: Path, expected_version: str) -> bytes: + expected_path = f"neural_sdk-{expected_version}.dist-info/METADATA" + try: + with zipfile.ZipFile(path) as archive: + names = [ + member.filename + for member in archive.infolist() + if not member.is_dir() and member.filename.endswith(".dist-info/METADATA") + ] + if len(names) != 1: + raise ReleaseCandidateError( + f"expected one wheel METADATA file in {path}; found {len(names)}" + ) + if names[0] != expected_path: + raise ReleaseCandidateError( + f"invalid wheel metadata path in {path.name}: " + f"expected {expected_path!r}, found {names[0]!r}" + ) + return archive.read(names[0]) + except (OSError, zipfile.BadZipFile) as exc: + raise ReleaseCandidateError(f"invalid wheel {path}: {exc}") from exc + + +def _sdist_metadata(path: Path, expected_version: str) -> bytes: + expected_path = f"neural_sdk-{expected_version}/PKG-INFO" + try: + with tarfile.open(path, mode="r:*") as archive: + members = [ + member + for member in archive + if member.isfile() + and member.name.endswith("/PKG-INFO") + and member.name.count("/") == 1 + ] + if len(members) != 1: + raise ReleaseCandidateError( + f"expected one top-level sdist PKG-INFO file in {path}; found {len(members)}" + ) + if members[0].name != expected_path: + raise ReleaseCandidateError( + f"invalid sdist metadata path in {path.name}: " + f"expected {expected_path!r}, found {members[0].name!r}" + ) + stream = archive.extractfile(members[0]) + if stream is None: + raise ReleaseCandidateError(f"cannot read sdist metadata from {path}") + with stream: + return stream.read() + except (OSError, tarfile.TarError) as exc: + raise ReleaseCandidateError(f"invalid sdist {path}: {exc}") from exc + + +def _validate_core_metadata(path: Path, content: bytes, expected_version: str) -> None: + metadata = BytesParser(policy=default).parsebytes(content) + expected = {"Version": expected_version, **_EXPECTED_METADATA} + mismatches = [ + f"{header}: expected {value!r}, found {metadata.get(header)!r}" + for header, value in expected.items() + if metadata.get(header) != value + ] + if mismatches: + raise ReleaseCandidateError( + f"invalid core metadata in {path.name}: " + "; ".join(mismatches) + ) + + +def _validate_artifact_identity(path: Path, expected_version: str) -> str: + if path.is_symlink() or not path.is_file(): + raise ReleaseCandidateError(f"release artifact must be a regular file: {path}") + + try: + if path.suffix == ".whl": + name, version, _, _ = parse_wheel_filename(path.name) + artifact_type = "wheel" + elif path.name.endswith((".tar.gz", ".tgz")): + name, version = parse_sdist_filename(path.name) + artifact_type = "sdist" + else: + raise ReleaseCandidateError(f"unsupported release artifact: {path}") + except (InvalidWheelFilename, InvalidSdistFilename) as exc: + raise ReleaseCandidateError( + f"invalid release artifact filename {path.name}: {exc}" + ) from exc + + if canonicalize_name(name) != canonicalize_name("neural-sdk"): + raise ReleaseCandidateError( + f"invalid distribution name in {path.name}: expected 'neural-sdk', found {name!r}" + ) + if version != Version(expected_version): + raise ReleaseCandidateError( + f"invalid version in {path.name}: expected {expected_version!r}, found {str(version)!r}" + ) + return artifact_type + + +def validate_artifacts(paths: list[Path], expected_version: str) -> None: + """Require one wheel and one sdist with approved contacts and version.""" + violations = [] + wheels = [] + sdists = [] + + for path in paths: + artifact_type = _validate_artifact_identity(path, expected_version) + if artifact_type == "wheel": + wheels.append(path) + violations.extend(_validate_wheel(path)) + else: + sdists.append(path) + violations.extend(_validate_sdist(path)) + + if len(wheels) != 1 or len(sdists) != 1: + raise ReleaseCandidateError( + f"expected one wheel and one sdist; found {len(wheels)} wheel(s) " + f"and {len(sdists)} sdist(s)" + ) + if violations: + raise ReleaseCandidateError( + "uncontrolled contact domains in artifacts: " + ", ".join(violations) + ) + _validate_core_metadata( + wheels[0], _wheel_metadata(wheels[0], expected_version), expected_version + ) + _validate_core_metadata( + sdists[0], _sdist_metadata(sdists[0], expected_version), expected_version + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + sha_parser = subparsers.add_parser("validate-sha") + sha_parser.add_argument("candidate_sha") + + version_parser = subparsers.add_parser("project-version") + version_parser.add_argument("project_file", type=Path) + + source_parser = subparsers.add_parser("validate-source") + source_parser.add_argument("root", type=Path) + + artifact_parser = subparsers.add_parser("validate-artifacts") + artifact_parser.add_argument("artifacts", nargs="+", type=Path) + artifact_parser.add_argument("--project-file", type=Path, required=True) + + args = parser.parse_args() + try: + if args.command == "validate-sha": + print(validate_candidate_sha(args.candidate_sha)) + elif args.command == "project-version": + print(project_version(args.project_file)) + elif args.command == "validate-source": + validate_source(args.root) + else: + validate_artifacts(args.artifacts, project_version(args.project_file)) + except ReleaseCandidateError as exc: + parser.exit(2, f"release candidate validation failed: {exc}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_candidate_validation.py b/tests/test_release_candidate_validation.py new file mode 100644 index 00000000..9cfe9ec3 --- /dev/null +++ b/tests/test_release_candidate_validation.py @@ -0,0 +1,221 @@ +import io +import tarfile +import zipfile +from pathlib import Path + +import pytest + +from scripts.release_candidate_smoke import ReleaseSmokeError, validate_install +from scripts.validate_release_candidate import ( + ReleaseCandidateError, + project_version, + validate_artifacts, + validate_candidate_sha, + validate_source, +) + + +def _uncontrolled_contact(suffix: str) -> str: + return f"EVIL@NEURAL-SDK.{suffix}" + + +def _metadata( + *, + name: str = "neural-sdk", + version: str = "0.4.2", + author: str = "Hudson Aikins ", + maintainer: str = "Advanced Intellectual Labs LLC ", +) -> bytes: + return ( + f"Metadata-Version: 2.4\nName: {name}\nVersion: {version}\n" + f"Author-email: {author}\nMaintainer-email: {maintainer}\n" + ).encode() + + +def _write_wheel(path: Path, content: bytes) -> None: + with zipfile.ZipFile(path, mode="w") as archive: + archive.writestr("neural_sdk-0.4.2.dist-info/METADATA", content) + + +def _write_sdist(path: Path, content: bytes) -> None: + with tarfile.open(path, mode="w:gz") as archive: + member = tarfile.TarInfo("neural_sdk-0.4.2/PKG-INFO") + member.size = len(content) + archive.addfile(member, io.BytesIO(content)) + + +def test_candidate_sha_requires_immutable_full_commit() -> None: + sha = "7aa6c856f5ad18b42375254fe8b52bc50cc868d4" + assert validate_candidate_sha(sha) == sha + + for invalid in ("main", "7aa6c85", sha.upper(), f"{sha}0"): + with pytest.raises(ReleaseCandidateError): + validate_candidate_sha(invalid) + + +def test_project_version_reads_pyproject(tmp_path: Path) -> None: + project_file = tmp_path / "pyproject.toml" + project_file.write_text('[project]\nversion = "1.2.3rc1"\n', encoding="utf-8") + + assert project_version(project_file) == "1.2.3rc1" + + +def test_project_version_rejects_non_pep440_shell_content(tmp_path: Path) -> None: + project_file = tmp_path / "pyproject.toml" + project_file.write_text('[project]\nversion = "\\"; echo PWNED; #"\n', encoding="utf-8") + + with pytest.raises(ReleaseCandidateError, match="invalid PEP 440"): + project_version(project_file) + + +def test_source_scan_is_case_insensitive_and_ignores_tests(tmp_path: Path) -> None: + public_doc = tmp_path / "docs" / "contact.md" + public_doc.parent.mkdir() + public_doc.write_text("Contact hudson@intelip.co", encoding="utf-8") + + fixture = tmp_path / "tests" / "malicious.txt" + fixture.parent.mkdir() + fixture.write_text(_uncontrolled_contact("DEV"), encoding="utf-8") + + validate_source(tmp_path) + + public_doc.write_text(_uncontrolled_contact("COM"), encoding="utf-8") + with pytest.raises(ReleaseCandidateError, match="docs/contact.md"): + validate_source(tmp_path) + + +def test_source_scan_rejects_symlinks(tmp_path: Path) -> None: + target = tmp_path / "target.txt" + target.write_text("safe", encoding="utf-8") + (tmp_path / "public-link.txt").symlink_to(target) + + with pytest.raises(ReleaseCandidateError, match="source symlink is not allowed"): + validate_source(tmp_path) + + +def test_source_scan_rejects_missing_root(tmp_path: Path) -> None: + with pytest.raises(ReleaseCandidateError, match="candidate source must be a directory"): + validate_source(tmp_path / "missing") + + +def test_clean_wheel_and_sdist_pass(tmp_path: Path) -> None: + wheel = tmp_path / "neural_sdk-0.4.2-py3-none-any.whl" + sdist = tmp_path / "neural_sdk-0.4.2.tar.gz" + _write_wheel(wheel, _metadata()) + _write_sdist(sdist, _metadata()) + + validate_artifacts([wheel, sdist], "0.4.2") + + +def test_artifact_identity_requires_neural_distribution_name(tmp_path: Path) -> None: + wheel = tmp_path / "attacker-0.4.2-py3-none-any.whl" + sdist = tmp_path / "attacker-0.4.2.tar.gz" + _write_wheel(wheel, _metadata(name="attacker")) + _write_sdist(sdist, _metadata(name="attacker")) + + with pytest.raises(ReleaseCandidateError, match="invalid distribution name"): + validate_artifacts([wheel, sdist], "0.4.2") + + +@pytest.mark.parametrize("artifact_type", ["wheel", "sdist"]) +def test_mixed_case_uncontrolled_contacts_block_artifacts( + tmp_path: Path, artifact_type: str +) -> None: + wheel = tmp_path / "neural_sdk-0.4.2-py3-none-any.whl" + sdist = tmp_path / "neural_sdk-0.4.2.tar.gz" + _write_wheel( + wheel, + _metadata() + + ( + f"Contact: {_uncontrolled_contact('DEV')}".encode() if artifact_type == "wheel" else b"" + ), + ) + _write_sdist( + sdist, + _metadata() + + ( + f"Contact: {_uncontrolled_contact('COM')}".encode() if artifact_type == "sdist" else b"" + ), + ) + + with pytest.raises(ReleaseCandidateError, match="uncontrolled contact domains"): + validate_artifacts([wheel, sdist], "0.4.2") + + +@pytest.mark.parametrize( + ("wheel_metadata", "sdist_metadata", "match"), + [ + (_metadata(version="9.9.9"), _metadata(), "Version"), + (_metadata(author="Attacker "), _metadata(), "Author-email"), + (_metadata(), _metadata(maintainer="Attacker "), "Maintainer-email"), + ], +) +def test_artifact_metadata_must_match_release_contract( + tmp_path: Path, + wheel_metadata: bytes, + sdist_metadata: bytes, + match: str, +) -> None: + wheel = tmp_path / "neural_sdk-0.4.2-py3-none-any.whl" + sdist = tmp_path / "neural_sdk-0.4.2.tar.gz" + _write_wheel(wheel, wheel_metadata) + _write_sdist(sdist, sdist_metadata) + + with pytest.raises(ReleaseCandidateError, match=match): + validate_artifacts([wheel, sdist], "0.4.2") + + +def test_installed_smoke_requires_version_agreement_and_installed_path() -> None: + validate_install( + "0.4.2", + "0.4.2", + "0.4.2", + Path("/tmp/venv/lib/python3.11/site-packages/neural/__init__.py"), + ) + + with pytest.raises(ReleaseSmokeError, match="distribution version"): + validate_install( + "0.4.2", + "0.4.1", + "0.4.2", + Path("/tmp/venv/lib/python3.11/site-packages/neural/__init__.py"), + ) + with pytest.raises(ReleaseSmokeError, match="installed package"): + validate_install( + "0.4.2", + "0.4.2", + "0.4.2", + Path("/workspace/neural/__init__.py"), + ) + + +def test_workflow_is_read_only_non_publishing_and_sha_bound() -> None: + workflow = Path(".github/workflows/release-candidate-validation.yml").read_text( + encoding="utf-8" + ) + + assert "workflow_dispatch:" in workflow + assert "candidate_sha:" in workflow + assert "permissions:\n contents: read" in workflow + assert 'test "${WORKFLOW_REF}" = "refs/heads/${DEFAULT_BRANCH}"' in workflow + assert workflow.count("ref: ${{ github.workflow_sha }}") == 3 + assert workflow.count("persist-credentials: false") == 5 + assert 'test "${actual_sha}" = "${CANDIDATE_SHA}"' in workflow + assert workflow.count("CANDIDATE_SHA: ${{ inputs.candidate_sha }}") == 5 + assert '"${{ inputs.candidate_sha }}"' not in workflow + assert workflow.count("--no-deps") == 2 + assert "needs: [authorize, build]" in workflow + assert "needs: [authorize, validate, wheel-smoke, sdist-smoke]" in workflow + assert workflow.index("Upload fully validated artifacts and digests") > workflow.index( + "Validate installed source distribution" + ) + assert "name: neural-release-contract-${{ inputs.candidate_sha }}" in workflow + assert "name: neural-release-validated-${{ inputs.candidate_sha }}" in workflow + assert "uses: actions/checkout@v4" not in workflow + assert "uses: actions/setup-python@v5" not in workflow + assert "uses: actions/upload-artifact@v4" not in workflow + assert "uses: actions/download-artifact@v4" not in workflow + assert "twine upload" not in workflow + assert "PYPI_API_TOKEN" not in workflow + assert "TESTPYPI_API_TOKEN" not in workflow + assert "deploy" not in workflow.lower() diff --git a/uv.lock b/uv.lock index aa9ac672..dba05b3c 100644 --- a/uv.lock +++ b/uv.lock @@ -1845,6 +1845,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "twine" }, { name = "types-requests" }, ] @@ -1896,6 +1897,7 @@ requires-dist = [ { name = "simplefix", specifier = ">=1.0.17" }, { name = "sqlalchemy", marker = "extra == 'deployment'", specifier = ">=2.0.0" }, { name = "textblob", marker = "extra == 'sentiment'", specifier = ">=0.17.1" }, + { name = "tomli", marker = "python_full_version < '3.11' and extra == 'dev'", specifier = ">=2.0.1" }, { name = "torch", marker = "extra == 'sentiment'", specifier = ">=1.12.0" }, { name = "transformers", marker = "extra == 'sentiment'", specifier = ">=4.21.0" }, { name = "twine", marker = "extra == 'dev'", specifier = ">=4.0.0" },