Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}"
Expand All @@ -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))
Expand All @@ -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}"
29 changes: 22 additions & 7 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -106,19 +107,20 @@ 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
cannot synthesize a tag from a default branch;
- 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
Expand All @@ -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.
Expand Down
23 changes: 18 additions & 5 deletions tests/test_release_immutable_publication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions tests/test_release_workflow_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading