diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 195b136cb..e59f17ec0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -319,7 +319,9 @@ jobs: echo "::error::The exact-commit wheel changed between isolated builds." >&2 exit 1 fi - (cd dist && sha256sum "${wheel##*/}" > SHA256SUMS) + test -s requirements.lock + cp requirements.lock dist/requirements.lock + (cd dist && sha256sum "${wheel##*/}" requirements.lock > SHA256SUMS) release_site="${RUNNER_TEMP}/release-package" uv pip install --no-deps --python 3.12 --target "${release_site}" "${wheel}" RELEASE_SITE="${release_site}" python - <<'PY' @@ -399,7 +401,13 @@ jobs: test -s release-notes.md test -s sbom-download/cyclonedx-sbom.json test -s "dist/contextual_orchestrator-${RELEASE_VERSION}-py3-none-any.whl" - (cd dist && sha256sum -c SHA256SUMS) + test -s dist/requirements.lock + test -s dist/SHA256SUMS + expected_manifest="$(cd dist && sha256sum "contextual_orchestrator-${RELEASE_VERSION}-py3-none-any.whl" requirements.lock)" + if [ "$(cat dist/SHA256SUMS)" != "${expected_manifest}" ]; then + echo "::error::Release manifest must contain exactly the verified wheel and dependency lock." >&2 + exit 1 + fi - name: Create the annotated release tag if: env.TAG_RESUME != 'true' @@ -436,6 +444,7 @@ jobs: (.draft == false and .immutable == true and ([.assets[] | select(.name == "cyclonedx-sbom.json" and (.size | type) == "number" and .size > 0)] | length) == 1 and ([.assets[] | select(.name == $wheel and (.size | type) == "number" and .size > 0)] | length) == 1 and + ([.assets[] | select(.name == "requirements.lock" and (.size | type) == "number" and .size > 0)] | length) == 1 and ([.assets[] | select(.name == "SHA256SUMS" and (.size | type) == "number" and .size > 0)] | length) == 1)) ' <<<"${release}" >/dev/null; then echo "::error::Existing release is neither a resumable draft nor a complete immutable release; refusing mutation." >&2 @@ -488,7 +497,7 @@ jobs: set -euo pipefail wheel="contextual_orchestrator-${RELEASE_VERSION}-py3-none-any.whl" (cd dist && sha256sum -c SHA256SUMS) - for asset in "${wheel}" SHA256SUMS; do + for asset in "${wheel}" requirements.lock SHA256SUMS; do attached="$(gh release view "v${RELEASE_VERSION}" --repo "${GITHUB_REPOSITORY}" --json assets --jq '.assets[].name')" if ! printf '%s\n' "${attached}" | grep -qxF "${asset}"; then gh release upload "v${RELEASE_VERSION}" "dist/${asset}" --repo "${GITHUB_REPOSITORY}" @@ -507,6 +516,7 @@ jobs: .tag_name == $tag and .prerelease == false and ([.assets[] | select(.name == "cyclonedx-sbom.json" and (.size | type) == "number" and .size > 0)] | length) == 1 and ([.assets[] | select(.name == $wheel and (.size | type) == "number" and .size > 0)] | length) == 1 and + ([.assets[] | select(.name == "requirements.lock" and (.size | type) == "number" and .size > 0)] | length) == 1 and ([.assets[] | select(.name == "SHA256SUMS" and (.size | type) == "number" and .size > 0)] | length) == 1 and ((.draft == true and .immutable == false) or (.draft == false and .immutable == true)) @@ -530,5 +540,7 @@ jobs: --repo "${GITHUB_REPOSITORY}" gh release verify-asset "v${RELEASE_VERSION}" "dist/contextual_orchestrator-${RELEASE_VERSION}-py3-none-any.whl" \ --repo "${GITHUB_REPOSITORY}" + gh release verify-asset "v${RELEASE_VERSION}" dist/requirements.lock \ + --repo "${GITHUB_REPOSITORY}" gh release verify-asset "v${RELEASE_VERSION}" dist/SHA256SUMS \ --repo "${GITHUB_REPOSITORY}" diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 91745cd05..dc009c3e2 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -8,8 +8,8 @@ for the full design and its explicit non-goals. ## What a release is, and is not A canonical release is an annotated git tag `vX.Y.Z`, a published GitHub -Release with `immutable: true`, its verified Python wheel and SHA-256 manifest, -and its verified mandatory SBOM. A versioned +Release with `immutable: true`, its verified Python wheel, hash-locked runtime +requirements, SHA-256 manifest, and mandatory SBOM. A versioned URL alone is not evidence that GitHub has locked the tag and assets. - `.../releases/tag/vX.Y.Z` identifies the version-specific release. Consumers @@ -69,7 +69,8 @@ check those contracts separately before replacing a source pin. The read-only release job builds the Python wheel from that same checkout, installs it into an isolated directory to verify its declared version and package contents, compares two builds at the source commit's fixed timestamp, - records its SHA-256 digest, and passes both files to publication. + records the wheel and `requirements.lock` SHA-256 digests, and passes both + files to publication. The build and isolated install use `uv` with Python 3.12 so they do not rely on runner-global `setuptools` or `pip`. This wheel is the installable Python package; the separately built Rust decision @@ -106,11 +107,12 @@ check those contracts separately before replacing a source pin. section over GitHub's 125,000-character body limit on a line boundary and linking the complete CHANGELOG.md at that commit; - downloads the exact-commit mandatory CycloneDX SBOM, builds the Python - wheel, records its SHA-256 digest, and passes these with the notes to the + wheel, copies the exact-commit dependency lock, records both SHA-256 + digests, and passes these with the notes to the publisher through an Actions artifact. 5. The write-scoped `publish` job, only after verification succeeds: - rechecks fresh-main identity and exact-target checks before mutation; - - requires non-empty notes, SBOM and wheel inputs, then checks the wheel + - requires non-empty notes, SBOM, wheel and dependency-lock inputs, then checks both against the SHA-256 manifest; - creates and pushes an annotated tag only for a fresh publication; - verifies the exact remote tag object and its peeled target, so GitHub @@ -118,7 +120,7 @@ check those contracts separately before replacing a source pin. - admits an existing release only as a typed, matching-tag, non-prerelease Draft, or a complete already-published immutable release; - creates a new release with `--verify-tag --draft`; - - attaches any missing SBOM, wheel and manifest **only while the release is + - attaches any missing SBOM, wheel, dependency lock and manifest **only while the release is a Draft**, then downloads each and compares its bytes with the verified input; - publishes the verified Draft. An already-public immutable release is @@ -127,9 +129,22 @@ check those contracts separately before replacing a source pin. matching-tag and `immutable: true`, then runs `gh release verify` and `gh release verify-asset` for each asset. 6. Confirm the version-specific release, successful publication run, exact - tag/commit, immutable state, wheel digest, SBOM and signed asset attestations. Do not admit + tag/commit, immutable state, wheel and lock digests, SBOM and signed asset attestations. Do not admit a release merely because it appears in the GitHub Releases list. +Install the downloaded wheel with the matching release's `requirements.lock`: + +```bash +sha256sum -c SHA256SUMS +uv pip install --require-hashes -r requirements.lock +uv pip install --no-deps contextual_orchestrator-X.Y.Z-py3-none-any.whl +``` + +Use an isolated Python 3.12 environment. The lock covers the gateway's +`api`, `db`, and `queue` extras; the wheel is installed without a second +dependency resolution. Pin and verify the release assets before running these +commands. This does not prove the deployed process uses that release. + ## Recovery and known limitations An interrupted Draft is recoverable without deleting it or moving its tag. diff --git a/tests/test_release_immutable_publication.py b/tests/test_release_immutable_publication.py index d197e1afa..c4e704540 100644 --- a/tests/test_release_immutable_publication.py +++ b/tests/test_release_immutable_publication.py @@ -20,6 +20,11 @@ _TAG = "v0.2.0" _SBOM = '{"bomFormat":"CycloneDX","version":1}\n' _WHEEL = "contextual_orchestrator-0.2.0-py3-none-any.whl" +_LOCK = "alpha==1.0 --hash=sha256:" + "a" * 64 + "\n" +_SUMS = ( + f"{hashlib.sha256(b'wheel').hexdigest()} {_WHEEL}\n" + f"{hashlib.sha256(_LOCK.encode()).hexdigest()} requirements.lock\n" +) _START = "Create the GitHub Release" _RESUME = "Validate the existing release lifecycle" @@ -131,7 +136,11 @@ def _steps() -> list[tuple[str, str, str]]: def _existing(*, draft: bool, immutable: bool, asset: str | None = _SBOM) -> dict: """Construct a remote lifecycle state, not a successful mock verdict.""" assets = [] if asset is None else [{"name": "cyclonedx-sbom.json", "size": len(asset)}] - assets.extend([{"name": _WHEEL, "size": 5}, {"name": "SHA256SUMS", "size": 90}]) + assets.extend([ + {"name": _WHEEL, "size": 5}, + {"name": "requirements.lock", "size": len(_LOCK)}, + {"name": "SHA256SUMS", "size": len(_SUMS)}, + ]) return { "tag_name": _TAG, "draft": draft, @@ -156,10 +165,14 @@ def _run(tmp_path: Path, **changes: object) -> tuple[subprocess.CompletedProcess (tmp_path / "sbom-download/cyclonedx-sbom.json").write_text(_SBOM) (tmp_path / "dist").mkdir() (tmp_path / "dist" / _WHEEL).write_text("wheel") - digest = hashlib.sha256(b"wheel").hexdigest() - sums = f"{digest} {_WHEEL}\n" - (tmp_path / "dist/SHA256SUMS").write_text(sums) - state["asset_bytes"] = {"cyclonedx-sbom.json": _SBOM, _WHEEL: "wheel", "SHA256SUMS": sums} + (tmp_path / "dist/requirements.lock").write_text(_LOCK) + (tmp_path / "dist/SHA256SUMS").write_text(_SUMS) + state["asset_bytes"] = { + "cyclonedx-sbom.json": _SBOM, + _WHEEL: "wheel", + "requirements.lock": _LOCK, + "SHA256SUMS": _SUMS, + } if existing_bytes is not None: if isinstance(existing_bytes, dict): state["asset_bytes"].update(existing_bytes) diff --git a/tests/test_release_workflow_contract.py b/tests/test_release_workflow_contract.py index 4f546e970..9f3ad24cb 100644 --- a/tests/test_release_workflow_contract.py +++ b/tests/test_release_workflow_contract.py @@ -322,6 +322,22 @@ def test_sbom_is_mandatory_before_release_publication() -> None: assert sbom_block.count("exit 1") >= 3 +def test_exact_commit_dependency_lock_is_verified_as_a_release_asset() -> None: + """A wheel consumer must be able to install the attested dependency closure.""" + workflow = _workflow_text() + verify = _job_block(workflow, "verify") + publish = _job_block(workflow, "publish") + + assert "cp requirements.lock dist/requirements.lock" in verify + assert 'sha256sum "${wheel##*/}" requirements.lock > SHA256SUMS' in verify + assert "test -s dist/requirements.lock" in publish + assert 'sha256sum "contextual_orchestrator-${RELEASE_VERSION}-py3-none-any.whl" requirements.lock' in publish + assert '"$(cat dist/SHA256SUMS)" != "${expected_manifest}"' in publish + assert 'for asset in "${wheel}" requirements.lock SHA256SUMS' in publish + assert publish.count('.name == "requirements.lock"') == 2 + assert "gh release verify-asset \"v${RELEASE_VERSION}\" dist/requirements.lock" in publish + + def test_concurrency_group_serializes_release_runs() -> None: """Two dispatched releases must never race the same tag push.""" workflow = _workflow_text()