From a03a92dceb9a7379e28ba561dd368b66c2691a41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 28 Sep 2026 14:56:17 +0900 Subject: [PATCH 1/3] fix(release): publish hash-locked runtime dependencies --- .github/workflows/release.yml | 11 ++++++++-- docs/RELEASING.md | 29 +++++++++++++++++++------ tests/test_release_workflow_contract.py | 14 ++++++++++++ 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 195b136cb..e94fa5bdf 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,6 +401,7 @@ jobs: test -s release-notes.md test -s sbom-download/cyclonedx-sbom.json test -s "dist/contextual_orchestrator-${RELEASE_VERSION}-py3-none-any.whl" + test -s dist/requirements.lock (cd dist && sha256sum -c SHA256SUMS) - name: Create the annotated release tag @@ -436,6 +439,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 +492,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 +511,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 +535,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_workflow_contract.py b/tests/test_release_workflow_contract.py index 4f546e970..6cb25ab48 100644 --- a/tests/test_release_workflow_contract.py +++ b/tests/test_release_workflow_contract.py @@ -322,6 +322,20 @@ 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 '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() From 9bc6c72affc4d9df386a8c3002626ee4f0ea7c70 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 28 Sep 2026 14:59:25 +0900 Subject: [PATCH 2/3] fix(release): require complete wheel and lock manifest --- .github/workflows/release.yml | 7 ++++++- tests/test_release_workflow_contract.py | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e94fa5bdf..e59f17ec0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -402,7 +402,12 @@ jobs: test -s sbom-download/cyclonedx-sbom.json test -s "dist/contextual_orchestrator-${RELEASE_VERSION}-py3-none-any.whl" test -s dist/requirements.lock - (cd dist && sha256sum -c SHA256SUMS) + 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' diff --git a/tests/test_release_workflow_contract.py b/tests/test_release_workflow_contract.py index 6cb25ab48..9f3ad24cb 100644 --- a/tests/test_release_workflow_contract.py +++ b/tests/test_release_workflow_contract.py @@ -331,6 +331,8 @@ def test_exact_commit_dependency_lock_is_verified_as_a_release_asset() -> None: 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 From 95ad076b47f8e7964ce1ca60c6be8eebfb8ed7d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 28 Sep 2026 16:02:54 +0900 Subject: [PATCH 3/3] test(release): model locked assets in publication rehearsal --- tests/test_release_immutable_publication.py | 23 ++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) 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)