diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 759ebcf..78d2ad4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,8 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} @@ -31,6 +33,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.13" @@ -38,7 +42,31 @@ jobs: - run: python -m pip install --upgrade pip build twine - run: python -m build - run: python -m twine check dist/* - - name: Smoke-test the wheel in a clean environment + - name: Upload distributions for cross-version smoke tests + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: python-package-distributions + path: dist/ + if-no-files-found: error + retention-days: 7 + + wheel-smoke: + needs: package + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: ${{ matrix.python-version }} + - name: Download built distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-package-distributions + path: dist/ + - name: Smoke-test the wheel outside the checkout shell: bash run: | python -m venv "$RUNNER_TEMP/wheel-smoke" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..19284c1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,73 @@ +name: Release distributions + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-and-attest: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.13" + - name: Install the project and release tooling + run: python -m pip install ".[dev]" + - name: Require the tag to match the package version + if: github.ref_type == 'tag' + env: + RELEASE_TAG: ${{ github.ref_name }} + run: python -c "import os, unified_llm; expected = f'v{unified_llm.__version__}'; assert os.environ['RELEASE_TAG'] == expected, f'tag must be {expected}'" + - name: Verify the exact release source + run: | + python -m ruff check . + python -m ruff format --check . + python -m mypy unified_llm tests examples + python -m pytest --cov=unified_llm --cov-report=term-missing + - name: Build and validate distributions + run: | + python -m build + python -m twine check dist/* + - name: Generate SLSA provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: dist/* + - name: Store distributions + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: python-package-distributions + path: dist/ + if-no-files-found: error + retention-days: 30 + + publish-to-pypi: + if: github.event_name == 'push' && github.ref_type == 'tag' + needs: build-and-attest + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/unified-llm + permissions: + id-token: write + steps: + - name: Download attested distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-package-distributions + path: dist/ + - name: Publish through PyPI Trusted Publishing + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + packages-dir: dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ef4cfad..80d1094 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to this project will be documented here. The project follows - Content-free sync/async attempt observation and inspectable provider health snapshots. - Cross-request transient-failure cooldown that deprioritizes unhealthy routes without removing last-resort fallback. - A canonical support-ticket triage reference consumer and deterministic public-API contract fixture. +- Cross-version installed-wheel smoke tests, supply-chain contract tests, and an attested PyPI Trusted Publishing workflow. ### Changed diff --git a/README.md b/README.md index 830fde9..41b937a 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ python -m build python -m twine check dist/* ``` -Tests use fakes and `httpx.MockTransport`; they do not need credentials and do not call external APIs. CI runs lint, type checking, tests, package build, metadata checks, and a wheel smoke test. See [.github/workflows/ci.yml](.github/workflows/ci.yml). +Tests use fakes and `httpx.MockTransport`; they do not need credentials and do not call external APIs. CI runs lint, type checking, tests, package build, metadata checks, and installed-wheel smoke tests on every supported Python version. See [.github/workflows/ci.yml](.github/workflows/ci.yml). ## Architecture @@ -222,11 +222,11 @@ See [SUPPORT.md](SUPPORT.md) for the information to include and the prerelease s The repository is a coherent release candidate for local evaluation. Public package publication remains blocked on: 1. owner-authorized live endpoint smoke tests; -2. owner authorization and configuration for PyPI trusted publishing/signing. +2. owner configuration and approval of the prepared PyPI Trusted Publishing environment. The package name was not present on PyPI when checked on 2026-07-28; re-check immediately before publishing. No package has been published or production infrastructure changed by this work. -See [docs/PRODUCTIZATION.md](docs/PRODUCTIZATION.md) for the evidence, remaining work, and acceptance criteria. +See [docs/PRODUCTIZATION.md](docs/PRODUCTIZATION.md) for the evidence and remaining gates, and [docs/RELEASING.md](docs/RELEASING.md) for the attested, tokenless release process. ## Contributing diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..1bda1ad --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,41 @@ +# Release process + +No package has been published from this repository yet. This process makes releases deliberate, reviewable, and recoverable; merging to `main` never uploads a package. + +## One-time owner setup + +1. Confirm that the `unified-llm` PyPI project name is available or controlled by Samsarix LLC. +2. In PyPI, register a pending GitHub Trusted Publisher for owner `Deathcharge`, repository `unified-llm`, workflow `release.yml`, and environment `pypi`. +3. Create the GitHub `pypi` environment and require manual approval. Restrict deployment to protected version tags when repository policy permits it. +4. Protect `main` and release tags, require the CI checks, and review any change to `.github/workflows/release.yml` as a credential-equivalent security change. Release builds intentionally do not restore dependency caches. +5. Ensure at least two Samsarix-controlled recovery methods exist for the PyPI and GitHub owner accounts. + +Do not add a long-lived PyPI token to repository secrets. The workflow requests a short-lived OIDC credential only inside the `pypi` environment. + +## Candidate gate + +Before tagging: + +1. update `pyproject.toml`, `unified_llm.__version__`, and `CHANGELOG.md` to the same version; +2. verify the exact commit with Ruff, mypy, pytest/coverage, build, Twine, and the installed-wheel smoke test; +3. record any live endpoint conformance evidence described in `docs/CONSUMER_CONTRACT.md`, or explicitly defer publication; +4. run the release workflow manually from the candidate commit and verify its build provenance and downloadable artifacts; manual runs never publish; +5. review the source distribution and wheel contents, dependency metadata, license, owner/support identity, and artifact digests. + +## Publication + +Create and push an annotated tag exactly matching `v` plus the package version, for example `v0.1.0`. The release workflow rejects a mismatched tag, rebuilds the source distribution and wheel, checks their metadata, creates GitHub-hosted SLSA provenance, and pauses at the protected `pypi` environment. An owner must inspect the run and approve that deployment before PyPI receives anything. + +The official PyPA publishing action uses Trusted Publishing and uploads PyPI attestations by default. After approval, verify the project page, both distributions, their hashes and attestations, and installation in a clean supported Python environment. Then create the GitHub release from the same tag and attach or link the verification record. + +## Rollback and incident response + +PyPI release files and versions are immutable. Do not overwrite or reuse a version. If a release is faulty: + +1. yank the affected version on PyPI with a concise reason; +2. publish a corrected higher patch version through the same reviewed workflow; +3. mark the GitHub release and changelog clearly; +4. if provenance or publisher identity is suspect, disable the PyPI trusted publisher and GitHub environment before investigating; +5. follow `SECURITY.md` for any confidentiality or integrity issue. + +Existing users can remain pinned to the last verified artifact while the correction is prepared. diff --git a/tests/test_release_contract.py b/tests/test_release_contract.py new file mode 100644 index 0000000..603329d --- /dev/null +++ b/tests/test_release_contract.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import re +from pathlib import Path + +import unified_llm + +ROOT = Path(__file__).parents[1] +WORKFLOWS = ROOT / ".github" / "workflows" + + +def test_package_version_is_consistent() -> None: + pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + match = re.search(r'^version = "([^"]+)"$', pyproject, flags=re.MULTILINE) + assert match is not None + assert match.group(1) == unified_llm.__version__ + assert f"## {unified_llm.__version__} -" in (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + + +def test_all_third_party_workflow_actions_are_commit_pinned() -> None: + for workflow in WORKFLOWS.glob("*.yml"): + source = workflow.read_text(encoding="utf-8") + actions = re.findall(r"^\s*-?\s*uses:\s*([^\s#]+)", source, flags=re.MULTILINE) + assert actions, f"{workflow.name} must contain at least one action" + for action in actions: + assert re.fullmatch(r"[^@]+@[0-9a-f]{40}", action), f"mutable action reference in {workflow.name}: {action}" + + +def test_release_workflow_uses_tokenless_approved_publication() -> None: + source = (WORKFLOWS / "release.yml").read_text(encoding="utf-8") + + assert "github.event_name == 'push' && github.ref_type == 'tag'" in source + assert re.search(r"environment:\s+name: pypi", source) + assert "id-token: write" in source + assert "pypa/gh-action-pypi-publish@" in source + assert "actions/attest@" in source + assert "python -m pytest --cov=unified_llm" in source + assert "password:" not in source + assert "secrets." not in source