From d2094b53694775f187b8860b9ed17c75513da83f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 18:15:38 +0900 Subject: [PATCH 01/35] test(packaging): require fast-mlsirm on supported runtimes --- tests/test_fast_mlsirm_runtime_contract.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/test_fast_mlsirm_runtime_contract.py diff --git a/tests/test_fast_mlsirm_runtime_contract.py b/tests/test_fast_mlsirm_runtime_contract.py new file mode 100644 index 000000000..8a06a3da3 --- /dev/null +++ b/tests/test_fast_mlsirm_runtime_contract.py @@ -0,0 +1,21 @@ +"""Packaging contract for mandatory fast-mlsirm response-quality evaluation.""" + +from __future__ import annotations + +from pathlib import Path +import tomllib + + +def test_supported_python_floor_matches_fast_mlsirm_runtime() -> None: + """Every supported interpreter must install the mandatory psychometric runtime.""" + project_data = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"] + + assert project_data["requires-python"] == ">=3.12" + fast_mlsirm_dependencies = [ + dependency + for dependency in project_data["dependencies"] + if dependency.startswith("fast-mlsirm ") + ] + assert fast_mlsirm_dependencies == [ + "fast-mlsirm @ https://github.com/ContextualWisdomLab/fast-mlsirm/archive/refs/tags/v0.9.1.tar.gz" + ] From 3718b7b21b2c38fe99225a517d5952a17fb376f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 18:15:51 +0900 Subject: [PATCH 02/35] fix(packaging): require fast-mlsirm on supported runtimes --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index de9f90de1..1a4f7468a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,14 +3,14 @@ name = "contextual-orchestrator" version = "0.2.0" description = "Paper-grounded model orchestration lab with an enterprise admin console." readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.12" dependencies = [ "cryptography>=43.0", "opentelemetry-api>=1.30.0", "opentelemetry-sdk>=1.30.0", "opentelemetry-exporter-otlp-proto-http>=1.30.0", "jsonschema>=4.26.0", - "fast-mlsirm @ https://github.com/ContextualWisdomLab/fast-mlsirm/archive/refs/tags/v0.9.1.tar.gz ; python_full_version >= '3.12'", + "fast-mlsirm @ https://github.com/ContextualWisdomLab/fast-mlsirm/archive/refs/tags/v0.9.1.tar.gz", ] [project.optional-dependencies] From e83d6d0687b604513a033d07bc765233d01af6d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 21:12:46 +0900 Subject: [PATCH 03/35] build: refresh Python 3.12 runtime locks --- conductor/tech-stack.md | 2 +- requirements.lock | 70 +----- uv.lock | 460 +--------------------------------------- 3 files changed, 12 insertions(+), 520 deletions(-) diff --git a/conductor/tech-stack.md b/conductor/tech-stack.md index 673f97230..951f3865c 100644 --- a/conductor/tech-stack.md +++ b/conductor/tech-stack.md @@ -2,7 +2,7 @@ ## Language -Python 3.11+. +Python 3.12+. ## Dependencies diff --git a/requirements.lock b/requirements.lock index 9ecf016ce..0c9fca630 100644 --- a/requirements.lock +++ b/requirements.lock @@ -1,8 +1,8 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # -# pip-compile --extra=api --extra=db --extra=queue --generate-hashes --no-index --output-file=requirements.lock pyproject.toml +# pip-compile --extra=api --extra=db --extra=queue --generate-hashes --output-file=requirements.lock --strip-extras pyproject.toml # alembic==1.18.5 \ --hash=sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc \ @@ -20,10 +20,6 @@ anyio==4.14.1 \ --hash=sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72 \ --hash=sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e # via starlette -async-timeout==5.0.1 \ - --hash=sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c \ - --hash=sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3 - # via redis attrs==26.1.0 \ --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 @@ -362,11 +358,7 @@ cryptography==50.0.0 \ --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \ --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645 # via contextual-orchestrator (pyproject.toml) -exceptiongroup==1.3.1 \ - --hash=sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219 \ - --hash=sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598 - # via anyio -fast-mlsirm @ https://github.com/ContextualWisdomLab/fast-mlsirm/archive/refs/tags/v0.9.1.tar.gz ; python_full_version >= "3.12" \ +fast-mlsirm @ https://github.com/ContextualWisdomLab/fast-mlsirm/archive/refs/tags/v0.9.1.tar.gz \ --hash=sha256:c43af5151edd6e80546a19f0d8b5afc4b5d636edd8a0563462e329a3659a208b # via contextual-orchestrator (pyproject.toml) fastapi==0.138.2 \ @@ -571,7 +563,7 @@ markupsafe==3.0.3 \ --hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \ --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50 # via mako -numpy==2.5.2 ; python_full_version >= "3.12" \ +numpy==2.5.2 \ --hash=sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8 \ --hash=sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860 # via fast-mlsirm @@ -619,7 +611,7 @@ protobuf==7.36.0 \ # via # googleapis-common-protos # opentelemetry-proto -psycopg[binary]==3.3.4 \ +psycopg==3.3.4 \ --hash=sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a \ --hash=sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc # via contextual-orchestrator (pyproject.toml) @@ -1009,63 +1001,12 @@ starlette==1.3.1 \ --hash=sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0 \ --hash=sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6 # via fastapi -tomli==2.4.1 \ - --hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \ - --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \ - --hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 \ - --hash=sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d \ - --hash=sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd \ - --hash=sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26 \ - --hash=sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54 \ - --hash=sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6 \ - --hash=sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c \ - --hash=sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a \ - --hash=sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd \ - --hash=sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f \ - --hash=sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5 \ - --hash=sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9 \ - --hash=sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662 \ - --hash=sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9 \ - --hash=sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1 \ - --hash=sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585 \ - --hash=sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e \ - --hash=sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c \ - --hash=sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41 \ - --hash=sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f \ - --hash=sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085 \ - --hash=sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15 \ - --hash=sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7 \ - --hash=sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c \ - --hash=sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36 \ - --hash=sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076 \ - --hash=sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac \ - --hash=sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8 \ - --hash=sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232 \ - --hash=sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece \ - --hash=sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a \ - --hash=sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897 \ - --hash=sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d \ - --hash=sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4 \ - --hash=sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917 \ - --hash=sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396 \ - --hash=sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a \ - --hash=sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc \ - --hash=sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba \ - --hash=sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f \ - --hash=sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257 \ - --hash=sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30 \ - --hash=sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf \ - --hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 \ - --hash=sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049 - # via alembic typing-extensions==4.15.0 \ --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 # via # alembic # anyio - # cryptography - # exceptiongroup # fastapi # opentelemetry-api # opentelemetry-exporter-otlp-proto-http @@ -1078,7 +1019,6 @@ typing-extensions==4.15.0 \ # sqlalchemy # starlette # typing-inspection - # uvicorn typing-inspection==0.4.2 \ --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \ --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464 diff --git a/uv.lock b/uv.lock index 7a11636f2..5f2363155 100644 --- a/uv.lock +++ b/uv.lock @@ -1,11 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.10" -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version < '3.11'", -] +requires-python = ">=3.12" [[package]] name = "alembic" @@ -14,7 +9,6 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mako" }, { name = "sqlalchemy" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/16/2b/e4153978368de59918115c9e01d3ebf58a558a7285efa7e960c383c4b59a/alembic-1.19.1.tar.gz", hash = "sha256:e0fca0518118c78acc493e31bcb5402f190057aaf6df8b5b95ce94c4789cf648", size = 2070816, upload-time = "2026-08-08T16:32:01.565Z" } @@ -45,7 +39,6 @@ name = "anyio" version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] @@ -54,15 +47,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] -[[package]] -name = "async-timeout" -version = "5.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, -] - [[package]] name = "atheris" version = "3.1.0" @@ -100,31 +84,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be", size = 183779, upload-time = "2026-08-03T21:19:15.602Z" }, - { url = "https://files.pythonhosted.org/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b", size = 184178, upload-time = "2026-08-03T21:19:16.67Z" }, - { url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" }, - { url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" }, - { url = "https://files.pythonhosted.org/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98", size = 205422, upload-time = "2026-08-03T21:19:19.96Z" }, - { url = "https://files.pythonhosted.org/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9", size = 205444, upload-time = "2026-08-03T21:19:21.246Z" }, - { url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" }, - { url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" }, - { url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" }, - { url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" }, - { url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" }, - { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, - { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, - { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, - { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, - { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, - { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, - { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, - { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, - { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, - { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, - { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, - { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, @@ -207,38 +166,6 @@ version = "3.5.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/aa/554e2614f38fc34c58ff1d0911ae8535ad2516440d5482d76fe59f1088b0/charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa", size = 369072, upload-time = "2026-08-15T08:16:22.964Z" }, - { url = "https://files.pythonhosted.org/packages/03/6d/439231dfc3ccfa6f8c06477b7da2219cbd41a2de3d49084df8ec7b5100f2/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda", size = 251142, upload-time = "2026-08-15T08:16:24.81Z" }, - { url = "https://files.pythonhosted.org/packages/55/53/7d819bd23a00ef45039146fa2cce1daa2f0771e758c5653ee1f6edac91ed/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45", size = 240714, upload-time = "2026-08-15T08:16:26.392Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2c/45847198c16f4b38090cc7423b2b6a9008e438704d8ab413211832498d31/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab", size = 279637, upload-time = "2026-08-15T08:16:27.961Z" }, - { url = "https://files.pythonhosted.org/packages/69/2b/d8be3523ddf9f0b0f3e56d1359034aa10653a4d11564c697f802b4775766/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a", size = 276543, upload-time = "2026-08-15T08:16:29.399Z" }, - { url = "https://files.pythonhosted.org/packages/32/cd/4f564b8f132de25db594efc706897069f016790cea63a5669c9df2675f64/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3", size = 261644, upload-time = "2026-08-15T08:16:30.722Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e3/38b975422534a608f98c360e79c2f07c763d66dd4272300d45fb1fee54b0/charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb", size = 259609, upload-time = "2026-08-15T08:16:32.248Z" }, - { url = "https://files.pythonhosted.org/packages/87/bd/fbc24d825c66f1c74f6ccdea3742c3d8354a4888e86d1315a197fee69061/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f", size = 252457, upload-time = "2026-08-15T08:16:33.849Z" }, - { url = "https://files.pythonhosted.org/packages/b9/2d/918d0e98a0e679469ed05bb2d90c2088b4d315bb612969d8499f76fb5210/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea", size = 242240, upload-time = "2026-08-15T08:16:35.396Z" }, - { url = "https://files.pythonhosted.org/packages/20/c8/c36f6e0b2dfec351bd38cbc05362697e58bcd073d7dbd95154290c9714ce/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f", size = 280308, upload-time = "2026-08-15T08:16:36.825Z" }, - { url = "https://files.pythonhosted.org/packages/ca/7b/311b3e02e8c4092400c449c850a760d8c45d900983c83a70cc07208c551d/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182", size = 258679, upload-time = "2026-08-15T08:16:38.22Z" }, - { url = "https://files.pythonhosted.org/packages/b9/90/082cc45599c392f28c036a497f49e0634041a785fc3849c80ccf396d096f/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa", size = 277221, upload-time = "2026-08-15T08:16:39.62Z" }, - { url = "https://files.pythonhosted.org/packages/58/ad/b9aecf38d805cbcf84fa94f14c5d972a16561e20296a11dc799a5dcf3763/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818", size = 263799, upload-time = "2026-08-15T08:16:40.885Z" }, - { url = "https://files.pythonhosted.org/packages/b7/23/b38a20598d5a825f85d9d7636860e56ff0db1479f86497a6e485aa9326f7/charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20", size = 182037, upload-time = "2026-08-15T08:16:42.198Z" }, - { url = "https://files.pythonhosted.org/packages/d2/21/83fffb77864408b8bf0fe1ca603926401d6f8775a8e150b39aacc9958f8a/charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d", size = 206030, upload-time = "2026-08-15T08:16:43.787Z" }, - { url = "https://files.pythonhosted.org/packages/86/2e/b93135b5034b1157fb29554b0d06d4844ce62282f0e0a14036f93d7ee2e7/charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5", size = 185092, upload-time = "2026-08-15T08:16:45.177Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" }, - { url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" }, - { url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" }, - { url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" }, - { url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" }, - { url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" }, - { url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" }, - { url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" }, - { url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" }, - { url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" }, - { url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" }, - { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" }, - { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" }, - { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" }, { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, @@ -391,7 +318,7 @@ version = "0.2.0" source = { virtual = "." } dependencies = [ { name = "cryptography" }, - { name = "fast-mlsirm", marker = "python_full_version >= '3.12'" }, + { name = "fast-mlsirm" }, { name = "jsonschema" }, { name = "opentelemetry-api" }, { name = "opentelemetry-exporter-otlp-proto-http" }, @@ -410,7 +337,7 @@ db = [ { name = "sqlalchemy" }, ] fuzz = [ - { name = "atheris", marker = "python_full_version >= '3.12'" }, + { name = "atheris" }, ] queue = [ { name = "redis" }, @@ -430,7 +357,7 @@ requires-dist = [ { name = "alembic", marker = "extra == 'db'", specifier = ">=1.17" }, { name = "atheris", marker = "python_full_version >= '3.12' and extra == 'fuzz'", specifier = "==3.1.0" }, { name = "cryptography", specifier = ">=43.0" }, - { name = "fast-mlsirm", marker = "python_full_version >= '3.12'", url = "https://github.com/ContextualWisdomLab/fast-mlsirm/archive/refs/tags/v0.9.1.tar.gz" }, + { name = "fast-mlsirm", url = "https://github.com/ContextualWisdomLab/fast-mlsirm/archive/refs/tags/v0.9.1.tar.gz" }, { name = "fastapi", marker = "extra == 'api'", specifier = ">=0.128.0" }, { name = "greenlet", marker = "extra == 'db'", specifier = ">=3.2" }, { name = "hypothesis", marker = "extra == 'test'", specifier = ">=6.100" }, @@ -457,7 +384,6 @@ version = "50.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } wheels = [ @@ -500,24 +426,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, - { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, - { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, - { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, - { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, - { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, - { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] [[package]] @@ -573,25 +481,6 @@ version = "3.5.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/67/07ecd6d85c0f253363cbc4ac9e7dd048ca571a267fbccfea084d6009ac3b/greenlet-3.5.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:816230f469381ad0a43abc9fa8dda5a699e32fb78958dde32ded93213b70a667", size = 292971, upload-time = "2026-08-10T13:28:09.837Z" }, - { url = "https://files.pythonhosted.org/packages/24/35/426733bc24247ee17bb76df90f550c299ff5f8572b2bdd7cacb5c53fd994/greenlet-3.5.5-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5433cf291e0ef9114bd14d0d824db6e5e4a43033234bca48181a9597acca07b", size = 609290, upload-time = "2026-08-10T14:14:32.273Z" }, - { url = "https://files.pythonhosted.org/packages/9c/dc/17d3a5acceb2fd0bbc9682a228117b719cdfb68085e6dbb33fa325755f27/greenlet-3.5.5-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:19d59f068887d8c5907fc177f27683413ace3011b6ed646c0b309266e74a6502", size = 622650, upload-time = "2026-08-10T14:27:22.004Z" }, - { url = "https://files.pythonhosted.org/packages/74/3f/b31e6bd6adb8f80492efb6a47e8f9cd0e7335db5aac2795b1876d5afcfee/greenlet-3.5.5-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:86c5113d698cb8d927b2750bb1f1d59eefe3a37e0e0217491aee29a7f84ef52c", size = 629560, upload-time = "2026-08-10T14:30:04.733Z" }, - { url = "https://files.pythonhosted.org/packages/51/91/00b3c0566316c6f383ea16ee05388ec4f57f6e0afa49e72ae471eda8c47f/greenlet-3.5.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff00e12102358292087274dfb1669132387ff6e7920ebf9d85f4826ce0d3a56", size = 622819, upload-time = "2026-08-10T13:40:46.562Z" }, - { url = "https://files.pythonhosted.org/packages/18/5e/58c561efde575c4ca070030e2e0513e8d1b07b76d8a2d84a9bcef1becd42/greenlet-3.5.5-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:c69bed34470abfcd456984fdadaa18e62169af4480335c45f3c32d1d9c12e638", size = 425489, upload-time = "2026-08-10T14:29:59.768Z" }, - { url = "https://files.pythonhosted.org/packages/3c/78/52de4f7ac9152ad1dbc8f437895c1e573d30ee1e1427e0f91a280e2417b6/greenlet-3.5.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:523bb8e27614d77101ea7a8cf59f8d91219b72d5c29f6a038c92b50828bfa8d0", size = 1582164, upload-time = "2026-08-10T14:15:02.645Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ff/c3855a00c2417e8f61c7b9f0bc4f8f599c6928f5c15681ad251e78a91e05/greenlet-3.5.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f1e2db190db51c17433eee424803818cf0670bf049d9cfe0dd07be111d1aa7c4", size = 1648807, upload-time = "2026-08-10T13:40:27.471Z" }, - { url = "https://files.pythonhosted.org/packages/70/21/d29ff6a79dbd1ec1fe74c155d25d4c5a85e221d6b9ffc2cc7a709e7c8c39/greenlet-3.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:740e544169527b82695ce76af2f7ad6f030904658f2f3921a1d245771fb88cfc", size = 322832, upload-time = "2026-08-10T13:28:10.85Z" }, - { url = "https://files.pythonhosted.org/packages/4e/a3/07297917485ee2ca85bc3c8dc6ed85ad3fffcf424047fba62671dba68e97/greenlet-3.5.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61", size = 294165, upload-time = "2026-08-10T13:25:17.987Z" }, - { url = "https://files.pythonhosted.org/packages/db/51/6f732f9314cda54c5fd48a7620c7160f4f286967e8045ad94b9d66ce80b7/greenlet-3.5.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c", size = 613610, upload-time = "2026-08-10T14:14:33.829Z" }, - { url = "https://files.pythonhosted.org/packages/d8/c0/b27589e25d220289edcd4d582b2b17b83058d1a56d53d971b6ea1a34f10d/greenlet-3.5.5-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da", size = 625481, upload-time = "2026-08-10T14:27:23.647Z" }, - { url = "https://files.pythonhosted.org/packages/39/82/5c873dbb4fb001d22fbbd50e80d4c1b0181ddae106856132160f84b94e88/greenlet-3.5.5-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:abc8bc8d9f935cd685457545b6a53863a877fdc12c2c0f5ee9beee18d9db139c", size = 633329, upload-time = "2026-08-10T14:30:06.062Z" }, - { url = "https://files.pythonhosted.org/packages/51/2d/f2c928218ac52f26d7a2c188c171d1b7e728b23782cb3347e7b4fce1493a/greenlet-3.5.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cc6df89ec5302337adc9cf096221cbed2510fd444b0e0f1586cf0470740864", size = 624562, upload-time = "2026-08-10T13:40:48.064Z" }, - { url = "https://files.pythonhosted.org/packages/70/12/f7df98e72a8eb4a7edfec5a08d6d1a4ab53a52c95ba0b2ea6c10b8dd9bd0/greenlet-3.5.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:3134291427bb0f3526e9d90311988caf336eb43730e95244997a4fb15f45144f", size = 428145, upload-time = "2026-08-10T14:30:01.071Z" }, - { url = "https://files.pythonhosted.org/packages/3e/4a/92fc51d5d35912f4f06eec037ba347985defd0be47463a010a325634d9d2/greenlet-3.5.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d9b454c5fc48aeaa7c4337813dbf513a6870468e426438a04d922c6d0fe63db", size = 1584909, upload-time = "2026-08-10T14:15:04.343Z" }, - { url = "https://files.pythonhosted.org/packages/ac/58/ed98b80ac5738c149a5258544843c45601ade1fd70f61740cdaead6351b3/greenlet-3.5.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03551ed792cb1b4fc0277a0c60dfd8c343894a0ba06fe60dcd22f568b433da39", size = 1651184, upload-time = "2026-08-10T13:40:28.879Z" }, - { url = "https://files.pythonhosted.org/packages/d8/be/b582ceb80cefdf9d8da34078714e4b12b3d16f509dee0f65e40a5cc8fc7d/greenlet-3.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:ab3df3dffb58bf70564e93a5cec7941e4d9faa5a36cc4234a10d3131afe04f53", size = 323280, upload-time = "2026-08-10T13:26:07.495Z" }, - { url = "https://files.pythonhosted.org/packages/4d/18/5313c4c58598c38b0373c013e4ff2b3e6d258aaaa338f373335ebecdaddd/greenlet-3.5.5-cp311-cp311-win_arm64.whl", hash = "sha256:2b70a766135540c472ac1393d57c2e1b4a2eb85bf526a1e41e6d096173a8cee5", size = 307785, upload-time = "2026-08-10T13:28:34.874Z" }, { url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809, upload-time = "2026-08-10T13:25:34.023Z" }, { url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910, upload-time = "2026-08-10T14:14:35.18Z" }, { url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198, upload-time = "2026-08-10T14:27:25.229Z" }, @@ -667,7 +556,6 @@ name = "hypothesis" version = "6.165.10" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "sortedcontainers" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5c/e2/0fad246d2b6330e1f78479bfc566b5c22be82aee8a865cde9a08f648487d/hypothesis-6.165.10.tar.gz", hash = "sha256:68b45e09834cd80523cb1eb274463073c7a9af4e4ef7cff34d9615f355572d32", size = 503703, upload-time = "2026-08-16T22:56:15.404Z" } @@ -688,20 +576,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/99/27450763853a034bca1574d3e0a315164b33ff49c3862df6872dda45e25e/hypothesis-6.165.10-cp310-abi3-win32.whl", hash = "sha256:b33dc30170a7402e03c180f2c5ef69dc077152f35b91621e9cebcde9c7d71746", size = 669039, upload-time = "2026-08-16T22:55:11.962Z" }, { url = "https://files.pythonhosted.org/packages/2c/fc/ff2988b72b5705ad9ca500444bf3f43e3c2f41edfa034bbfeb23b215791a/hypothesis-6.165.10-cp310-abi3-win_amd64.whl", hash = "sha256:e9f924aa610c0618445e1e8738c822c3190ce2a2699a0cb48ec3a351a96761f2", size = 675213, upload-time = "2026-08-16T22:55:01.697Z" }, { url = "https://files.pythonhosted.org/packages/c5/8b/821810d36f78d9d9421cd2c5d9d36983b45bb3575c3086276cc5c76f9f73/hypothesis-6.165.10-cp310-abi3-win_arm64.whl", hash = "sha256:1d305448e9bd8e2f4f3cea0eafd809efdaab4e998a0019bc615650c8463e42f1", size = 673537, upload-time = "2026-08-16T22:54:47.898Z" }, - { url = "https://files.pythonhosted.org/packages/26/61/5e89268ce03317fb9f82449a1b3efd9e599dee090288fd0cf7586c532fb1/hypothesis-6.165.10-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:73e6df02a6a62f8045b511c272f894d08e56d174504c793c9effcbc6778051a8", size = 783959, upload-time = "2026-08-16T22:55:29.078Z" }, - { url = "https://files.pythonhosted.org/packages/e1/e9/f4e0832e81bb53b70cf1712e28c867db64245b32595b594217452e7dbd8d/hypothesis-6.165.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8b20f44773a9ab84400465e318712d8c2ca16418d35b9f80aa27fdf2d690ad10", size = 779684, upload-time = "2026-08-16T22:54:57.698Z" }, - { url = "https://files.pythonhosted.org/packages/77/de/ea072d3359d5678771bed407f80439e8ac7ca905d1031b0372f61bf5746e/hypothesis-6.165.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb8c7d05ea27a093a92b250904095d71d924b6b44e5795a415c1b20c265f0c65", size = 1108540, upload-time = "2026-08-16T22:55:03.282Z" }, - { url = "https://files.pythonhosted.org/packages/28/56/e7c395cdaa3d6c28b944c1c3c516dee50d2b7b3aeafa31874b57009ca51f/hypothesis-6.165.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4dafd6d6ababfa3b14dd6e5f0378cb7c7d291895a31a40abcbb7cc74f396131", size = 1158089, upload-time = "2026-08-16T22:54:36.205Z" }, - { url = "https://files.pythonhosted.org/packages/b1/49/1c6d2c465b9c5fc3213f1be89be95ba53819ca0130248c484129ccfefb71/hypothesis-6.165.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fa74636a49fc8077413ce8db3e85f1c4aff880788bb55bda56253118e036fe5b", size = 1284125, upload-time = "2026-08-16T22:54:37.727Z" }, - { url = "https://files.pythonhosted.org/packages/7d/bc/7caf5ac3d0173bd57bd2a5ab854ca49a3664a4309257be1452f81025cc24/hypothesis-6.165.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2b112768cfb67f2b683e53e58c1a33d27811aacf60c942b8eb74635e469a73f6", size = 1325082, upload-time = "2026-08-16T22:54:38.952Z" }, - { url = "https://files.pythonhosted.org/packages/70/99/9d844330f570d6a4f127a683eab1e78c8263e6e72b16189f3534fa6bf6de/hypothesis-6.165.10-cp310-cp310-win_amd64.whl", hash = "sha256:56cb8c9055e50545fe6e3e5a560ec25a724673b2e4051f3c24d44e3ebc35dd72", size = 675082, upload-time = "2026-08-16T22:54:29.872Z" }, - { url = "https://files.pythonhosted.org/packages/ed/c2/b9546ace11f241c9c02d389f258cb80c14447a8c885771c9f1f0bc1d85ca/hypothesis-6.165.10-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:592107a0faf6c9c3a63a8dbf13dfb1cbda1cf599b0bc11c953221b00204b9ce1", size = 783716, upload-time = "2026-08-16T22:55:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/37/10/27c2fdd574fd798caf5e91eb51f7834b098f5d840ce733efb3fba79ef86e/hypothesis-6.165.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9180c362bde06fd05380298ded4e234fbc0d6ede0a864835bfd91c1e24283d5", size = 779507, upload-time = "2026-08-16T22:55:07.633Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b6/70bc23695f3783c4b0486b6cad47b08a20f791db4a3c1b25250add9659fa/hypothesis-6.165.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d623801ae3dcd97b77b983400ef3d48bf976648e4efff19929175322eaae074d", size = 1108406, upload-time = "2026-08-16T22:55:39.653Z" }, - { url = "https://files.pythonhosted.org/packages/71/4c/32e200bd7a352af4b7f4e3729aaa4cd002cb5fe8c4c6aef5599d0019f152/hypothesis-6.165.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20f6236cfb90b7817bb1a6a087589ca4aa46d73170f0dd62963952ed5dadc589", size = 1157850, upload-time = "2026-08-16T22:55:24.394Z" }, - { url = "https://files.pythonhosted.org/packages/03/a5/8efc2a9a484822efc0d0da466f50094e0f2c068187faaf33831fc905873e/hypothesis-6.165.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad0764730e8e3421601c2cc7e1f054a9206c60ea0917165d8d9193dc453f34f1", size = 1283704, upload-time = "2026-08-16T22:54:27.279Z" }, - { url = "https://files.pythonhosted.org/packages/46/2a/90cc8d7463929c04786f29600de45f3227c12fa9bed1d5b7ce319b05e1c9/hypothesis-6.165.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:10d9a650a4666b0914831f769703d36140ed8039fd19bf9b71f615b8541eccf2", size = 1325077, upload-time = "2026-08-16T22:55:16.561Z" }, - { url = "https://files.pythonhosted.org/packages/82/ac/bc16faba4b42883e3d290bfaceff51e258b63fbbdf789bf9fe88df1ce537/hypothesis-6.165.10-cp311-cp311-win_amd64.whl", hash = "sha256:5671d2b2bf83bd4b6f02e55b32d432506eff5358c82f39b460a849ce19a2666e", size = 674920, upload-time = "2026-08-16T22:55:42.613Z" }, { url = "https://files.pythonhosted.org/packages/e9/45/cde4f78afe2b9e29caecf38319eedc1deb76aebcacbdd128e03cbb2511c3/hypothesis-6.165.10-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:637445c1593a2a9d1024fda50082f07bb56baedda78d90a25f64b8111727ef94", size = 784835, upload-time = "2026-08-16T22:54:45.429Z" }, { url = "https://files.pythonhosted.org/packages/7f/81/847f30b81cbfd07607296b3ce43067cf4f80799bd9244167f587de9c8081/hypothesis-6.165.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:713f4ce4e82c26b53031f139de959bc9e8b54d3995aa824b89bbdf8229df2a45", size = 776419, upload-time = "2026-08-16T22:55:33.633Z" }, { url = "https://files.pythonhosted.org/packages/04/66/4c71c5be7a49d84b8c3a9278c1807c4c81181ab5474beb27df9d4c40dc0e/hypothesis-6.165.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9ff356e97e3ab09db07c8b675efa67340103874a0bae7465acb83dad7a35f7f", size = 1106830, upload-time = "2026-08-16T22:55:10.389Z" }, @@ -747,11 +621,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/63/ad5cc153dcc72ae5e7905fb9b3585f3e48ce892a2d6366f90163e867a69d/hypothesis-6.165.10-cp315-abi3.abi3t-win32.whl", hash = "sha256:c6559380469295c4009215fe1cab561301591a3bee2e2fb3f4f96d2273a3affc", size = 666038, upload-time = "2026-08-16T22:56:11.797Z" }, { url = "https://files.pythonhosted.org/packages/80/32/b62307b73fbc99f0a4381d6f9456df76fbcbb7a27ef7256e26f0376f48ea/hypothesis-6.165.10-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:30797f20ca45e57f526d2df872f63ba453cb4e1091ad542184a7a951af8da79d", size = 671941, upload-time = "2026-08-16T22:55:00.235Z" }, { url = "https://files.pythonhosted.org/packages/c2/dd/e0f98add0548ef73ea7afac45da1fb8efc854d7f9931db568754d0f963f3/hypothesis-6.165.10-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:c53e9b1c36350df9965ec44d6c0d4e0bbbb38f720dd2b0e1256dc6524d411015", size = 669931, upload-time = "2026-08-16T22:55:50.205Z" }, - { url = "https://files.pythonhosted.org/packages/0b/6a/880d6eeed5c451fb40a66733dadec4a5d498628a4a7f6a8a5f633f4c6dcb/hypothesis-6.165.10-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:34ee6402df6f31274d89119f1561b5f7489c97866afc5b7a3ed3a13d7e762802", size = 784644, upload-time = "2026-08-16T22:54:20.127Z" }, - { url = "https://files.pythonhosted.org/packages/27/e0/9e942bd3c3cf5ea0d5c0fd0905893bbfb6cefb7284c70fcc8033f8fdec38/hypothesis-6.165.10-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:277f41801e88dad2eba082f91a75632b7584ff64044ba2cf9dadf511b0d19cd0", size = 780515, upload-time = "2026-08-16T22:55:04.676Z" }, - { url = "https://files.pythonhosted.org/packages/19/32/f11a618415dc5fa9cdde41fea56c489f0814759527ae1ecd11a75a4558b9/hypothesis-6.165.10-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72df95fb1db41755b155c5f02106e0036a339250555c8d351d488704fd112cf9", size = 1109374, upload-time = "2026-08-16T22:56:00.241Z" }, - { url = "https://files.pythonhosted.org/packages/5e/6f/db49b719842297c2b71e0d81e5b8967d31215fb7389421abcb465ce7ed3f/hypothesis-6.165.10-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e20a02775eb3cf0ffb4f0219b6d7c1f240336663d4e5d7028675ec247c790c4", size = 1159092, upload-time = "2026-08-16T22:55:58.57Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2a/bf0bae84ba1cb3923d295973f1fe38ee867eaf90119e0d559116083be300/hypothesis-6.165.10-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1ec53f08732e3cfd0342cbbd75dbd1b193c8f19390660466e536a748bb81f757", size = 676045, upload-time = "2026-08-16T22:55:45.514Z" }, ] [[package]] @@ -780,8 +649,7 @@ dependencies = [ { name = "attrs" }, { name = "jsonschema-specifications" }, { name = "referencing" }, - { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "rpds-py" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ @@ -818,28 +686,6 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, - { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, - { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, - { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, - { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, - { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, - { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, - { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, - { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, - { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, - { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, - { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, - { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, - { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, @@ -1107,28 +953,6 @@ name = "psycopg-binary" version = "3.3.4" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/bf/70d8a60488f9955cbbcd538beae44d56bb2f1d19e673b72788f2d343ff55/psycopg_binary-3.3.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b7bfff1ca23732b488cbca3076fc11bc98d520ee122514fdb17a8e20d3338f5a", size = 4609750, upload-time = "2026-05-01T23:24:20.06Z" }, - { url = "https://files.pythonhosted.org/packages/db/b0/29e98ba210c9dbc75a6dc91e3f99b9e06ea901a62ca95804e02a1ae13e6b/psycopg_binary-3.3.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:32a6fbf8481e3a370d0d72b860d35948a693cb01281da217f7b2f307636e591a", size = 4676700, upload-time = "2026-05-01T23:25:21.727Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ab/3df087b3c12bf74e47c08204172b2fabb5a144679110d5c7ad12d9201323/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bdef84570ebbce1d42b4e7ea952d21c414c5f118ad02fee00c5625f35e134429", size = 5496319, upload-time = "2026-05-01T23:25:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/87/9a/f088207b4cd6772f9e0d8a91807e79fa2458d4eb9eb1ae406c68415f2bec/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbc10768a796c96d3243656016bf4e337c81c71097270bb7b0ad6210d9765", size = 5171906, upload-time = "2026-05-01T23:25:34.004Z" }, - { url = "https://files.pythonhosted.org/packages/48/45/4523a857f253871d75c22e1c2e79fd47e599e736bcba1bad58d83e24be02/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf7f73a4a792bc5db58a4b385d8a1467e8d468f7548702fb0ed1e9b7501b1c13", size = 6762621, upload-time = "2026-05-01T23:25:41.392Z" }, - { url = "https://files.pythonhosted.org/packages/7c/d1/925bf776503345bef428e6c45fb017d0139ddbe0e211814b585c4253dca8/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7b4d40c153fa352ab3cca530f3a0baedf7621b2ebcbd7f084009522c21788fc", size = 5006319, upload-time = "2026-05-01T23:25:51.419Z" }, - { url = "https://files.pythonhosted.org/packages/6f/aa/99727337206fbba357ca084bf4ea8b29dc986f61842a2685859af61416db/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9b1c2533af01cd7648378599f82b0b8ae32f293296e6eec5753a625bc97ef28", size = 4535388, upload-time = "2026-05-01T23:25:57.957Z" }, - { url = "https://files.pythonhosted.org/packages/0b/a4/567ba2c37d19d8c2f63d836385dfd2495aa5897bbee6cfab104d9ee58624/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad3bc94054876155549fdaedf4a46d1ec69d39a5bcee377148afe498e84c4b8e", size = 4224544, upload-time = "2026-05-01T23:26:03.832Z" }, - { url = "https://files.pythonhosted.org/packages/b7/23/86457f5a82731685d7701de7bfaa5eb783dd1fecbf875321897d9d9ce33a/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb4eed2079c01a4850bf467deacfab56d356d4225040170af03dc9958321242d", size = 3956282, upload-time = "2026-05-01T23:26:09.983Z" }, - { url = "https://files.pythonhosted.org/packages/a7/d8/249456df16d47de082abd9b73bce8ccdeb0293eb12e590f9150c7cbdb788/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f80e3f2b5331dbbf0901bcb658056c03eeb2c1ef31d774afb0d61598b242e744", size = 4261736, upload-time = "2026-05-01T23:26:16.798Z" }, - { url = "https://files.pythonhosted.org/packages/15/6b/c4abe228acafd8a385c1fb615d4f1e3c9b8ad7a4e4f0e84118ba3ffeed9c/psycopg_binary-3.3.4-cp310-cp310-win_amd64.whl", hash = "sha256:574ea21a9651958f1535c5a1c649c7409e9168bcbffa29a3f2f961f58b322949", size = 3570620, upload-time = "2026-05-01T23:26:22.655Z" }, - { url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" }, - { url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" }, - { url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" }, - { url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" }, - { url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" }, - { url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" }, - { url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" }, - { url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" }, - { url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" }, - { url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" }, - { url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" }, { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, @@ -1197,35 +1021,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, - { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, - { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, - { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, - { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, - { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, - { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, - { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, - { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, - { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, - { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, - { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, - { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, - { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, - { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, - { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, - { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, - { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, - { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, - { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, - { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, - { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, - { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, @@ -1286,22 +1081,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, - { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, - { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, - { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, - { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, - { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, - { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, - { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, - { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, - { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, - { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] [[package]] @@ -1319,12 +1102,10 @@ version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -1335,9 +1116,6 @@ wheels = [ name = "redis" version = "8.1.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/a8/99/604f0b666d4c616d891cf77ebb9db6bb21601344c051aebf1b72b9ff915f/redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25", size = 5254356, upload-time = "2026-07-30T08:51:00.269Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/66/9d/c5731f6e3608663d4d3656fd8d3aecee8b509c3082818f5a13eae925baea/redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb", size = 560618, upload-time = "2026-07-30T08:50:58.497Z" }, @@ -1349,8 +1127,7 @@ version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, - { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "rpds-py" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } @@ -1373,156 +1150,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] -[[package]] -name = "rpds-py" -version = "0.30.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, - { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, - { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, - { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, - { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, - { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, - { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, - { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, - { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, - { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, - { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, - { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, - { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, - { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, - { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, - { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, - { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, - { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, - { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, - { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, - { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, - { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, - { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, - { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, - { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, - { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, - { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, - { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, - { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, - { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, - { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, - { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, - { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, - { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, - { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, - { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, - { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, - { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, - { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, - { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, - { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, - { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, - { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, - { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, - { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, - { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, - { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, - { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, - { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, - { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, - { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, - { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, - { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, - { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, - { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, - { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, - { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, - { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, - { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, - { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, - { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, - { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, - { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, - { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, - { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, - { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, - { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, - { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, - { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, - { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, - { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, - { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, - { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, - { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, - { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, - { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, - { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, - { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, - { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, - { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, - { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, - { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, - { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, - { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, - { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, -] - [[package]] name = "rpds-py" version = "2026.6.3" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, - { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, - { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, - { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, - { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, - { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, - { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, - { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, - { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, - { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, - { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, @@ -1611,18 +1244,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, - { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, - { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, - { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, - { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, - { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, - { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, - { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, - { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, - { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, ] [[package]] @@ -1644,20 +1265,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/d7/e0354e7334d33ea2795db3ecbe2977026c05a1ecf8ba4b5953c329872453/sqlalchemy-2.0.52-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a7438774e1091192fc50a2bd8ceff5c596912d00ecd46587e88effdea7826101", size = 2172616, upload-time = "2026-08-11T20:58:21.078Z" }, - { url = "https://files.pythonhosted.org/packages/5b/64/98eef682e6946eb1b4195a9a2393db4662ebfcc89f823ae78b938765c3c0/sqlalchemy-2.0.52-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c1b7ed45bf87b214e0a9def9c2313949067efe6269db5ef18d542ee13250af7", size = 3279798, upload-time = "2026-08-11T21:00:03.535Z" }, - { url = "https://files.pythonhosted.org/packages/20/05/5b96afc1407c314347ad006b72bb251fb68ef84d05505ebf8a39bc47fcde/sqlalchemy-2.0.52-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:309cc8ba50fc5d2174189dfcd49cdf7aa711f8346afcff19f2642ae4fc449c14", size = 3277555, upload-time = "2026-08-11T21:05:47.932Z" }, - { url = "https://files.pythonhosted.org/packages/50/69/ce6776724511d1b5dd40477b08d6a5f0953a45375e092dfa852b1857732c/sqlalchemy-2.0.52-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2f9eccf8793c8c3f8dd2dfd11b9e400cb27d1d19370ef732b66017e212107822", size = 3231246, upload-time = "2026-08-11T21:00:05.184Z" }, - { url = "https://files.pythonhosted.org/packages/72/19/ab0cb9ccdafa2419c796ae62f8740aedb903f1e93bb326064b1a0147e458/sqlalchemy-2.0.52-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9255ceb65a80c1b001129060b63ee776a2e9c288be3b662be36dfbb888fffdcd", size = 3250763, upload-time = "2026-08-11T21:05:49.664Z" }, - { url = "https://files.pythonhosted.org/packages/41/f2/3c9b54b61bec4f493c0007dc9e2700c963c5b32667e21a506f1aca7a8115/sqlalchemy-2.0.52-cp310-cp310-win32.whl", hash = "sha256:2e15b1d1116a64fc399b8c2694a83f3e792fdc58df28514a81e1dc4f8cf22729", size = 2132169, upload-time = "2026-08-11T21:09:48.108Z" }, - { url = "https://files.pythonhosted.org/packages/b3/a1/934bb6cf543a398c72784d1fc777eb530559c16ebe1549fa7611e5989ce9/sqlalchemy-2.0.52-cp310-cp310-win_amd64.whl", hash = "sha256:11560064cc4696e772298b6221ede59e646386d9f2a85d549365473b972f7850", size = 2156289, upload-time = "2026-08-11T21:09:49.429Z" }, - { url = "https://files.pythonhosted.org/packages/6b/08/cc5f7627b92f1456bc0b5fb7e98af4600248abe422a44da0d17a3fe6a448/sqlalchemy-2.0.52-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0c3ce43907374889f3352bdcc6195c970148a2cb71574cd0237a5071a37fb6c", size = 2172460, upload-time = "2026-08-11T20:58:22.429Z" }, - { url = "https://files.pythonhosted.org/packages/ed/dc/9a2abad8bfc8fdcd38c64adc056aeefab7aaa96ecd32f5e8c140e6375f17/sqlalchemy-2.0.52-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0d48c4b80717c61385b4e966e087c839a66cfd7b780641dcb428f4dba65608", size = 3355720, upload-time = "2026-08-11T21:00:06.746Z" }, - { url = "https://files.pythonhosted.org/packages/a8/73/e75597b5841043e3c74055d00d4feb53d9a49a5c89ba2450d2d9aab53597/sqlalchemy-2.0.52-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938325a5373267afc53bfbe72983b20fbd64ca47842aac62433c3da1137ecff1", size = 3354394, upload-time = "2026-08-11T21:05:51.454Z" }, - { url = "https://files.pythonhosted.org/packages/12/25/410fbc6c2f1fa8310f4ef1b6847d47d0ac1c042c7b4e81eaaca063d030a9/sqlalchemy-2.0.52-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f8438a98d49424acf69d0d53c0a522951dfe49a6f2d86417fbb37ad3066ab43", size = 3306991, upload-time = "2026-08-11T21:00:08.603Z" }, - { url = "https://files.pythonhosted.org/packages/b2/ba/25ffd5c24681ea4b46e62c80ceca8200ce204de1773366321306cf3f608a/sqlalchemy-2.0.52-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4699dbb8d396d199e7e78fd4d525e3ad3d6008a9c8c0160b87e74c606c2c3736", size = 3327454, upload-time = "2026-08-11T21:05:53.368Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f1/0f1b1d4800e51218e736a06ed55a3b2a59c257600bbaca7673bf13d2dbec/sqlalchemy-2.0.52-cp311-cp311-win32.whl", hash = "sha256:cef328349452ae152637df4d11ce5a0919ecdf0a363e16c830c3518ee33bde72", size = 2131248, upload-time = "2026-08-11T21:09:50.765Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f0/04d2ac5ad66f3d31278f37064ed5f5ef3fe653f7bdaa67036663f223d186/sqlalchemy-2.0.52-cp311-cp311-win_amd64.whl", hash = "sha256:f1c850792a3b25a3ad74dade3f05e4f402cdebfea27438bcadafaa1617f77bcc", size = 2156943, upload-time = "2026-08-11T21:09:51.979Z" }, { url = "https://files.pythonhosted.org/packages/e0/d5/1b77a026d161f98a08f11af1a5f6c47b98ee7c7e2648af525a1004826c78/sqlalchemy-2.0.52-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be8c49131665dfe2cc74c498aa1240ffb548d0fd901325dd11c2c7a18956f727", size = 2170940, upload-time = "2026-08-11T20:58:11.25Z" }, { url = "https://files.pythonhosted.org/packages/54/bd/f444444adb37b5d53753fb1730ee7a421628e2e3b756c4da461af7e6394a/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b2d9e507a458832adcfbd8af6e2036ddf069b7710b799448542ebccae2dceee", size = 3383415, upload-time = "2026-08-11T21:02:38.534Z" }, { url = "https://files.pythonhosted.org/packages/be/57/2eadf93a552568c57e8680b7e58bb5e9770d80942a1bdbaf4f2f63f0d7c8/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8738008376d22f30f411ea3efecf39b51110b6996d80bb73786f30bcfdd5fd3b", size = 3398577, upload-time = "2026-08-11T21:16:59.092Z" }, @@ -1696,60 +1303,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, ] -[[package]] -name = "tomli" -version = "2.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, - { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, - { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, - { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, - { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, - { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, - { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, - { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, - { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, - { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, - { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, - { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, - { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, - { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, - { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, - { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, - { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, - { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, - { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, - { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, - { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, - { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, -] - [[package]] name = "typing-extensions" version = "4.16.0" @@ -1796,7 +1349,6 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } wheels = [ From ecac722a490ca1a03e4549a26f7096170dd85673 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 19:19:20 +0000 Subject: [PATCH 04/35] =?UTF-8?q?test(release):=20RED=20=E2=80=94=20extrac?= =?UTF-8?q?tion=20helper=20for=20release=20notes=20does=20not=20exist=20ye?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/ci/release_notes.py is not implemented yet, so read_declared_version/extract_changelog_section/render_release_notes/main all fail to import (FileNotFoundError via the importlib-by-path loader this repository's scripts/ci contract tests use). Confirmed failing: `python -m pytest tests/test_release_notes.py -q` -> 10 failed. Part of the canonical immutable release mechanism (docs/planning/adrs/0127-canonical-immutable-release.md). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4 --- tests/test_release_notes.py | 199 ++++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 tests/test_release_notes.py diff --git a/tests/test_release_notes.py b/tests/test_release_notes.py new file mode 100644 index 000000000..2fe867ed1 --- /dev/null +++ b/tests/test_release_notes.py @@ -0,0 +1,199 @@ +"""Tests for the canonical-release CHANGELOG/version extraction helper.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pytest + + +def _module(): + path = Path(__file__).resolve().parents[1] / "scripts" / "ci" / "release_notes.py" + spec = importlib.util.spec_from_file_location("release_notes", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +_PYPROJECT = """[project] +name = "contextual-orchestrator" +version = "0.2.0" +description = "x" +""" + +_CHANGELOG = """# Changelog + +## [0.2.0] - Unreleased + +### Added + +- New thing. + +### Fixed + +- Old bug. + +## [0.1.0] - Unreleased + +### Added + +- First thing. +""" + + +def test_read_declared_version_extracts_the_project_version() -> None: + """The declared version comes from `[project]`'s `version = "..."` line only.""" + module = _module() + assert module.read_declared_version(_PYPROJECT) == "0.2.0" + + +def test_read_declared_version_rejects_a_missing_version_field() -> None: + """A pyproject with no version field must fail closed, not guess.""" + module = _module() + with pytest.raises(ValueError, match="version"): + module.read_declared_version("[project]\nname = \"x\"\n") + + +def test_extract_changelog_section_returns_only_the_matching_version_body() -> None: + """Only the body between the matching heading and the next heading is returned.""" + module = _module() + section = module.extract_changelog_section(_CHANGELOG, "0.2.0") + assert "New thing." in section + assert "Old bug." in section + assert "First thing." not in section + assert "## [0.1.0]" not in section + + +def test_extract_changelog_section_matches_a_dated_heading_too() -> None: + """A heading with a real date, not just 'Unreleased', still matches.""" + module = _module() + dated = _CHANGELOG.replace("## [0.2.0] - Unreleased", "## [0.2.0] - 2026-09-02") + section = module.extract_changelog_section(dated, "0.2.0") + assert "New thing." in section + + +def test_extract_changelog_section_rejects_a_missing_version() -> None: + """A version with no CHANGELOG section must fail closed, not publish empty notes.""" + module = _module() + with pytest.raises(ValueError, match="0.9.9"): + module.extract_changelog_section(_CHANGELOG, "0.9.9") + + +def test_extract_changelog_section_rejects_an_empty_section() -> None: + """A heading with no content beneath it must fail closed.""" + module = _module() + empty = "## [0.3.0] - Unreleased\n\n## [0.2.0] - Unreleased\n\nbody\n" + with pytest.raises(ValueError, match="empty"): + module.extract_changelog_section(empty, "0.3.0") + + +def test_render_release_notes_includes_provenance_and_section_body() -> None: + """The rendered body cites the exact commit and repository alongside notes.""" + module = _module() + body = module.render_release_notes( + version="0.2.0", + section_body="- New thing.", + repository="ContextualWisdomLab/contextual-orchestrator", + commit_sha="a" * 40, + ) + assert "0.2.0" in body + assert "a" * 40 in body + assert "ContextualWisdomLab/contextual-orchestrator" in body + assert "- New thing." in body + + +def test_main_writes_rendered_notes_to_the_requested_output_path(tmp_path: Path) -> None: + """The CLI wires pyproject/changelog extraction into one written notes file.""" + module = _module() + pyproject_path = tmp_path / "pyproject.toml" + pyproject_path.write_text(_PYPROJECT, encoding="utf-8") + changelog_path = tmp_path / "CHANGELOG.md" + changelog_path.write_text(_CHANGELOG, encoding="utf-8") + output_path = tmp_path / "notes.md" + + exit_code = module.main( + [ + "--pyproject", + str(pyproject_path), + "--changelog", + str(changelog_path), + "--version", + "0.2.0", + "--repository", + "ContextualWisdomLab/contextual-orchestrator", + "--commit-sha", + "b" * 40, + "--output", + str(output_path), + ] + ) + + assert exit_code == 0 + written = output_path.read_text(encoding="utf-8") + assert "New thing." in written + assert "b" * 40 in written + + +def test_main_fails_closed_when_version_input_does_not_match_pyproject(tmp_path: Path) -> None: + """The CLI refuses to render notes for a version pyproject.toml does not declare.""" + module = _module() + pyproject_path = tmp_path / "pyproject.toml" + pyproject_path.write_text(_PYPROJECT, encoding="utf-8") + changelog_path = tmp_path / "CHANGELOG.md" + changelog_path.write_text(_CHANGELOG, encoding="utf-8") + + exit_code = module.main( + [ + "--pyproject", + str(pyproject_path), + "--changelog", + str(changelog_path), + "--version", + "9.9.9", + "--repository", + "ContextualWisdomLab/contextual-orchestrator", + "--commit-sha", + "c" * 40, + "--output", + str(tmp_path / "notes.md"), + ] + ) + + assert exit_code == 2 + assert not (tmp_path / "notes.md").exists() + + +def test_main_prints_to_stdout_when_no_output_path_is_given( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Omitting --output prints the rendered notes for a caller to capture.""" + module = _module() + pyproject_path = tmp_path / "pyproject.toml" + pyproject_path.write_text(_PYPROJECT, encoding="utf-8") + changelog_path = tmp_path / "CHANGELOG.md" + changelog_path.write_text(_CHANGELOG, encoding="utf-8") + + exit_code = module.main( + [ + "--pyproject", + str(pyproject_path), + "--changelog", + str(changelog_path), + "--version", + "0.2.0", + "--repository", + "ContextualWisdomLab/contextual-orchestrator", + "--commit-sha", + "d" * 40, + ] + ) + + assert exit_code == 0 + assert "New thing." in capsys.readouterr().out + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(pytest.main([__file__])) From b7ac64d9b6e5b819677e378095946f787495cf96 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 19:19:28 +0000 Subject: [PATCH 05/35] =?UTF-8?q?feat(release):=20GREEN=20=E2=80=94=20extr?= =?UTF-8?q?act=20release=20notes=20from=20CHANGELOG.md=20and=20pyproject.t?= =?UTF-8?q?oml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements scripts/ci/release_notes.py per the RED test in ecac722a490ca1a03e4549a26f7096170dd85673: read_declared_version() reads pyproject.toml's [project] version field only (never guesses from a tag or CHANGELOG heading); extract_changelog_section() returns the body of the matching '## [X.Y.Z]' section, regardless of an '- Unreleased' or dated suffix, and fails closed on a missing or empty section; render_release_notes() adds commit/repository provenance; main() wires both together for .github/workflows/release.yml and fails closed (exit 2) when the requested --version does not match pyproject.toml. Confirmed: `python -m pytest tests/test_release_notes.py -q` -> 10 passed. `python -m interrogate -f 100 scripts/ci/release_notes.py` -> PASSED (100.0%). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4 --- scripts/ci/release_notes.py | 106 ++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 scripts/ci/release_notes.py diff --git a/scripts/ci/release_notes.py b/scripts/ci/release_notes.py new file mode 100644 index 000000000..fc107ad8b --- /dev/null +++ b/scripts/ci/release_notes.py @@ -0,0 +1,106 @@ +"""Render one GitHub Release body from `pyproject.toml` and `CHANGELOG.md`. + +This helper backs `.github/workflows/release.yml`. It never guesses a +version and never publishes empty notes: a missing `version` field, a +requested version absent from `CHANGELOG.md`, or a heading with no content +underneath all fail closed instead of producing a placeholder release body. +It has no network access and no GitHub credential; the workflow itself owns +tag/Release creation via the `gh` CLI. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +_VERSION_FIELD_PATTERN = re.compile(r'(?m)^version\s*=\s*"([^"]+)"\s*$') +_HEADING_PATTERN = re.compile(r"(?m)^## \[(?P[^\]]+)\][^\n]*$") + + +def read_declared_version(pyproject_text: str) -> str: + """Return the `[project]` `version` declared in *pyproject_text*. + + Raises ``ValueError`` when no `version = "..."` line is present, rather + than guessing a version from a tag, a changelog heading, or any other + inferred source. + """ + match = _VERSION_FIELD_PATTERN.search(pyproject_text) + if match is None: + raise ValueError("pyproject.toml has no version field") + return match.group(1) + + +def extract_changelog_section(changelog_text: str, version: str) -> str: + """Return the body of `CHANGELOG.md`'s `## [version]` section. + + Matches a heading regardless of what follows the version in brackets + (``- Unreleased`` or a real date), and returns everything up to the next + ``## [`` heading or end of file. Raises ``ValueError`` when *version* has + no matching heading, or when the matched section has no content. + """ + headings = list(_HEADING_PATTERN.finditer(changelog_text)) + for index, heading in enumerate(headings): + if heading.group("version") != version: + continue + start = heading.end() + end = headings[index + 1].start() if index + 1 < len(headings) else len(changelog_text) + section = changelog_text[start:end].strip("\n") + if not section.strip(): + raise ValueError(f"CHANGELOG.md section for {version} is empty") + return section + raise ValueError(f"CHANGELOG.md has no section for {version}") + + +def render_release_notes(*, version: str, section_body: str, repository: str, commit_sha: str) -> str: + """Compose the final GitHub Release body from provenance and the section.""" + return ( + f"Release `v{version}` of `{repository}`, built from commit " + f"`{commit_sha}`.\n\n{section_body}\n" + ) + + +def _parse_args(argv: list[str] | None) -> argparse.Namespace: + """Parse CLI arguments for rendering one release's notes.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--pyproject", required=True, help="path to pyproject.toml") + parser.add_argument("--changelog", required=True, help="path to CHANGELOG.md") + parser.add_argument("--version", required=True, help="version being released, e.g. 0.2.0") + parser.add_argument("--repository", required=True, help="owner/name GitHub repository") + parser.add_argument("--commit-sha", required=True, help="exact commit SHA being released") + parser.add_argument("--output", help="write rendered notes here instead of stdout") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Render one release's notes and fail closed on any mismatch or gap.""" + args = _parse_args(argv) + try: + pyproject_text = Path(args.pyproject).read_text(encoding="utf-8") + declared_version = read_declared_version(pyproject_text) + if declared_version != args.version: + raise ValueError( + f"pyproject.toml declares version {declared_version!r}, " + f"but --version was {args.version!r}" + ) + changelog_text = Path(args.changelog).read_text(encoding="utf-8") + section_body = extract_changelog_section(changelog_text, args.version) + notes = render_release_notes( + version=args.version, + section_body=section_body, + repository=args.repository, + commit_sha=args.commit_sha, + ) + except (OSError, ValueError) as exc: + print(str(exc), file=sys.stderr) + return 2 + if args.output: + Path(args.output).write_text(notes, encoding="utf-8") + else: + print(notes) + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) From c9b0154dfa3ab0d839f81b63a1c0793252be1460 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 19:19:34 +0000 Subject: [PATCH 06/35] =?UTF-8?q?test(release):=20RED=20=E2=80=94=20releas?= =?UTF-8?q?e=20workflow=20does=20not=20exist=20yet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .github/workflows/release.yml is absent from this branch's parent (and from origin/main) at this point, so every structural assertion in tests/test_release_workflow_contract.py fails on FileNotFoundError. Confirmed failing both by direct pytest collection and by checking `git show origin/main:.github/workflows/release.yml` (no such file). Part of the canonical immutable release mechanism (docs/planning/adrs/0127-canonical-immutable-release.md). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4 --- tests/test_release_workflow_contract.py | 168 ++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 tests/test_release_workflow_contract.py diff --git a/tests/test_release_workflow_contract.py b/tests/test_release_workflow_contract.py new file mode 100644 index 000000000..f95f5a84a --- /dev/null +++ b/tests/test_release_workflow_contract.py @@ -0,0 +1,168 @@ +"""Static contract for the canonical immutable release mechanism. + +Pins the structure of `.github/workflows/release.yml` per +docs/planning/adrs/0127-canonical-immutable-release.md: a deliberate, +maintainer-dispatched trigger only; a fail-closed gate that verifies the +released commit is protected main's untampered current tip and that the +requested version matches `pyproject.toml`; a fresh full test-suite run; and +an immutable, never-reused git tag backing a real GitHub Release. + +Uses plain text assertions rather than a YAML parser, matching this +repository's existing workflow-contract convention (e.g. +`tests/test_nim_benchmark_workflow_contract.py`) and the Ponytail gate: no +new dependency (PyYAML is not otherwise used anywhere in this repository) +when substring/index assertions already prove the same structure. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +_WORKFLOW_PATH = REPOSITORY_ROOT / ".github/workflows/release.yml" + + +def _workflow_text() -> str: + """Return the release workflow's raw YAML text.""" + return _WORKFLOW_PATH.read_text(encoding="utf-8") + + +def test_release_workflow_file_exists() -> None: + """A first canonical release mechanism must actually be present on disk.""" + assert _WORKFLOW_PATH.exists() + + +def test_release_is_triggered_only_by_deliberate_manual_dispatch() -> None: + """Releases are never an automatic side effect of push, PR, or schedule.""" + workflow = _workflow_text() + trigger_start = workflow.index("\non:\n") + trigger_end = workflow.index("\npermissions:\n") + trigger_block = workflow[trigger_start:trigger_end] + assert "workflow_dispatch:" in trigger_block + assert "push:" not in trigger_block + assert "schedule:" not in trigger_block + assert "pull_request:" not in trigger_block + + +def test_dispatch_requires_an_explicit_version_input_with_no_default() -> None: + """A maintainer must type the exact version; nothing is inferred silently.""" + workflow = _workflow_text() + inputs_start = workflow.index("inputs:") + permissions_start = workflow.index("\npermissions:\n") + inputs_block = workflow[inputs_start:permissions_start] + assert "version:" in inputs_block + assert "required: true" in inputs_block + assert "default:" not in inputs_block + + +def test_release_job_only_runs_against_the_main_branch_ref() -> None: + """Dispatching against any other branch must not publish a release.""" + workflow = _workflow_text() + assert "if: github.ref == 'refs/heads/main'" in workflow + + +def test_write_permission_is_scoped_to_the_release_job_only() -> None: + """The workflow-default permission stays read-only; only the release job writes.""" + workflow = _workflow_text() + top_permissions = workflow.index("permissions:\n contents: read") + jobs_start = workflow.index("\njobs:\n") + assert top_permissions < jobs_start + job_permissions = workflow.index("permissions:\n contents: write") + assert job_permissions > jobs_start + + +def test_gate_verifies_exact_current_main_tip_before_anything_else() -> None: + """The release must fail closed if a merge landed after dispatch started.""" + workflow = _workflow_text() + tip_check_index = workflow.index("current, untampered tip") + tag_step_index = workflow.index("Create the annotated release tag") + assert tip_check_index < tag_step_index + assert 'repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha' in workflow + assert 'if [ "${remote_head}" != "${GITHUB_SHA}" ]' in workflow + + +def test_gate_verifies_requested_version_matches_pyproject_toml() -> None: + """A release must never redefine what version an already-merged commit is.""" + workflow = _workflow_text() + assert 'if [ "${declared}" != "${RELEASE_VERSION}" ]' in workflow + assert "pyproject.toml" in workflow + + +def test_gate_refuses_to_republish_or_move_an_existing_tag() -> None: + """An already-existing tag must block the run instead of being overwritten.""" + workflow = _workflow_text() + assert 'git rev-parse "refs/tags/v${RELEASE_VERSION}"' in workflow + assert "repos/${GITHUB_REPOSITORY}/git/ref/tags/v${RELEASE_VERSION}" in workflow + + +def test_gate_reruns_the_full_test_suite_fresh_before_tagging() -> None: + """No release ships on a merely-trusted, potentially stale prior test run.""" + workflow = _workflow_text() + fresh_run_index = workflow.index( + "uv run --locked --extra api --extra db --extra queue --group dev python -m pytest -q" + ) + tag_step_index = workflow.index("Create the annotated release tag") + assert fresh_run_index < tag_step_index + + +def test_release_notes_are_rendered_from_the_changelog_via_the_tested_helper() -> None: + """Notes come from the tested extractor, not inline ad hoc text-munging.""" + workflow = _workflow_text() + assert "python -m scripts.ci.release_notes" in workflow + assert "--changelog CHANGELOG.md" in workflow + assert "--output release-notes.md" in workflow + + +def test_release_tag_is_annotated_and_pushed_before_the_release_is_created() -> None: + """The immutable tag exists before `gh release create` runs, never after.""" + workflow = _workflow_text() + tag_index = workflow.index('git tag -a "v${RELEASE_VERSION}"') + push_index = workflow.index('git push origin "refs/tags/v${RELEASE_VERSION}"') + publish_index = workflow.index("gh release create") + assert tag_index < push_index < publish_index + + +def test_release_notes_file_backs_the_published_release_body() -> None: + """The GitHub Release body is the rendered notes file, not inline text.""" + workflow = _workflow_text() + assert "--notes-file release-notes.md" in workflow + + +def test_sbom_asset_attachment_is_best_effort_and_never_blocks_the_release() -> None: + """A missing SBOM artifact must warn, not fail, the release.""" + workflow = _workflow_text() + sbom_step_index = workflow.index("Fetch the CycloneDX SBOM") + publish_index = workflow.index("Publish the GitHub Release") + sbom_block = workflow[sbom_step_index:publish_index] + assert "::warning::" in sbom_block + assert "gh run download" in sbom_block + + +def test_concurrency_group_serializes_release_runs() -> None: + """Two dispatched releases must never race the same tag push.""" + workflow = _workflow_text() + concurrency_start = workflow.index("\nconcurrency:\n") + jobs_start = workflow.index("\njobs:\n") + concurrency_block = workflow[concurrency_start:jobs_start] + assert "group: release" in concurrency_block + assert "cancel-in-progress: false" in concurrency_block + + +def test_pinned_actions_use_full_commit_shas() -> None: + """Every third-party action reference stays pinned per Scorecard convention.""" + workflow = _workflow_text() + for line in workflow.splitlines(): + stripped = line.strip() + if stripped.startswith("uses:"): + ref = stripped.split("uses:", 1)[1].strip() + assert "@" in ref + _, _, pin = ref.partition("@") + pin = pin.split()[0] + assert len(pin) == 40, f"unpinned or non-SHA action ref: {ref}" + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(pytest.main([__file__])) From 26e61a61a39ebb9119028a674a399988e340e744 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 19:19:45 +0000 Subject: [PATCH 07/35] =?UTF-8?q?feat(release):=20GREEN=20=E2=80=94=20cano?= =?UTF-8?q?nical=20immutable=20release=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements .github/workflows/release.yml per the RED test in c9b0154dfa3ab0d839f81b63a1c0793252be1460 and the design in docs/planning/adrs/0127-canonical-immutable-release.md: - workflow_dispatch only, with a required version input and no default — never push/schedule/pull_request-triggered, so a release is always a deliberate maintainer action; - runs only against github.ref == 'refs/heads/main'; - fails closed unless the dispatched commit equals main's live tip (gh api repos/$REPO/commits/main), guarding against a race with a concurrent merge; - fails closed unless pyproject.toml's declared version matches the dispatch input; - fails closed if tag v${VERSION} already exists locally or on origin — a release is never republished or moved onto a different commit; - reruns the full test suite fresh on the exact commit (the same `uv run --locked ... pytest -q` invocation as Makefile's `make test` and ci.yml's Tests job) rather than trusting a prior run's status; - renders release notes via the tested scripts/ci/release_notes.py; - creates and pushes an annotated tag, then publishes a GitHub Release, best-effort attaching the CycloneDX SBOM from the matching successful Security workflow run for this commit (a missing SBOM warns, it never blocks the release); - contents: write is scoped to the release job only; every other default stays contents: read. Also lists the two new release-mechanism test files in README.md's Check section, next to the existing release_authorization/release_authority tests they sit beside. Confirmed: `python -m pytest tests/test_release_workflow_contract.py -q` -> 15 passed. `python tests/test_release_workflow_contract.py` and `python tests/test_release_notes.py` (direct-execution wrapper) both pass. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4 --- .github/workflows/release.yml | 128 ++++++++++++++++++++++++++++++++++ README.md | 2 + 2 files changed, 130 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..daa6ac3a7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,128 @@ +name: Release + +# Publishes a canonical, immutable GitHub Release so downstream consumers can +# pin a versioned tag instead of vendoring this repository's source SHA (see +# docs/planning/adrs/0127-canonical-immutable-release.md). This is a +# deliberate, maintainer-dispatched action only -- it never runs on push, +# schedule, or as a side effect of merging to main. It never re-implements +# GitHub branch-protection governance: it verifies the released commit really +# is protected main's current, untampered tip, then re-runs this repository's +# own primary regression gate fresh before cutting anything. + +on: + workflow_dispatch: + inputs: + version: + description: "Version to release, matching pyproject.toml exactly (e.g. 0.2.0, no leading v)" + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + name: Publish canonical immutable release + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + steps: + - name: Checkout protected main + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + persist-credentials: true + fetch-depth: 0 + fetch-tags: true + + - name: Verify this commit is protected main's current, untampered tip + run: | + set -euo pipefail + remote_head="$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha)" + if [ "${remote_head}" != "${GITHUB_SHA}" ]; then + echo "::error::Dispatched commit ${GITHUB_SHA} is not protected main's current tip (${remote_head}); a merge landed after this run started. Re-dispatch against the fresh tip." >&2 + exit 1 + fi + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + version: "0.12.5" + + - name: Verify the requested version matches pyproject.toml + run: | + set -euo pipefail + declared="$(python -c 'import re,sys; text=open("pyproject.toml", encoding="utf-8").read(); m=re.search(r"(?m)^version\s*=\s*\"([^\"]+)\"\s*$", text); sys.exit(1) if not m else print(m.group(1))')" + if [ "${declared}" != "${RELEASE_VERSION}" ]; then + echo "::error::pyproject.toml declares version '${declared}', but the dispatch input was '${RELEASE_VERSION}'. Land a version-bump PR first; a release never redefines a commit's version." >&2 + exit 1 + fi + + - name: Verify tag v${{ inputs.version }} does not already exist + run: | + set -euo pipefail + if git rev-parse "refs/tags/v${RELEASE_VERSION}" >/dev/null 2>&1; then + echo "::error::Tag v${RELEASE_VERSION} already exists locally; a release is never re-published or moved onto a new commit." >&2 + exit 1 + fi + if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/v${RELEASE_VERSION}" >/dev/null 2>&1; then + echo "::error::Tag v${RELEASE_VERSION} already exists on origin; a release is never re-published or moved onto a new commit." >&2 + exit 1 + fi + + - name: Run the full required test suite fresh on this exact commit + run: uv run --locked --extra api --extra db --extra queue --group dev python -m pytest -q + + - name: Render release notes from CHANGELOG.md + run: | + set -euo pipefail + python -m scripts.ci.release_notes \ + --pyproject pyproject.toml \ + --changelog CHANGELOG.md \ + --version "${RELEASE_VERSION}" \ + --repository "${GITHUB_REPOSITORY}" \ + --commit-sha "${GITHUB_SHA}" \ + --output release-notes.md + + - name: Create the annotated release tag + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "v${RELEASE_VERSION}" -m "Release v${RELEASE_VERSION}" + git push origin "refs/tags/v${RELEASE_VERSION}" + + - name: Fetch the CycloneDX SBOM for this commit, best-effort + run: | + set -euo pipefail + run_id="$(gh run list --repo "${GITHUB_REPOSITORY}" --workflow security.yml --commit "${GITHUB_SHA}" --status success --json databaseId --jq '.[0].databaseId // empty')" + if [ -n "${run_id}" ]; then + gh run download --repo "${GITHUB_REPOSITORY}" "${run_id}" --name cyclonedx-sbom --dir sbom-download || echo "::warning::Security workflow run ${run_id} has no cyclonedx-sbom artifact; publishing without it." + else + echo "::warning::No successful Security workflow run found for commit ${GITHUB_SHA}; publishing without an SBOM asset." + fi + + - name: Publish the GitHub Release + run: | + set -euo pipefail + assets=() + if [ -f "sbom-download/cyclonedx-sbom.json" ]; then + assets+=("sbom-download/cyclonedx-sbom.json") + fi + gh release create "v${RELEASE_VERSION}" \ + --repo "${GITHUB_REPOSITORY}" \ + --title "v${RELEASE_VERSION}" \ + --notes-file release-notes.md \ + "${assets[@]}" diff --git a/README.md b/README.md index 6eedf5755..8fae78ed3 100644 --- a/README.md +++ b/README.md @@ -419,6 +419,8 @@ python tests/test_commercial_evidence_export.py python tests/test_commercial_acceptance_check.py python tests/test_release_authorization.py python tests/test_release_authority_snapshot.py +python tests/test_release_notes.py +python tests/test_release_workflow_contract.py python tests/test_commercial_buyer_acceptance_workflow.py python tests/test_commercial_release_candidate.py python tests/test_commercial_gap_register.py From ad732374d2493892cce46bd3e3e4208fb190dce5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 19:19:53 +0000 Subject: [PATCH 08/35] =?UTF-8?q?docs(adr):=200127=20=E2=80=94=20canonical?= =?UTF-8?q?=20immutable=20release,=20gated=20on=20protected-main=20evidenc?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the design decided before implementation: a new, distinct concern from contextual_orchestrator/release_authorization.py (which is a PR-scoped, buyer-facing product-evidence report never wired to any GitHub Actions workflow), sharing its fail-closed spirit without duplicating GitHub governance already enforced by branch protection at merge time. Cites the direct consumer evidence (keyverse#132, bandscope#881, the Wardnet handoff, EgressWeave#235) from contextual-orchestrator#971, and scopes out PyPI publishing, release-on-every-merge, automatic version bumps, and dynamic ruleset-derived required-check names as non-goals for this first mechanism. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4 --- .../adrs/0127-canonical-immutable-release.md | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 docs/planning/adrs/0127-canonical-immutable-release.md diff --git a/docs/planning/adrs/0127-canonical-immutable-release.md b/docs/planning/adrs/0127-canonical-immutable-release.md new file mode 100644 index 000000000..41b24350b --- /dev/null +++ b/docs/planning/adrs/0127-canonical-immutable-release.md @@ -0,0 +1,269 @@ +--- +id: "0127" +title: "Publish a canonical immutable GitHub Release, gated on protected-main evidence" +status: proposed +proposed_date: "2026-09-02" +deciders: + - "repository maintainer" +affected_components: + - ".github/workflows/release.yml" + - "scripts/ci/release_notes.py" + - "docs/RELEASING.md" +related: + - path: "docs/planning/adrs/0020-fail-closed-release-authorization.md" + relation: "distinct-concern-shares-fail-closed-spirit" +success_criteria: + - metric: "consumer pin target" + target: "GET /repos/ContextualWisdomLab/contextual-orchestrator/releases/latest returns a tag, not 404" + source: "gh api repos/.../releases/latest after the first manual dispatch" + - metric: "no vendored source SHA required" + target: "a consumer can depend on the released tag/API/client/schema without vendoring this repository's source" + source: "owner acceptance criteria, PR #971 comment 2026-09-02T18:26:04Z" + - metric: "no paid/provider-specific fallback required" + target: "the release mechanism itself calls no paid API and needs no new runtime dependency" + source: "workflow uses only actions/checkout, git, and the gh CLI already available on GitHub-hosted runners" +--- + +# Publish a canonical immutable GitHub Release, gated on protected-main evidence + +## Context + +This repository has never cut a release. `git tag -l` is empty, no +`.github/workflows/*release*.yml` exists, and `GET /repos/ContextualWisdomLab/ +contextual-orchestrator/releases/latest` returns 404. `pyproject.toml` has +carried `version = "0.2.0"` and `CHANGELOG.md` an `## [0.2.0] - Unreleased` +section through hundreds of merged PRs, and `CHANGELOG.md`'s own preamble +already states the intended process ("a version is released only after the +protected `main` branch, required Checks, independent review, and release +artifacts are verified on the same commit") — but nothing has ever executed +it. + +This is a real, evidenced cross-repo consumer defect, not a hypothetical +gap. On `contextual-orchestrator#971` the repository owner (seonghobae) +recorded, on the same PR body this ADR's `success_criteria` cite, four +independent consumer-owner handoffs that all hit the same wall: + +- **`ContextualWisdomLab/keyverse#132`** (2026-09-02T09:42:46Z): Keyverse + vendors `contextual-orchestrator` at commit `045d17da5e2aea56a97e241ee158ab1 + 628d78660`, 175 commits behind protected `main`. A later comment + (2026-09-02T18:26:04Z) confirms Keyverse is still pinning source revision + `464da4715b495b5eaaa593eba3796e2d976ee0c9` because "[t]he owner repository + currently has no GitHub `latest` release endpoint (`/releases/latest` + returns 404)." +- **`ContextualWisdomLab/bandscope#881`** (2026-09-02T11:15:54Z): explicitly + told not to copy the mutable owner branch or invent a direct provider + fallback; waiting on "an immutable contextual-orchestrator release with + the compatible OpenAI-style gateway/API contract." +- **Wardnet** (2026-09-02T11:42:50Z): "[f]resh release inventory for + `ContextualWisdomLab/contextual-orchestrator` is empty, so Wardnet cannot + correctly replace these seams with a mutable branch or copied source." +- **EgressWeave#235** (referenced 2026-09-02T18:26:04Z): a 45-minute Actions + job timeout on the same gateway-backed pattern — a related but distinct + resumable-long-running-execution gap, explicitly out of scope for this ADR + (see Non-Goals). + +The owner's stated RED/GREEN acceptance for the release piece, verbatim from +that last comment: "the resulting released API/client/schema is immutable +enough for consumers to pin without vendoring this repository's source ... +No paid/provider-specific fallback should be required to consume it." + +### Does this compose with `release_authorization.py`, or is it a new concern? + +`contextual_orchestrator/release_authorization.py` +(`evaluate_release_authorization`), its ADR (0020), and every test/doc that +touches it (`tests/test_release_authorization.py`, +`tests/test_release_authority_snapshot.py`, +`tests/test_commercial_release_candidate.py`, +`docs/commercial_release_candidate.md`, `docs/doctoring/ +release-authorization.md`) were read in full before writing this ADR. That +machinery: + +- is a **pure evaluator function** plus a **read-only collector script** + (`scripts/ci/release_authority_snapshot.py`) that together answer "does + this GitHub *pull request*'s exact head currently satisfy protected-`main` + governance (checks, independent review, findings)?"; +- feeds exactly one caller: `TaskOrchestrator`'s + `commercial_release_candidate_report()` behind + `/api/v1/commercial_release_candidates/latest` — a **buyer-facing product + evidence report inside the running gateway service**, gated behind admin + auth, consumed by a human/procurement audience; +- requires `--pr ` and a KV-registered HMAC signing key + (`CONTEXTUAL_ORCHESTRATOR_RELEASE_AUTHORITY_SIGNING_KEY`) before a server + operator can even load a snapshot; +- is **never invoked by any GitHub Actions workflow today** — it is a manual/ + administrative tool, run by a human or agent with an authenticated `gh` CLI, + its own docs say to "[r]egister ... in the KV for both the protected CI + collector and the gateway"; +- has **no code path that creates a git tag, a GitHub Release, or any + publication artifact**. `docs/commercial_release_candidate.md`'s own scope + section says as much: it is "a local product readiness artifact, not a + valuation guarantee, purchase commitment, or production compliance + certificate." + +**Conclusion: this is a new, distinct concern that must not be built as a +duplicate of the same governance.** The two do not compose at the function- +call level, for a concrete reason that is not merely "different endpoint": +`collect_authority()` is *PR-scoped* (it reads `pulls/{pr}/reviews`, +`pulls/{pr}/commits`, and a PR's `head`/`base`). Protected `main`'s tip after +a merge is not "a pull request" — there is no stable, non-fragile way to +keep re-deriving "which PR produced this exact main commit" arbitrarily far +into a release workflow's future without either (a) hardcoding a PR number +that goes stale the moment another PR merges, or (b) reverse-searching GitHub +for the merging PR by commit SHA, which is unreliable across squash/rebase/ +merge-commit strategies and would itself duplicate GitHub's own merge +bookkeeping inside this repository — exactly what ADR 0020 already warns +against: "GitHub governance remains in the central `.github` repository +rather than being duplicated in the inference runtime." + +That said, the two **do share the same fail-closed spirit**, and the release +mechanism must honor it without re-implementing it: + +- Branch protection (the active ruleset `release_authority_snapshot.py` + itself inspects via `rulesets?includes_parents=true`) is the actual + enforcement point. It already refuses to let a PR merge into `main` without + every required check terminal-success and the required independent + approval on that exact head. Any commit that is genuinely the current tip + of protected `main` has therefore already passed the same evidence + `evaluate_release_authorization()` would demand of a PR — enforced once, at + the authoritative point, not re-derived speculatively per release. + Composing "in spirit" means the release workflow's job is to verify that a + commit **is, in fact, untampered current protected-`main` tip** (guards + against a stale ref, a race with a concurrent merge, or a direct push that + bypassed the ruleset) and to re-run this repository's own primary + regression gate fresh, immediately before cutting an immutable artifact — + not to re-ask "was this PR reviewed," which branch protection already + answered irrevocably before the merge could exist. +- `docs/commercial_release_candidate.md` frames buyer-facing "release + authorization" and "product evidence" as deliberately separate concerns + that must never be conflated ("Product evidence and release authorization + are separate ... Reviewer delay ... never authorizes a release"). A GitHub + Release/tag is neither of those two things; it is a third, narrower + concern — "does this exact artifact exist at an immutable, citable + address" — and folding it into either existing surface would blur a + boundary this repository has already deliberately drawn. + +## Decision + +### Trigger + +`workflow_dispatch` only, with a required `version` input (e.g. `0.2.0`, no +leading `v`). No `push`, `schedule`, or tag-push trigger. Releases are +deliberate, maintainer-initiated actions, never an automatic side effect of +merging to `main` — consistent with the owner's explicit request in the task +that spawned this ADR and with `CHANGELOG.md`'s own stated process. Manually +dispatching a workflow already requires write access to the repository, which +is the same friction this repository already relies on elsewhere (e.g. no +separate actor allowlist exists for `workflow_dispatch` in `nim-benchmark.yml` +or `provider-catalog-sync.yml`); adding a bespoke actor check here would be +new, unproven ceremony this repository has not needed before. + +### Gate (never weakened, never skipped) + +Before any tag or Release is created, the release job: + +1. Confirms the ref is `refs/heads/main`. +2. Re-fetches protected `main`'s current tip via `gh api repos/$REPO/commits/ + main --jq .sha` and fails closed if it does not exactly equal the checked- + out commit — guards against a stale dispatch racing a concurrent merge, or + a detached/rewritten ref. This needs only `contents: read`. +3. Parses `pyproject.toml`'s `version = "..."` and fails closed unless it is + byte-for-byte equal to the `version` input. A release never redefines what + version a commit is; the version bump is a normal, already-reviewed PR + that must land first. +4. Fails closed if git tag `v${version}` already exists locally or on the + remote — an existing tag is never moved, deleted, or overwritten + (immutability). +5. Runs this repository's own full test suite fresh, on the exact commit + about to be tagged (`uv run --locked --extra api --extra db --extra queue + --group dev python -m pytest -q`, the same invocation `ci.yml`'s "Full + unit and contract suite" job uses) — a genuine, no-stale-cache + confirmation of the single most likely regression surface, not merely a + read of a prior run's status. +6. Extracts `CHANGELOG.md`'s `## [${version}]` section (`scripts/ci/ + release_notes.py`, new, tested) and fails closed if that section is + missing or empty — a Release is never published without real notes. + +Steps 5 (full local suite) and 6 (real content) are direct, cheap, in- +workflow re-verification. CodeQL, Trivy, OSV, Scorecard, Semgrep, +`opencode-review`, `noema-review`, and `strix` are **not** re-executed inside +`release.yml`: they are exactly the required checks that already had to pass +before this commit could reach protected `main` at all (step 2 confirms that +identity), several are centrally owned by `ContextualWisdomLab/.github` per +this repository's own `CLAUDE.md` ("Central PR governance ... is the +canonical implementation ... for every sibling repo"), and re-running them +here would duplicate infrastructure this repository does not own rather than +add release-specific assurance. + +### What the release contains + +- An **annotated** git tag `v${version}` (`git tag -a`, not lightweight — + carries tagger identity and a message, and is the artifact GitHub's + Release API attaches to). +- A **GitHub Release** (`gh release create`) at that tag, titled `v${version}`, + with a body built from the extracted `CHANGELOG.md` section plus the exact + released commit SHA. +- The workflow uploads the CycloneDX SBOM `security.yml`'s + `python_supply_chain` job already generates as a release asset when that + artifact is available for the released commit, giving consumers the same + provenance evidence this repository already produces for every merge to + `main` — reusing existing SBOM generation rather than adding a second one + inside `release.yml`. +- `permissions: contents: write` is scoped to the one job that creates the + tag/Release; every other job/step keeps the workflow-default `contents: + read`. + +### Non-goals (explicitly deferred — do not build now) + +- **Composing with `release_authorization.py` at the function-call level.** + Deferred per the Context section above. A future PR could extend + `evaluate_release_authorization()`/its collector to accept a bare commit + SHA instead of a PR number if a concrete need for that specific evidence + shape (rather than the ruleset-tip check this ADR specifies) emerges; not + needed for a first working mechanism. +- **Resumable long-running execution / checkpoint-and-re-dispatch** (the + EgressWeave#235 / `OPENCODE_RUN_TIMEOUT_SECONDS` half of the owner's + 2026-09-02 comment). Real, but an orthogonal runtime concern from + publishing an immutable release artifact; tracked separately in + `docs/product-technical-gap-baseline.md`. +- **Publishing to PyPI or any package index.** Consumers named in the + evidence (Keyverse, BandScope, Wardnet) vendor/pin *source*, not a Python + package; a PyPI publish step is unevidenced scope growth for this pass. + `pyproject.toml`'s own `version` field is exactly what a future PyPI step + would need, so nothing here forecloses it. +- **Automatic `pyproject.toml` version bumping.** The version bump remains a + normal, reviewed PR; `release.yml` only validates it matches the dispatch + input. +- **A release cut on every merge to `main`.** Explicitly rejected — see + Trigger above. +- **Re-deriving required-check names dynamically from the GitHub ruleset + API** (as `release_authority_snapshot.py` does for its PR-scoped + evidence). This ADR's gate does not need the exhaustive required-check + inventory — it needs "is this commit really `main`'s tip" (step 2) plus a + fresh regression run (step 5), both of which are simpler and do not need + the `administration: read` permission the rulesets endpoint requires. + +## Consequences + +- Consumers gain a real, immutable pin target: + `github.com/ContextualWisdomLab/contextual-orchestrator/releases/latest` + and `.../releases/tag/v0.2.0`, satisfying the owner's stated acceptance + criterion without any paid or provider-specific dependency. +- Releasing stays deliberate and rare (manual `workflow_dispatch`), matching + `CHANGELOG.md`'s existing stated process instead of introducing a new, + undocumented cadence. +- The buyer-facing `/api/v1/commercial_release_candidates/latest` surface, + `release_authorization.py`, and ADR 0020 are untouched by this change — + they keep answering "is this PR commercially/buyer-sale-ready," a + different question from "does an immutable citable artifact exist." +- A future PR remains free to wire a stronger, SHA-scoped variant of + `evaluate_release_authorization()` into this gate if a concrete gap in the + current tip-check surfaces in practice, without needing to revisit this + ADR's core trigger/tag/notes decisions. + +## Customer next action + +Run `.github/workflows/release.yml` via `workflow_dispatch` with `version` +set to `pyproject.toml`'s current value once a maintainer has confirmed the +`## [x.y.z]` `CHANGELOG.md` section is ready to publish. This ADR's own +implementation does not trigger a real release; cutting the first `v0.2.0` +tag is a separate, deliberate action left to the repository owner. From a827880995d8cb874d35f5ebdb8096ad0a2a96c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 19:19:58 +0000 Subject: [PATCH 09/35] docs: add RELEASING.md maintainer runbook Documents preconditions, the dispatch steps, what happens after a release, and rollback policy (a mistake gets a new patch release, never a routine moved/deleted tag) for a human cutting a real contextual-orchestrator release. Explicitly distinguishes this from docs/commercial_release_candidate.md, which answers a different question (buyer/procurement readiness) and is untouched by this change. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4 --- docs/RELEASING.md | 86 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 docs/RELEASING.md diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 000000000..68642bc2a --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,86 @@ +# Releasing + +This document is for a human maintainer cutting a real, immutable +`contextual-orchestrator` release. It is deliberately narrow — see +[`docs/planning/adrs/0127-canonical-immutable-release.md`](planning/adrs/0127-canonical-immutable-release.md) +for the full design and its explicit non-goals. + +## What a release is, and is not + +A release is a git tag `vX.Y.Z` and a GitHub Release built from it. It gives +downstream consumers (Keyverse, BandScope, Wardnet, and others) an immutable, +citable pin target — `.../releases/latest` and `.../releases/tag/vX.Y.Z` — so +they never again need to vendor a mutable source SHA off `main`. + +A release is **not** the same thing as +[`/api/v1/commercial_release_candidates/latest`](commercial_release_candidate.md) +or `contextual_orchestrator/release_authorization.py`. Those answer "is this +pull request commercially/buyer-sale-ready" for a human procurement audience, +gated behind admin auth inside the running gateway. This document's release +mechanism answers a narrower question — "does an immutable, citable artifact +exist for this exact commit" — and is unaffected by, and does not affect, +that separate system. + +## Preconditions + +1. The version to release is already merged to `main`: `pyproject.toml`'s + `version` field carries the exact `X.Y.Z` you intend to release, landed + through the normal PR process (review, required checks, no exceptions). +2. `CHANGELOG.md` has a `## [X.Y.Z]` section (an `- Unreleased` or dated + suffix is fine) with real, non-empty content describing what changed. +3. No git tag `vX.Y.Z` already exists (`git tag -l | grep vX.Y.Z` locally, or + check ). + A tag is never reused or moved onto a different commit — bump the version + again if you need to re-release. +4. `main` is currently green — its own required checks (Tests, Security, + Fuzz, and the org-central Strix/OpenCode/security-scan/OSV/Scorecard + checks from `ContextualWisdomLab/.github`) are passing. The release + workflow re-verifies the commit is genuinely `main`'s untampered tip and + re-runs the full test suite fresh, but it does not re-run CodeQL, Trivy, + OSV, Scorecard, or the review bots — those already had to pass before this + commit could exist on protected `main` at all. + +## Cutting a release + +1. Go to **Actions → Release → Run workflow** in the GitHub UI (or + `gh workflow run release.yml -f version=X.Y.Z`). +2. Select branch `main` (the workflow refuses to run against anything else). +3. Enter the exact version, e.g. `0.2.0` — no leading `v`, must match + `pyproject.toml` byte-for-byte. +4. Dispatch. The workflow, in order: + - fails closed if the dispatched commit is not `main`'s current tip (a + race with a concurrent merge); + - fails closed if the input version does not match `pyproject.toml`; + - fails closed if the tag already exists, locally or on `origin`; + - runs the full test suite fresh (`uv run --locked --extra api --extra db + --extra queue --group dev python -m pytest -q`); + - renders release notes from `CHANGELOG.md`'s matching section + (`scripts/ci/release_notes.py`, tested in + `tests/test_release_notes.py`); + - creates and pushes an annotated tag `vX.Y.Z`; + - best-effort attaches the CycloneDX SBOM from the matching successful + `security.yml` run for this commit, if one exists (a missing SBOM warns, + it never blocks the release); + - publishes the GitHub Release. +5. Confirm at + . + +## After a release + +- Bump `pyproject.toml`'s `version` and open a new `## [next-version] - + Unreleased` `CHANGELOG.md` section in an ordinary PR, so the repository is + never left claiming to already be the version it just released. +- Downstream consumers with an open handoff on this gap + (`ContextualWisdomLab/keyverse#132`, `ContextualWisdomLab/bandscope#881`, + the Wardnet consumer-owner handoff on `contextual-orchestrator#971`) can + now bump to the published tag instead of a vendored source SHA. + +## Rollback + +Releases are immutable — never delete or retag a published release to "fix" +it. If a released commit turns out to be broken, release a new patch/minor +version with the fix through the same process above. `gh release delete +vX.Y.Z` (and its tag) is reserved for a genuine publishing mistake caught +immediately after dispatch, before any consumer could plausibly have pinned +it, and should still be treated as an exceptional, logged action, not routine +practice. From 27136fe983a0fb53fd7ab4c7aa8a2369cfde3aa8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 19:20:05 +0000 Subject: [PATCH 10/35] docs(gaps): record the canonical immutable release mechanism landing - product-technical-gap-baseline.md: dated 2026-09-02 entry with the Phase 1 research finding (release_authorization.py is a distinct, PR-scoped buyer-evidence concern, not a release publisher), the direct consumer evidence from contextual-orchestrator#971 (keyverse#132/bandscope#881/Wardnet/EgressWeave#235), what was built, and verification evidence. Explicitly notes no real release was triggered and the resumable-long-running-execution half of the gap is unaddressed here. - CHANGELOG.md: new [0.2.0] Added entry for the release mechanism itself. - conductor/tracks.md: new active track 005-canonical-immutable-release. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4 --- CHANGELOG.md | 19 ++++ conductor/tracks.md | 1 + docs/product-technical-gap-baseline.md | 132 +++++++++++++++++++++++++ 3 files changed, 152 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f0b268c9..433ab3fad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,25 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [0.2.0] - Unreleased +### Added + +- A canonical, immutable release mechanism: `.github/workflows/release.yml` + (`workflow_dispatch` only, explicit `version` input, never triggered by + push/schedule/merge) verifies the dispatched commit is protected `main`'s + untampered current tip, verifies the requested version matches + `pyproject.toml`, refuses to re-publish or move an existing tag, re-runs + the full test suite fresh, renders release notes from this file's matching + `## [X.Y.Z]` section via the new tested `scripts/ci/release_notes.py`, then + creates an annotated `vX.Y.Z` tag and a GitHub Release (best-effort + CycloneDX SBOM asset attached when available). Gives downstream consumers + (`ContextualWisdomLab/keyverse#132`, `bandscope#881`, and the Wardnet + consumer-owner handoff, all recorded on `contextual-orchestrator#971`) an + immutable pin target instead of a vendored source SHA. See + `docs/planning/adrs/0127-canonical-immutable-release.md` and + `docs/RELEASING.md`. No release has been cut yet — landing this mechanism + and dispatching the first `v0.2.0` release are deliberately separate + actions. + ### Deprecated - Internal callers now use diff --git a/conductor/tracks.md b/conductor/tracks.md index 6f1f9ec5b..e5f3a967a 100644 --- a/conductor/tracks.md +++ b/conductor/tracks.md @@ -6,3 +6,4 @@ | 002-enterprise-design-foundation | active | Add paper-grounded screen design, user stories, REST API, code/DB conventions, and i18n | | 003-reasoning-effort-profiles | active | Issue #568: versioned per-role `reasoning_effort_profile`, equal-budget θ̂ RMSE ablation, snapshot on run/stream/batch, production defaults locked | | 004-nim-cost-quality-benchmark | active | Evidence-grade NIM catalog discovery, all-modality capability probes, and the route/conduct/single-worker cost-quality benchmark (docs/nim_benchmark.md) | +| 005-canonical-immutable-release | active | Deliberate `workflow_dispatch` release mechanism (ADR 0127): protected-main-tip + version + fresh-suite gate, CHANGELOG-derived notes, annotated tag, GitHub Release — so consumers pin a release instead of vendoring source (docs/RELEASING.md) | diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index d145a0b1d..fef1a1d13 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,137 @@ # Contextual Orchestrator: Product & Technical Gap Baseline +## 2026-09-02 canonical immutable release + resumable long-running execution + +Observation time: 2026-09-02 Asia/Seoul. + +### The gap, with direct consumer evidence + +This repository has never cut a release: `git tag -l` was empty, no +`.github/workflows/*release*.yml` existed, and `GET /repos/ +ContextualWisdomLab/contextual-orchestrator/releases/latest` returned 404. +`pyproject.toml` has carried `version = "0.2.0"` and `CHANGELOG.md` an +`## [0.2.0] - Unreleased` section through hundreds of merged PRs, and +`CHANGELOG.md`'s own preamble already stated the intended process — but +nothing had ever executed it. + +Four independent downstream consumers hit this wall on +[`contextual-orchestrator#971`](https://github.com/ContextualWisdomLab/contextual-orchestrator/pull/971) +(owner seonghobae, all comments dated 2026-09-02 Asia/Seoul): + +- **[`ContextualWisdomLab/keyverse#132`](https://github.com/ContextualWisdomLab/keyverse/issues/132)**: + vendors this repository at commit `045d17da5e2aea56a97e241ee158ab1628d78660`, + 175 commits behind protected `main`; a later comment confirms it is still + pinning `464da4715b495b5eaaa593eba3796e2d976ee0c9` because "[t]he owner + repository currently has no GitHub `latest` release endpoint (`/releases/ + latest` returns 404)." +- **`ContextualWisdomLab/bandscope#881`**: told explicitly not to copy the + mutable owner branch or invent a direct provider fallback while waiting for + "an immutable contextual-orchestrator release with the compatible + OpenAI-style gateway/API contract." +- **Wardnet** (consumer-owner handoff comment on `#971`): "[f]resh release + inventory for `ContextualWisdomLab/contextual-orchestrator` is empty, so + Wardnet cannot correctly replace these seams with a mutable branch or + copied source." +- **`ContextualWisdomLab/EgressWeave#235`**: a 45-minute Actions job timeout + on the same gateway-backed pattern — the resumable-long-running-execution + half of this gap, tracked here but explicitly out of scope for the release + mechanism landed below (see Non-goals in ADR 0127). + +The owner's stated RED/GREEN acceptance for the release piece, verbatim +(PR #971, comment at 2026-09-02T18:26:04Z): "the resulting released API/ +client/schema is immutable enough for consumers to pin without vendoring +this repository's source ... No paid/provider-specific fallback should be +required to consume it." + +### Research: does this compose with `release_authorization.py`? + +Read in full before designing anything: +`contextual_orchestrator/release_authorization.py`, +`docs/planning/adrs/0020-fail-closed-release-authorization.md`, +`docs/commercial_release_candidate.md`, +`docs/doctoring/release-authorization.md`, +`tests/test_release_authorization.py`, +`tests/test_release_authority_snapshot.py`, +`tests/test_commercial_release_candidate.py`, +`scripts/ci/release_authority_snapshot.py`. + +Confirmed by reading the code (not assuming): that machinery is a pure +evaluator (`evaluate_release_authorization`) plus a read-only, **PR-scoped** +collector, feeding exactly one caller — +`/api/v1/commercial_release_candidates/latest`, a buyer-facing product +readiness report behind admin auth inside the running gateway. It is never +invoked by any GitHub Actions workflow today, requires a KV-registered HMAC +signing key before a snapshot can even be loaded, and has no code path that +creates a git tag, a GitHub Release, or any publication artifact — +`docs/commercial_release_candidate.md` says as much itself ("a local product +readiness artifact, not a ... production compliance certificate"). + +**Conclusion**: a new, distinct concern, not a duplicate. The two do not +compose at the function-call level because `collect_authority()` is +PR-scoped and protected `main`'s tip after a merge is not "a pull request" — +re-deriving "which PR produced this commit" indefinitely into the future +would itself duplicate GitHub's merge bookkeeping inside this repository, +which ADR 0020 already warns against. They share the same fail-closed +spirit without sharing code: branch protection already enforced the checks/ +review evidence `evaluate_release_authorization()` would ask for, once, at +merge time — the release gate's job is to confirm a commit really is that +untampered protected-`main` tip, not re-litigate a question branch +protection already answered. Full reasoning: +[ADR 0127](planning/adrs/0127-canonical-immutable-release.md). + +### What was built (this session, landed on a branch — not yet merged, no release cut) + +- **ADR**: `docs/planning/adrs/0127-canonical-immutable-release.md` — trigger + (`workflow_dispatch` only, explicit `version` input, never push/schedule), + gate (exact current-main-tip check, `pyproject.toml` version match, tag- + non-existence, a fresh full-suite pytest run), release contents (annotated + tag `vX.Y.Z`, GitHub Release with CHANGELOG-derived notes, best-effort + CycloneDX SBOM asset), and an explicit non-goals list (no PyPI publish, no + release-on-every-merge, no automatic version bump, no dynamic ruleset-name + re-derivation). +- **RED → GREEN, test-first**: + - `tests/test_release_notes.py` (10 tests) written first against a + not-yet-existing `scripts/ci/release_notes.py`; confirmed failing + (`FileNotFoundError`), then `scripts/ci/release_notes.py` implemented + (pure `read_declared_version`/`extract_changelog_section`/ + `render_release_notes` plus a `main()` CLI) to make all 10 pass. 100% + interrogate docstring coverage. + - `tests/test_release_workflow_contract.py` (15 tests) written against + `.github/workflows/release.yml`; confirmed the file was absent on + `origin/main` (RED baseline) before implementing the workflow, then all + 15 passed against the new file (GREEN). Follows this repository's + existing text-assertion contract-test convention (`tests/ + test_nim_benchmark_workflow_contract.py`) rather than adding a new + PyYAML dependency nothing else in this repository uses. +- **`.github/workflows/release.yml`**: implements the ADR 0127 gate exactly + as designed above; `permissions: contents: write` is scoped to the release + job only, every other default stays `contents: read`. +- **`docs/RELEASING.md`**: new, maintainer-facing runbook for dispatching a + release, its preconditions, and rollback policy (a mistake gets a new + patch release, never a moved/deleted tag as routine practice). + +### Verification evidence + +- `python -m pytest tests/test_release_notes.py tests/ + test_release_workflow_contract.py tests/test_planning_adr_identifiers.py -q` + → 26 passed. +- `python tests/test_self_check.py` → `ok`. +- `python -m interrogate -c pyproject.toml .` → `PASSED (minimum: 100.0%, + actual: 100.0%)` — this repository's repo-wide docstring gate, unaffected + by the new `scripts/ci/release_notes.py`. +- Full `python -m pytest tests -q` run for regression-freedom before landing + the PR (see the PR body for the exact pass count from this run). + +### Explicitly not done in this session + +Per the owner's own instruction: no real release was triggered. Cutting the +first `v0.2.0` tag is a separate, deliberate action left to the repository +owner after this mechanism is reviewed and merged. The resumable-long- +running-execution half of the 2026-09-02 owner comment (EgressWeave#235, +`OPENCODE_RUN_TIMEOUT_SECONDS`, checkpoint/re-dispatch across runner +termination) is unaddressed here — a real, separate runtime gap, deliberately +out of scope for this release-mechanism pass (see ADR 0127's Non-goals). + ## 2026-09-01 Autonomous Commercialization Loop: PR #970 Merge, Token Accounting & Cost Gateway Harmonization Observation time: 2026-09-01 Asia/Seoul. From 615cb5569ca4ec0e5cf1224d106637ea5b276a25 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 19:26:04 +0000 Subject: [PATCH 11/35] docs(adr): renumber canonical immutable release ADR 0127 -> 0129 PR #1020 (ContextualWisdomLab/contextual-orchestrator#1020, `docs/planning/adrs/0127-evidence-based-per-model-timeout-allocator.md`) independently claimed ADR number 0127 before this PR was created, and PR #1012 (#1012) already holds 0128 from an earlier same-day collision fix. Per the ADR-uniqueness convention in this repo's CLAUDE.md ("a same-number collision is a rename, not a redesign") and the tie-breaker already applied once today (the more-recently-created/ updated PR bumps further), this PR's ADR renumbers to 0129 -- verified free against origin/main and all 38 currently open PRs by walking each PR's head tree for new docs/planning/adrs/*.md files not present on main. - Rename docs/planning/adrs/0127-canonical-immutable-release.md to 0129-canonical-immutable-release.md; update its front-matter id. - Update every in-repo reference to the old number: .github/workflows/release.yml, tests/test_release_workflow_contract.py, conductor/tracks.md, CHANGELOG.md, docs/product-technical-gap-baseline.md, docs/RELEASING.md. Verified: tests/test_release_notes.py, tests/test_release_workflow_contract.py, tests/test_planning_adr_identifiers.py, tests/test_product_planning_contract.py (30 passed); python -m interrogate -c pyproject.toml . (100.0%); a scratch- directory side-by-side check against PR #1020's 0127 file confirmed no residual collision at the new number. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4 --- .github/workflows/release.yml | 2 +- CHANGELOG.md | 2 +- conductor/tracks.md | 2 +- docs/RELEASING.md | 2 +- ...-release.md => 0129-canonical-immutable-release.md} | 2 +- docs/product-technical-gap-baseline.md | 10 +++++----- tests/test_release_workflow_contract.py | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) rename docs/planning/adrs/{0127-canonical-immutable-release.md => 0129-canonical-immutable-release.md} (99%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index daa6ac3a7..ef9b7b822 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ name: Release # Publishes a canonical, immutable GitHub Release so downstream consumers can # pin a versioned tag instead of vendoring this repository's source SHA (see -# docs/planning/adrs/0127-canonical-immutable-release.md). This is a +# docs/planning/adrs/0129-canonical-immutable-release.md). This is a # deliberate, maintainer-dispatched action only -- it never runs on push, # schedule, or as a side effect of merging to main. It never re-implements # GitHub branch-protection governance: it verifies the released commit really diff --git a/CHANGELOG.md b/CHANGELOG.md index 433ab3fad..579ef09a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) (`ContextualWisdomLab/keyverse#132`, `bandscope#881`, and the Wardnet consumer-owner handoff, all recorded on `contextual-orchestrator#971`) an immutable pin target instead of a vendored source SHA. See - `docs/planning/adrs/0127-canonical-immutable-release.md` and + `docs/planning/adrs/0129-canonical-immutable-release.md` and `docs/RELEASING.md`. No release has been cut yet — landing this mechanism and dispatching the first `v0.2.0` release are deliberately separate actions. diff --git a/conductor/tracks.md b/conductor/tracks.md index e5f3a967a..5e0c5dbc8 100644 --- a/conductor/tracks.md +++ b/conductor/tracks.md @@ -6,4 +6,4 @@ | 002-enterprise-design-foundation | active | Add paper-grounded screen design, user stories, REST API, code/DB conventions, and i18n | | 003-reasoning-effort-profiles | active | Issue #568: versioned per-role `reasoning_effort_profile`, equal-budget θ̂ RMSE ablation, snapshot on run/stream/batch, production defaults locked | | 004-nim-cost-quality-benchmark | active | Evidence-grade NIM catalog discovery, all-modality capability probes, and the route/conduct/single-worker cost-quality benchmark (docs/nim_benchmark.md) | -| 005-canonical-immutable-release | active | Deliberate `workflow_dispatch` release mechanism (ADR 0127): protected-main-tip + version + fresh-suite gate, CHANGELOG-derived notes, annotated tag, GitHub Release — so consumers pin a release instead of vendoring source (docs/RELEASING.md) | +| 005-canonical-immutable-release | active | Deliberate `workflow_dispatch` release mechanism (ADR 0129): protected-main-tip + version + fresh-suite gate, CHANGELOG-derived notes, annotated tag, GitHub Release — so consumers pin a release instead of vendoring source (docs/RELEASING.md) | diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 68642bc2a..bea3e323b 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -2,7 +2,7 @@ This document is for a human maintainer cutting a real, immutable `contextual-orchestrator` release. It is deliberately narrow — see -[`docs/planning/adrs/0127-canonical-immutable-release.md`](planning/adrs/0127-canonical-immutable-release.md) +[`docs/planning/adrs/0129-canonical-immutable-release.md`](planning/adrs/0129-canonical-immutable-release.md) for the full design and its explicit non-goals. ## What a release is, and is not diff --git a/docs/planning/adrs/0127-canonical-immutable-release.md b/docs/planning/adrs/0129-canonical-immutable-release.md similarity index 99% rename from docs/planning/adrs/0127-canonical-immutable-release.md rename to docs/planning/adrs/0129-canonical-immutable-release.md index 41b24350b..6a99ef0a6 100644 --- a/docs/planning/adrs/0127-canonical-immutable-release.md +++ b/docs/planning/adrs/0129-canonical-immutable-release.md @@ -1,5 +1,5 @@ --- -id: "0127" +id: "0129" title: "Publish a canonical immutable GitHub Release, gated on protected-main evidence" status: proposed proposed_date: "2026-09-02" diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index fef1a1d13..2a6548662 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -35,7 +35,7 @@ Four independent downstream consumers hit this wall on - **`ContextualWisdomLab/EgressWeave#235`**: a 45-minute Actions job timeout on the same gateway-backed pattern — the resumable-long-running-execution half of this gap, tracked here but explicitly out of scope for the release - mechanism landed below (see Non-goals in ADR 0127). + mechanism landed below (see Non-goals in ADR 0129). The owner's stated RED/GREEN acceptance for the release piece, verbatim (PR #971, comment at 2026-09-02T18:26:04Z): "the resulting released API/ @@ -77,11 +77,11 @@ review evidence `evaluate_release_authorization()` would ask for, once, at merge time — the release gate's job is to confirm a commit really is that untampered protected-`main` tip, not re-litigate a question branch protection already answered. Full reasoning: -[ADR 0127](planning/adrs/0127-canonical-immutable-release.md). +[ADR 0129](planning/adrs/0129-canonical-immutable-release.md). ### What was built (this session, landed on a branch — not yet merged, no release cut) -- **ADR**: `docs/planning/adrs/0127-canonical-immutable-release.md` — trigger +- **ADR**: `docs/planning/adrs/0129-canonical-immutable-release.md` — trigger (`workflow_dispatch` only, explicit `version` input, never push/schedule), gate (exact current-main-tip check, `pyproject.toml` version match, tag- non-existence, a fresh full-suite pytest run), release contents (annotated @@ -103,7 +103,7 @@ protection already answered. Full reasoning: existing text-assertion contract-test convention (`tests/ test_nim_benchmark_workflow_contract.py`) rather than adding a new PyYAML dependency nothing else in this repository uses. -- **`.github/workflows/release.yml`**: implements the ADR 0127 gate exactly +- **`.github/workflows/release.yml`**: implements the ADR 0129 gate exactly as designed above; `permissions: contents: write` is scoped to the release job only, every other default stays `contents: read`. - **`docs/RELEASING.md`**: new, maintainer-facing runbook for dispatching a @@ -130,7 +130,7 @@ owner after this mechanism is reviewed and merged. The resumable-long- running-execution half of the 2026-09-02 owner comment (EgressWeave#235, `OPENCODE_RUN_TIMEOUT_SECONDS`, checkpoint/re-dispatch across runner termination) is unaddressed here — a real, separate runtime gap, deliberately -out of scope for this release-mechanism pass (see ADR 0127's Non-goals). +out of scope for this release-mechanism pass (see ADR 0129's Non-goals). ## 2026-09-01 Autonomous Commercialization Loop: PR #970 Merge, Token Accounting & Cost Gateway Harmonization diff --git a/tests/test_release_workflow_contract.py b/tests/test_release_workflow_contract.py index f95f5a84a..b2d6e8489 100644 --- a/tests/test_release_workflow_contract.py +++ b/tests/test_release_workflow_contract.py @@ -1,7 +1,7 @@ """Static contract for the canonical immutable release mechanism. Pins the structure of `.github/workflows/release.yml` per -docs/planning/adrs/0127-canonical-immutable-release.md: a deliberate, +docs/planning/adrs/0129-canonical-immutable-release.md: a deliberate, maintainer-dispatched trigger only; a fail-closed gate that verifies the released commit is protected main's untampered current tip and that the requested version matches `pyproject.toml`; a fresh full test-suite run; and From eea42bd679a9e335a5d410588cfeca515f929389 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 19:58:34 +0000 Subject: [PATCH 12/35] fix(release): idempotent publish + least-privilege split + final tip check Addresses Devin findings 1-3 on PR #1030 (all sharing one root cause: the tag is pushed before the fallible SBOM/GitHub-Release steps), plus the overlapping CodeRabbit findings and its CWE-269 least-privilege suggestion: - Finding 1 (concurrent merges produce stale releases): add a second, authoritative main-tip check in the `publish` job, immediately before tag creation and after every long-running gate (fresh test suite, note rendering) -- the original early check stays as a fast-fail. - Finding 2 (SBOM lookup strands every release): grant `actions: read` at the `verify` job's scope, and make every SBOM discovery call genuinely non-fatal via explicit `if !` guards instead of a bare `set -e` that aborted the whole job on the permission gap. - Finding 3 (publication failures block automated recovery): resolve an existing `vX.Y.Z` tag through the GitHub commits API into resume (points at this exact commit, no Release published yet -- skip re-tagging) vs. reject (points elsewhere, or already has a published Release), replacing the old any-existing-tag hard fail. - CodeRabbit's CWE-269 suggestion: split the job into a read-only, credential-less `verify` job (runs all repository-controlled code -- the test suite and note rendering -- with no write token or persisted git credential present) and a write-scoped `publish` job (tag + Release only, `needs: verify`). This also gives finding 1's final tip check its natural placement: the first thing `publish` does. Notes/SBOM cross the job boundary via upload-artifact/download-artifact. Finding 4 (tests miss operational invariants): `tests/ test_release_workflow_contract.py` gained a `_job_block` helper to bound assertions to one job's own steps instead of loose substring presence, plus new tests for job-scoped permissions and the final-check ordering. The resume/reject branching and least-privilege split get their own file, `tests/test_release_workflow_idempotency_contract.py` (12 tests), since they share the one root cause above. Verified the new/changed assertions actually fail against the pre-fix workflow (not vacuous) by running both files against a stash of the original file. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4 --- .github/workflows/release.yml | 120 ++++++++-- tests/test_release_workflow_contract.py | 121 +++++++++- ...t_release_workflow_idempotency_contract.py | 225 ++++++++++++++++++ 3 files changed, 431 insertions(+), 35 deletions(-) create mode 100644 tests/test_release_workflow_idempotency_contract.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef9b7b822..eeaf834fd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,13 @@ name: Release # GitHub branch-protection governance: it verifies the released commit really # is protected main's current, untampered tip, then re-runs this repository's # own primary regression gate fresh before cutting anything. +# +# Two jobs, least-privilege: `verify` runs with no write permission and no +# persisted git credentials while it executes repository-controlled code +# (the test suite, release-notes rendering) -- a `contents: write` token is +# never present alongside that code. `publish` holds the write token, and its +# very first step re-checks protected main's tip immediately before tag +# creation, right after `verify`'s potentially long-running gate finishes. on: workflow_dispatch: @@ -25,20 +32,23 @@ concurrency: cancel-in-progress: false jobs: - release: - name: Publish canonical immutable release + verify: + name: Verify release preconditions (read-only) if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: - contents: write + contents: read + actions: read env: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ inputs.version }} + outputs: + tag_resume: ${{ steps.tag_state.outputs.tag_resume }} steps: - - name: Checkout protected main + - name: Checkout protected main (no write credentials) uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 with: - persist-credentials: true + persist-credentials: false fetch-depth: 0 fetch-tags: true @@ -61,27 +71,41 @@ jobs: with: version: "0.12.5" - - name: Verify the requested version matches pyproject.toml + - name: Verify the requested version matches pyproject.toml's [project] table run: | set -euo pipefail - declared="$(python -c 'import re,sys; text=open("pyproject.toml", encoding="utf-8").read(); m=re.search(r"(?m)^version\s*=\s*\"([^\"]+)\"\s*$", text); sys.exit(1) if not m else print(m.group(1))')" + declared="$(python -c 'from scripts.ci.release_notes import read_declared_version; print(read_declared_version(open("pyproject.toml", encoding="utf-8").read()))')" if [ "${declared}" != "${RELEASE_VERSION}" ]; then - echo "::error::pyproject.toml declares version '${declared}', but the dispatch input was '${RELEASE_VERSION}'. Land a version-bump PR first; a release never redefines a commit's version." >&2 + echo "::error::pyproject.toml's [project] table declares version '${declared}', but the dispatch input was '${RELEASE_VERSION}'. Land a version-bump PR first; a release never redefines a commit's version." >&2 exit 1 fi - - name: Verify tag v${{ inputs.version }} does not already exist + - name: Determine whether the release tag is a fresh publish or a safe resume + id: tag_state run: | set -euo pipefail - if git rev-parse "refs/tags/v${RELEASE_VERSION}" >/dev/null 2>&1; then - echo "::error::Tag v${RELEASE_VERSION} already exists locally; a release is never re-published or moved onto a new commit." >&2 + # The commits API dereferences an annotated or lightweight tag to + # its target commit in one call, and needs no local git fetch or + # push credential -- this step runs with contents: read only. + if ! tag_commit="$(gh api "repos/${GITHUB_REPOSITORY}/commits/v${RELEASE_VERSION}" --jq .sha 2>/dev/null)"; then + echo "::notice::Tag v${RELEASE_VERSION} does not exist yet; this is a fresh publish." + echo "tag_resume=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + + if [ "${tag_commit}" != "${GITHUB_SHA}" ]; then + echo "::error::Tag v${RELEASE_VERSION} already exists and points at ${tag_commit}, not this run's commit (${GITHUB_SHA}); a release tag is never moved onto a different commit. Publish a new version instead." >&2 exit 1 fi - if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/v${RELEASE_VERSION}" >/dev/null 2>&1; then - echo "::error::Tag v${RELEASE_VERSION} already exists on origin; a release is never re-published or moved onto a new commit." >&2 + + if gh release view "v${RELEASE_VERSION}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + echo "::error::v${RELEASE_VERSION} already has a published GitHub Release for this exact commit; there is nothing left to resume." >&2 exit 1 fi + echo "::notice::Tag v${RELEASE_VERSION} already points at this commit with no published GitHub Release yet; resuming publication instead of re-tagging." + echo "tag_resume=true" >> "${GITHUB_OUTPUT}" + - name: Run the full required test suite fresh on this exact commit run: uv run --locked --extra api --extra db --extra queue --group dev python -m pytest -q @@ -96,7 +120,67 @@ jobs: --commit-sha "${GITHUB_SHA}" \ --output release-notes.md + - name: Fetch the CycloneDX SBOM for this commit, best-effort + run: | + set -uo pipefail + run_id="" + if ! run_id="$(gh run list --repo "${GITHUB_REPOSITORY}" --workflow security.yml --commit "${GITHUB_SHA}" --status success --json databaseId --jq '.[0].databaseId // empty')"; then + echo "::warning::Could not query Security workflow runs for commit ${GITHUB_SHA} (permission or API error); publishing without an SBOM asset." + run_id="" + fi + if [ -n "${run_id}" ]; then + if ! gh run download --repo "${GITHUB_REPOSITORY}" "${run_id}" --name cyclonedx-sbom --dir sbom-download; then + echo "::warning::Security workflow run ${run_id} has no downloadable cyclonedx-sbom artifact; publishing without it." + fi + else + echo "::warning::No successful Security workflow run found for commit ${GITHUB_SHA}; publishing without an SBOM asset." + fi + + - name: Upload rendered notes and SBOM for the publish job + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # actions/upload-artifact@v5 + with: + name: release-publish-inputs + path: | + release-notes.md + sbom-download/** + if-no-files-found: ignore + retention-days: 1 + + publish: + name: Publish canonical immutable release + needs: verify + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + TAG_RESUME: ${{ needs.verify.outputs.tag_resume }} + steps: + - name: Checkout protected main + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + persist-credentials: true + fetch-depth: 0 + fetch-tags: true + + - name: Re-verify protected main has not advanced since verification started + run: | + set -euo pipefail + remote_head="$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha)" + if [ "${remote_head}" != "${GITHUB_SHA}" ]; then + echo "::error::Protected main advanced to ${remote_head} while the verify job was testing and rendering notes for ${GITHUB_SHA}; refusing to publish a now-superseded commit as the latest release. Re-dispatch against the fresh tip." >&2 + exit 1 + fi + + - name: Download the release notes and SBOM produced by verify + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # actions/download-artifact@v5 + with: + name: release-publish-inputs + - name: Create the annotated release tag + if: env.TAG_RESUME != 'true' run: | set -euo pipefail git config user.name "github-actions[bot]" @@ -104,16 +188,6 @@ jobs: git tag -a "v${RELEASE_VERSION}" -m "Release v${RELEASE_VERSION}" git push origin "refs/tags/v${RELEASE_VERSION}" - - name: Fetch the CycloneDX SBOM for this commit, best-effort - run: | - set -euo pipefail - run_id="$(gh run list --repo "${GITHUB_REPOSITORY}" --workflow security.yml --commit "${GITHUB_SHA}" --status success --json databaseId --jq '.[0].databaseId // empty')" - if [ -n "${run_id}" ]; then - gh run download --repo "${GITHUB_REPOSITORY}" "${run_id}" --name cyclonedx-sbom --dir sbom-download || echo "::warning::Security workflow run ${run_id} has no cyclonedx-sbom artifact; publishing without it." - else - echo "::warning::No successful Security workflow run found for commit ${GITHUB_SHA}; publishing without an SBOM asset." - fi - - name: Publish the GitHub Release run: | set -euo pipefail diff --git a/tests/test_release_workflow_contract.py b/tests/test_release_workflow_contract.py index b2d6e8489..f5f937707 100644 --- a/tests/test_release_workflow_contract.py +++ b/tests/test_release_workflow_contract.py @@ -5,13 +5,20 @@ maintainer-dispatched trigger only; a fail-closed gate that verifies the released commit is protected main's untampered current tip and that the requested version matches `pyproject.toml`; a fresh full test-suite run; and -an immutable, never-reused git tag backing a real GitHub Release. +an immutable, never-reused git tag backing a real GitHub Release. The +workflow is two jobs (`verify`, read-only and credential-less; `publish`, +write-scoped) -- see `tests/test_release_workflow_idempotency_contract.py` +for the deeper resume/reject and least-privilege invariants that split +motivates. Uses plain text assertions rather than a YAML parser, matching this repository's existing workflow-contract convention (e.g. `tests/test_nim_benchmark_workflow_contract.py`) and the Ponytail gate: no new dependency (PyYAML is not otherwise used anywhere in this repository) -when substring/index assertions already prove the same structure. +when substring/index assertions already prove the same structure. Where a +finding calls for more than "does this text appear anywhere," helpers below +bound the search to one job's own step block so the assertion proves real +step order and job scoping rather than incidental proximity. """ from __future__ import annotations @@ -24,12 +31,42 @@ REPOSITORY_ROOT = Path(__file__).resolve().parents[1] _WORKFLOW_PATH = REPOSITORY_ROOT / ".github/workflows/release.yml" +_JOB_NAMES = ("verify", "publish") + def _workflow_text() -> str: """Return the release workflow's raw YAML text.""" return _WORKFLOW_PATH.read_text(encoding="utf-8") +def _job_block(workflow: str, job_name: str) -> str: + """Return one top-level job's own YAML text, steps and all. + + Bounded from the job's ` :` line to the next known sibling + job at the same two-space indent, or end of file for the last job. + Keeping this to the two real job names (rather than a generic regex) + avoids ever silently matching a step name that happens to look like a + job key. + """ + start = workflow.index(f"\n {job_name}:\n") + later_siblings = [ + workflow.index(f"\n {sibling}:\n") + for sibling in _JOB_NAMES + if sibling != job_name and workflow.index(f"\n {sibling}:\n") > start + ] + end = min(later_siblings) if later_siblings else len(workflow) + return workflow[start:end] + + +def _step_names(block: str) -> list[str]: + """Return a job block's `- name: ...` step names in file order.""" + return [ + line.split("- name:", 1)[1].strip() + for line in block.splitlines() + if line.strip().startswith("- name:") + ] + + def test_release_workflow_file_exists() -> None: """A first canonical release mechanism must actually be present on disk.""" assert _WORKFLOW_PATH.exists() @@ -91,11 +128,17 @@ def test_gate_verifies_requested_version_matches_pyproject_toml() -> None: assert "pyproject.toml" in workflow -def test_gate_refuses_to_republish_or_move_an_existing_tag() -> None: - """An already-existing tag must block the run instead of being overwritten.""" +def test_gate_determines_tag_resume_state_via_the_commits_api() -> None: + """A tag's existence is resolved through the commit-dereferencing API. + + Superseded by the idempotent resume/reject design (Devin finding 3): see + `tests/test_release_workflow_idempotency_contract.py` for the full + resume-vs-reject branching contract. + """ workflow = _workflow_text() - assert 'git rev-parse "refs/tags/v${RELEASE_VERSION}"' in workflow - assert "repos/${GITHUB_REPOSITORY}/git/ref/tags/v${RELEASE_VERSION}" in workflow + verify_block = _job_block(workflow, "verify") + assert 'repos/${GITHUB_REPOSITORY}/commits/v${RELEASE_VERSION}' in verify_block + assert "tag_resume" in verify_block def test_gate_reruns_the_full_test_suite_fresh_before_tagging() -> None: @@ -132,13 +175,29 @@ def test_release_notes_file_backs_the_published_release_body() -> None: def test_sbom_asset_attachment_is_best_effort_and_never_blocks_the_release() -> None: - """A missing SBOM artifact must warn, not fail, the release.""" + """A missing SBOM artifact, or a failed lookup, must warn, never abort. + + Bounded tightly to just the SBOM step's own body (the next step's + heading is the boundary), and asserts the *mechanism* of non-fatality -- + each fallible `gh` call is guarded by an explicit `if !`, and the step + does not opt into `set -e` (which would abort the whole step on the + first failing command, including the permission-sensitive `gh run + list`) -- rather than only checking that a warning string appears + somewhere loosely nearby (Devin finding 4). + """ workflow = _workflow_text() - sbom_step_index = workflow.index("Fetch the CycloneDX SBOM") - publish_index = workflow.index("Publish the GitHub Release") - sbom_block = workflow[sbom_step_index:publish_index] - assert "::warning::" in sbom_block - assert "gh run download" in sbom_block + verify_block = _job_block(workflow, "verify") + sbom_step_index = verify_block.index("Fetch the CycloneDX SBOM") + next_step_index = verify_block.index("Upload rendered notes and SBOM") + sbom_block = verify_block[sbom_step_index:next_step_index] + + assert "set -euo pipefail" not in sbom_block, ( + "the SBOM step must not abort-on-error via -e; every fallible " + "command needs its own explicit failure handling instead" + ) + assert "if ! run_id=" in sbom_block + assert "if ! gh run download" in sbom_block + assert sbom_block.count("::warning::") >= 2 def test_concurrency_group_serializes_release_runs() -> None: @@ -151,6 +210,44 @@ def test_concurrency_group_serializes_release_runs() -> None: assert "cancel-in-progress: false" in concurrency_block +def test_verify_job_has_read_only_permissions_and_no_persisted_credentials() -> None: + """`verify` runs repository-controlled code (tests, note rendering) with + no write token present and no persisted git credential (CodeRabbit + CWE-269 hardening: least-privilege split, see the idempotency contract + test file for the matching `publish`-side assertions).""" + workflow = _workflow_text() + verify_block = _job_block(workflow, "verify") + assert "contents: read" in verify_block + assert "actions: read" in verify_block + assert "contents: write" not in verify_block + assert "persist-credentials: false" in verify_block + + +def test_final_main_tip_check_happens_after_testing_and_note_rendering_but_before_tagging() -> None: + """Devin finding 1: a second, authoritative main-tip check must run + after every potentially long-running gate (the fresh test suite and + note rendering) and before the tag is created -- not only at the start.""" + workflow = _workflow_text() + test_run_index = workflow.index( + "uv run --locked --extra api --extra db --extra queue --group dev python -m pytest -q" + ) + notes_render_index = workflow.index("Render release notes from CHANGELOG.md") + final_tip_check_index = workflow.index( + "Re-verify protected main has not advanced since verification started" + ) + tag_step_index = workflow.index("Create the annotated release tag") + + assert test_run_index < notes_render_index < final_tip_check_index < tag_step_index + + # The first (fast-fail) tip check still exists, distinct from this one. + first_tip_check_index = workflow.index("current, untampered tip") + assert first_tip_check_index < test_run_index < final_tip_check_index + + final_tip_check_block = workflow[final_tip_check_index:tag_step_index] + assert 'repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha' in final_tip_check_block + assert 'if [ "${remote_head}" != "${GITHUB_SHA}" ]' in final_tip_check_block + + def test_pinned_actions_use_full_commit_shas() -> None: """Every third-party action reference stays pinned per Scorecard convention.""" workflow = _workflow_text() diff --git a/tests/test_release_workflow_idempotency_contract.py b/tests/test_release_workflow_idempotency_contract.py new file mode 100644 index 000000000..7f0f3a6bf --- /dev/null +++ b/tests/test_release_workflow_idempotency_contract.py @@ -0,0 +1,225 @@ +"""Idempotent-retry and least-privilege contract for the release workflow. + +Covers the design that resolves three related Devin/CodeRabbit findings on +`.github/workflows/release.yml`, kept in one file because they share a root +cause -- the tag is pushed before the fallible SBOM/GitHub-Release steps, +so any failure after that push must be safely retryable without ever moving +the tag or double-publishing: + +- The tag/release resume-vs-reject branching: an already-existing tag that + points at this exact commit with no published GitHub Release yet is a + safe resume; a tag pointing at any other commit, or a tag whose release + already exists, is rejected rather than silently accepted or overwritten. +- `actions: read` is granted at the `verify` job's scope (needed for the + best-effort SBOM lookup) and nowhere else. +- The two-job least-privilege split: `verify` (read-only, no persisted git + credential) executes all repository-controlled code -- the fresh test + suite and note rendering -- before `publish` (the only job holding + `contents: write`) ever runs, and `publish`'s first action is an + authoritative main-tip re-check immediately before it creates anything. + +Uses plain text/index assertions on the same raw YAML text convention as +`tests/test_release_workflow_contract.py` (see that file's docstring for the +Ponytail no-new-YAML-dependency rationale) -- but bounded to one job's own +step block via `_job_block`, and to one step's own branch via explicit +ordering assertions, so these prove real operational structure rather than +loose text proximity. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +_WORKFLOW_PATH = REPOSITORY_ROOT / ".github/workflows/release.yml" + +_JOB_NAMES = ("verify", "publish") + + +def _workflow_text() -> str: + """Return the release workflow's raw YAML text.""" + return _WORKFLOW_PATH.read_text(encoding="utf-8") + + +def _job_block(workflow: str, job_name: str) -> str: + """Return one top-level job's own YAML text, steps and all. + + Bounded from the job's ` :` line to the next known sibling + job at the same two-space indent, or end of file for the last job. + """ + start = workflow.index(f"\n {job_name}:\n") + later_siblings = [ + workflow.index(f"\n {sibling}:\n") + for sibling in _JOB_NAMES + if sibling != job_name and workflow.index(f"\n {sibling}:\n") > start + ] + end = min(later_siblings) if later_siblings else len(workflow) + return workflow[start:end] + + +def _tag_state_step(workflow: str) -> str: + """Return just the `verify` job's tag-resume-determination step body.""" + verify_block = _job_block(workflow, "verify") + step_start = verify_block.index( + "Determine whether the release tag is a fresh publish or a safe resume" + ) + next_step_start = verify_block.index( + "Run the full required test suite fresh on this exact commit" + ) + return verify_block[step_start:next_step_start] + + +# --- Tag resume-vs-reject branching (Devin finding 3) ----------------------- + + +def test_tag_state_step_exists_with_a_stable_output() -> None: + """The resume decision is exposed as a step output the `publish` job + can gate on, not just an internal variable.""" + workflow = _workflow_text() + verify_block = _job_block(workflow, "verify") + assert 'id: tag_state' in verify_block + assert 'echo "tag_resume=false" >> "${GITHUB_OUTPUT}"' in verify_block + assert 'echo "tag_resume=true" >> "${GITHUB_OUTPUT}"' in verify_block + assert "outputs:" in verify_block + assert "tag_resume: ${{ steps.tag_state.outputs.tag_resume }}" in verify_block + + +def test_absent_tag_is_a_fresh_publish_checked_before_any_reject_branch() -> None: + """No tag at all must short-circuit straight to `tag_resume=false`, + before the points-elsewhere/already-published reject logic ever runs + (that logic requires a tag to exist, so it must come after).""" + step = _tag_state_step(_workflow_text()) + fresh_index = step.index('echo "tag_resume=false"') + points_elsewhere_index = step.index("a release tag is never moved onto a different commit") + already_published_index = step.index("there is nothing left to resume") + resume_index = step.index('echo "tag_resume=true"') + assert fresh_index < points_elsewhere_index < already_published_index < resume_index + + +def test_tag_pointing_at_a_different_commit_is_rejected_not_moved() -> None: + """A tag that exists but targets a different commit must fail the run + outright -- a release tag is never moved onto a new commit.""" + step = _tag_state_step(_workflow_text()) + assert '${tag_commit}" != "${GITHUB_SHA}' in step + mismatch_index = step.index('${tag_commit}" != "${GITHUB_SHA}') + # The very next non-blank statement after the mismatch check must exit + # nonzero -- reject, don't silently continue past a moved tag. + following = step[mismatch_index : mismatch_index + 400] + assert "::error::" in following + assert "exit 1" in following + + +def test_tag_matching_commit_with_existing_release_is_rejected_as_nothing_to_resume() -> None: + """A tag at the right commit whose GitHub Release already exists must + also reject -- retries are for incomplete publications, not to + re-publish a release that already exists.""" + step = _tag_state_step(_workflow_text()) + assert 'gh release view "v${RELEASE_VERSION}"' in step + release_view_index = step.index('gh release view "v${RELEASE_VERSION}"') + following = step[release_view_index : release_view_index + 400] + assert "::error::" in following + assert "exit 1" in following + assert "already has a published GitHub Release" in following + + +def test_tag_matching_commit_with_no_release_yet_resumes_without_retagging() -> None: + """Only the narrow safe case -- same commit, no release published yet -- + sets `tag_resume=true`, and this must be the last branch reached (after + both reject checks have already passed).""" + step = _tag_state_step(_workflow_text()) + resume_notice_index = step.index("resuming publication instead of re-tagging") + resume_output_index = step.index('echo "tag_resume=true"') + release_view_index = step.index('gh release view "v${RELEASE_VERSION}"') + assert release_view_index < resume_notice_index < resume_output_index + + +def test_publish_job_skips_tag_creation_only_when_resuming() -> None: + """`publish` must gate the actual `git tag`/`git push` step on the + resume flag computed by `verify` -- not recompute or ignore it.""" + workflow = _workflow_text() + publish_block = _job_block(workflow, "publish") + assert "TAG_RESUME: ${{ needs.verify.outputs.tag_resume }}" in publish_block + tag_step_index = publish_block.index("Create the annotated release tag") + following_line_end = publish_block.index("\n", tag_step_index) + if_line = publish_block[tag_step_index:publish_block.index("\n", following_line_end + 1)] + assert "if: env.TAG_RESUME != 'true'" in if_line + + +# --- `actions: read` scoping (Devin finding 2) ------------------------------- + + +def test_actions_read_is_granted_only_on_the_verify_job() -> None: + """`actions: read` (needed for `gh run list`/`gh run download`) belongs + on `verify`, which performs the SBOM lookup, and nowhere else -- the + write-scoped `publish` job never needs Actions API access.""" + workflow = _workflow_text() + verify_block = _job_block(workflow, "verify") + publish_block = _job_block(workflow, "publish") + assert "actions: read" in verify_block + assert "actions: read" not in publish_block + + +def test_sbom_step_is_the_one_step_needing_actions_read() -> None: + """The permission and the step that consumes it live in the same job.""" + workflow = _workflow_text() + verify_block = _job_block(workflow, "verify") + assert "gh run list" in verify_block + assert "gh run download" in verify_block + + +# --- Two-job least-privilege split (CodeRabbit CWE-269 hardening) ----------- + + +def test_publish_job_depends_on_verify_completing_first() -> None: + """`publish` must not run until `verify` (which runs the fresh test + suite and renders notes) has fully succeeded.""" + workflow = _workflow_text() + publish_block = _job_block(workflow, "publish") + assert "needs: verify" in publish_block + + +def test_only_publish_job_holds_write_permission_and_persisted_credentials() -> None: + """The write-scoped token and a persisted git credential exist only in + `publish`; `verify` -- which runs repository-controlled test code -- + never holds either.""" + workflow = _workflow_text() + verify_block = _job_block(workflow, "verify") + publish_block = _job_block(workflow, "publish") + assert "contents: write" in publish_block + assert "contents: write" not in verify_block + assert "persist-credentials: true" in publish_block + assert "persist-credentials: false" in verify_block + + +def test_publish_jobs_first_step_is_checkout_and_second_is_the_final_tip_check() -> None: + """The re-verification must be the very first thing `publish` does + after checking out -- nothing fallible runs before it.""" + workflow = _workflow_text() + publish_block = _job_block(workflow, "publish") + step_names = [ + line.split("- name:", 1)[1].strip() + for line in publish_block.splitlines() + if line.strip().startswith("- name:") + ] + assert step_names[0] == "Checkout protected main" + assert step_names[1] == "Re-verify protected main has not advanced since verification started" + + +def test_release_artifacts_flow_from_verify_to_publish_via_upload_download() -> None: + """Notes/SBOM produced in the credential-less `verify` job reach + `publish` through an artifact hand-off, not a shared filesystem or a + second independent render (which could drift from what was tested).""" + workflow = _workflow_text() + verify_block = _job_block(workflow, "verify") + publish_block = _job_block(workflow, "publish") + assert "actions/upload-artifact@" in verify_block + assert "name: release-publish-inputs" in verify_block + assert "actions/download-artifact@" in publish_block + assert "name: release-publish-inputs" in publish_block + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(pytest.main([__file__])) From 00fe2949073ba1840453231d8f06aa2cd252684d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 19:58:42 +0000 Subject: [PATCH 13/35] fix(release-notes): bound version parsing to [project] table Addresses two CodeRabbit findings on PR #1030: - Real bug: `read_declared_version`'s regex matched the first `version = "..."` line anywhere in pyproject.toml, so a same-named `version` key under an unrelated table declared earlier (e.g. `[tool.some_tool]`) would be read instead of `[project]`'s real version -- both by this script's own CLI and by release.yml's version-match gate (which now calls this same tested function instead of duplicating the regex, so both are fixed by one change). Bounds the search to the span between the `[project]` header and the next `[...]` table header. Added regression tests for a same-named key both before and after `[project]`'s body, plus a missing-`[project]`-table case. - Minor test bug: `pytest.raises(ValueError, match="0.9.9")` left the dots as unescaped regex metacharacters (RUF043); escaped to `match=r"0\.9\.9"`. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4 --- scripts/ci/release_notes.py | 27 +++++++++++++++++++------ tests/test_release_notes.py | 40 ++++++++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/scripts/ci/release_notes.py b/scripts/ci/release_notes.py index fc107ad8b..5c8636ff9 100644 --- a/scripts/ci/release_notes.py +++ b/scripts/ci/release_notes.py @@ -15,20 +15,35 @@ import sys from pathlib import Path +_PROJECT_TABLE_HEADER_PATTERN = re.compile(r"(?m)^\[project\]\s*$") +_TABLE_HEADER_PATTERN = re.compile(r"(?m)^\[") _VERSION_FIELD_PATTERN = re.compile(r'(?m)^version\s*=\s*"([^"]+)"\s*$') _HEADING_PATTERN = re.compile(r"(?m)^## \[(?P[^\]]+)\][^\n]*$") def read_declared_version(pyproject_text: str) -> str: - """Return the `[project]` `version` declared in *pyproject_text*. - - Raises ``ValueError`` when no `version = "..."` line is present, rather - than guessing a version from a tag, a changelog heading, or any other + """Return the `[project]` table's `version` declared in *pyproject_text*. + + Only a `version = "..."` line that appears within the `[project]` + table's own body is honored -- the search is bounded to the span between + the `[project]` header and the next `[...]` table header (or end of + file), so a same-named `version` key under an unrelated table (e.g. + `[tool.some_tool]`) declared earlier in the file is never mistaken for + the project's real version. Raises ``ValueError`` when there is no + `[project]` table, or it has no `version = "..."` line, rather than + guessing a version from a tag, a changelog heading, or any other inferred source. """ - match = _VERSION_FIELD_PATTERN.search(pyproject_text) + header_match = _PROJECT_TABLE_HEADER_PATTERN.search(pyproject_text) + if header_match is None: + raise ValueError("pyproject.toml has no [project] table") + body_start = header_match.end() + next_header_match = _TABLE_HEADER_PATTERN.search(pyproject_text, body_start) + body_end = next_header_match.start() if next_header_match else len(pyproject_text) + project_body = pyproject_text[body_start:body_end] + match = _VERSION_FIELD_PATTERN.search(project_body) if match is None: - raise ValueError("pyproject.toml has no version field") + raise ValueError("pyproject.toml [project] table has no version field") return match.group(1) diff --git a/tests/test_release_notes.py b/tests/test_release_notes.py index 2fe867ed1..7ce906631 100644 --- a/tests/test_release_notes.py +++ b/tests/test_release_notes.py @@ -57,6 +57,44 @@ def test_read_declared_version_rejects_a_missing_version_field() -> None: module.read_declared_version("[project]\nname = \"x\"\n") +def test_read_declared_version_rejects_a_missing_project_table() -> None: + """A pyproject with no `[project]` table at all must fail closed too.""" + module = _module() + with pytest.raises(ValueError, match=r"\[project\]"): + module.read_declared_version('[tool.other]\nversion = "9.9.9"\n') + + +def test_read_declared_version_ignores_a_same_named_key_in_an_earlier_table() -> None: + """A `version` key under an unrelated table must never be mistaken for + `[project]`'s real version, even when it textually precedes it.""" + module = _module() + pyproject = ( + '[tool.some_tool]\n' + 'version = "9.9.9"\n' + '\n' + '[project]\n' + 'name = "contextual-orchestrator"\n' + 'version = "0.2.0"\n' + 'description = "x"\n' + ) + assert module.read_declared_version(pyproject) == "0.2.0" + + +def test_read_declared_version_ignores_a_same_named_key_in_a_later_table() -> None: + """A `version` key in a table declared after `[project]` must not leak in + either, once the `[project]` table's own body has ended.""" + module = _module() + pyproject = ( + '[project]\n' + 'name = "x"\n' + 'version = "0.2.0"\n' + '\n' + '[tool.other]\n' + 'version = "7.7.7"\n' + ) + assert module.read_declared_version(pyproject) == "0.2.0" + + def test_extract_changelog_section_returns_only_the_matching_version_body() -> None: """Only the body between the matching heading and the next heading is returned.""" module = _module() @@ -78,7 +116,7 @@ def test_extract_changelog_section_matches_a_dated_heading_too() -> None: def test_extract_changelog_section_rejects_a_missing_version() -> None: """A version with no CHANGELOG section must fail closed, not publish empty notes.""" module = _module() - with pytest.raises(ValueError, match="0.9.9"): + with pytest.raises(ValueError, match=r"0\.9\.9"): module.extract_changelog_section(_CHANGELOG, "0.9.9") From a869042bbb5b7759ecd9a5ce8fb4eae788eb44cc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 19:58:51 +0000 Subject: [PATCH 14/35] docs(release): fix /releases/latest mutability claim + add ADR grounding Addresses Devin finding 5 and CodeRabbit's documentation-accuracy finding on PR #1030: - `/releases/latest` is a mutable alias that repoints to the newest release, not itself an immutable pin -- only `/releases/tag/vX.Y.Z` is. Both `docs/RELEASING.md` and ADR 0129's Consequences section previously described them as equally immutable pin targets, which undermines this PR's whole stated purpose if left wrong. Fixed in both places, and `docs/RELEASING.md`'s "Cutting a release" walkthrough and preconditions are updated to describe the two-job, idempotent-resume behavior from the companion workflow fix. - ADR 0129 gained a "Research grounding" section: this is release- engineering/DevOps process tooling, not a novel algorithm, so it cites the normative standards it implements -- SemVer 2.0.0, Keep a Changelog 1.1.0, and the GitHub Releases API -- instead of an academic literature review, per this org's research-grounding convention. CHANGELOG.md's `## [0.2.0] - Unreleased` entry and docs/product-technical-gap-baseline.md's narrative are updated to describe the mechanism's actual, fixed behavior (still unreleased, so this amends the existing entries rather than adding a separate "Fixed" bullet). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4 --- CHANGELOG.md | 26 ++++--- docs/RELEASING.md | 67 +++++++++++++------ .../adrs/0129-canonical-immutable-release.md | 34 +++++++++- docs/product-technical-gap-baseline.md | 41 ++++++++++++ 4 files changed, 136 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 579ef09a8..2c62942ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,16 +14,26 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) - A canonical, immutable release mechanism: `.github/workflows/release.yml` (`workflow_dispatch` only, explicit `version` input, never triggered by - push/schedule/merge) verifies the dispatched commit is protected `main`'s - untampered current tip, verifies the requested version matches - `pyproject.toml`, refuses to re-publish or move an existing tag, re-runs - the full test suite fresh, renders release notes from this file's matching - `## [X.Y.Z]` section via the new tested `scripts/ci/release_notes.py`, then - creates an annotated `vX.Y.Z` tag and a GitHub Release (best-effort - CycloneDX SBOM asset attached when available). Gives downstream consumers + push/schedule/merge), split into a read-only, credential-less `verify` job + and a write-scoped `publish` job for least privilege. `verify` checks the + dispatched commit is protected `main`'s untampered current tip, checks the + requested version against `pyproject.toml`'s `[project]` table (table- + boundary aware, so a same-named `version` key in an unrelated table can + never be mistaken for it), resolves any existing `vX.Y.Z` tag via the + GitHub commits API (rejecting one that points at a different commit or + whose Release already exists, but permitting a safe idempotent resume when + it matches this commit with no Release published yet), re-runs the full + test suite fresh, renders release notes from this file's matching + `## [X.Y.Z]` section via the tested `scripts/ci/release_notes.py`, and + best-effort looks up a CycloneDX SBOM (a missing SBOM or failed lookup + warns, never blocks). `publish` re-verifies `main`'s tip has not advanced + since `verify` started testing — immediately before it creates anything — + then creates the annotated `vX.Y.Z` tag (skipped on a resumed run) and the + GitHub Release. Gives downstream consumers (`ContextualWisdomLab/keyverse#132`, `bandscope#881`, and the Wardnet consumer-owner handoff, all recorded on `contextual-orchestrator#971`) an - immutable pin target instead of a vendored source SHA. See + immutable pin target (`.../releases/tag/vX.Y.Z` — not the mutable + `.../releases/latest` alias) instead of a vendored source SHA. See `docs/planning/adrs/0129-canonical-immutable-release.md` and `docs/RELEASING.md`. No release has been cut yet — landing this mechanism and dispatching the first `v0.2.0` release are deliberately separate diff --git a/docs/RELEASING.md b/docs/RELEASING.md index bea3e323b..c6ee4adfc 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -9,8 +9,16 @@ for the full design and its explicit non-goals. A release is a git tag `vX.Y.Z` and a GitHub Release built from it. It gives downstream consumers (Keyverse, BandScope, Wardnet, and others) an immutable, -citable pin target — `.../releases/latest` and `.../releases/tag/vX.Y.Z` — so -they never again need to vendor a mutable source SHA off `main`. +citable pin target they never again need to vendor a mutable source SHA off +`main` for. Those are two different URLs with two different guarantees: + +- `.../releases/tag/vX.Y.Z` is the actual immutable pin — always the same + commit, forever. **Consumers should pin this one.** +- `.../releases/latest` is a **mutable discovery alias** that repoints to + whatever the newest release is; it is useful for finding "what's current" + in a human workflow, but a consumer pinning to it is *not* protected from + behavior changes across future releases and should not use it as a pin + target. A release is **not** the same thing as [`/api/v1/commercial_release_candidates/latest`](commercial_release_candidate.md) @@ -28,10 +36,12 @@ that separate system. through the normal PR process (review, required checks, no exceptions). 2. `CHANGELOG.md` has a `## [X.Y.Z]` section (an `- Unreleased` or dated suffix is fine) with real, non-empty content describing what changed. -3. No git tag `vX.Y.Z` already exists (`git tag -l | grep vX.Y.Z` locally, or - check ). - A tag is never reused or moved onto a different commit — bump the version - again if you need to re-release. +3. Either no git tag `vX.Y.Z` exists yet, or one does but points at the exact + commit you're dispatching and has no GitHub Release published yet (a safe + resume of a run that failed after pushing the tag but before publishing — + see step 4 below). A tag pointing at any *other* commit, or one whose + release already exists, is rejected: a tag is never reused or moved onto a + different commit — bump the version again if you need to re-release. 4. `main` is currently green — its own required checks (Tests, Security, Fuzz, and the org-central Strix/OpenCode/security-scan/OSV/Scorecard checks from `ContextualWisdomLab/.github`) are passing. The release @@ -47,21 +57,36 @@ that separate system. 2. Select branch `main` (the workflow refuses to run against anything else). 3. Enter the exact version, e.g. `0.2.0` — no leading `v`, must match `pyproject.toml` byte-for-byte. -4. Dispatch. The workflow, in order: - - fails closed if the dispatched commit is not `main`'s current tip (a - race with a concurrent merge); - - fails closed if the input version does not match `pyproject.toml`; - - fails closed if the tag already exists, locally or on `origin`; - - runs the full test suite fresh (`uv run --locked --extra api --extra db - --extra queue --group dev python -m pytest -q`); - - renders release notes from `CHANGELOG.md`'s matching section - (`scripts/ci/release_notes.py`, tested in - `tests/test_release_notes.py`); - - creates and pushes an annotated tag `vX.Y.Z`; - - best-effort attaches the CycloneDX SBOM from the matching successful - `security.yml` run for this commit, if one exists (a missing SBOM warns, - it never blocks the release); - - publishes the GitHub Release. +4. Dispatch. The workflow is two jobs, least-privilege: `verify` runs with no + write permission and no persisted git credential while it executes any + repository-controlled code; `publish` holds the write token and does + nothing but tag and publish. In order: + - **`verify`** (read-only): + - fails closed if the dispatched commit is not `main`'s current tip (a + race with a concurrent merge); + - fails closed if the input version does not match `pyproject.toml`'s + `[project]` table; + - resolves any existing `vX.Y.Z` tag via the commit API: fails closed if + it points at a different commit or its GitHub Release already exists; + otherwise proceeds (fresh publish, or a safe resume — see step 3 + above); + - runs the full test suite fresh (`uv run --locked --extra api --extra + db --extra queue --group dev python -m pytest -q`); + - renders release notes from `CHANGELOG.md`'s matching section + (`scripts/ci/release_notes.py`, tested in + `tests/test_release_notes.py`); + - best-effort looks up and downloads the CycloneDX SBOM from the + matching successful `security.yml` run for this commit, if one exists + (a missing SBOM, or a failed lookup, warns — it never blocks the + release); + - uploads the rendered notes and any SBOM for `publish` to pick up. + - **`publish`** (write-scoped, only after `verify` succeeds): + - re-verifies `main`'s tip has not advanced while `verify` was testing + and rendering notes (a second, authoritative check right before + anything is created); + - creates and pushes an annotated tag `vX.Y.Z` — skipped when resuming + a run whose tag already exists at this commit; + - publishes the GitHub Release using the notes/SBOM `verify` produced. 5. Confirm at . diff --git a/docs/planning/adrs/0129-canonical-immutable-release.md b/docs/planning/adrs/0129-canonical-immutable-release.md index 6a99ef0a6..fc03ece57 100644 --- a/docs/planning/adrs/0129-canonical-immutable-release.md +++ b/docs/planning/adrs/0129-canonical-immutable-release.md @@ -242,12 +242,40 @@ add release-specific assurance. fresh regression run (step 5), both of which are simpler and do not need the `administration: read` permission the rulesets endpoint requires. +## Research grounding + +This repository's convention (`CLAUDE.md`, `AGENTS.md`) asks substantive +feature/process PRs to attach relevant grounding. This change is +release-engineering/DevOps process tooling — how an existing, unreleased +version number gets tagged and published — not a novel algorithm or a +research contribution, so the correct grounding is the normative standards +this mechanism implements, not an academic literature review: + +- Semantic Versioning 2.0.0 () — already + the versioning scheme this repository's `CHANGELOG.md` preamble commits + to; this ADR's gate (`version` must equal `pyproject.toml` exactly, a + version is never redefined once tagged) is what makes that commitment + real instead of aspirational prose. +- Keep a Changelog 1.1.0 () — also + already `CHANGELOG.md`'s stated format; `scripts/ci/release_notes.py` + extracts the `## [X.Y.Z]` section this format defines and renders it + verbatim as the GitHub Release body, rather than inventing a parallel + notes format. +- The GitHub Releases API () — the + mechanism's actual implementation surface: an annotated tag plus a Release + object created through `gh release create`, consumed by downstream + clients via `.../releases/tag/vX.Y.Z` (the immutable pin) and + `.../releases/latest` (a mutable discovery alias, not a pin — see + `docs/RELEASING.md`). + ## Consequences - Consumers gain a real, immutable pin target: - `github.com/ContextualWisdomLab/contextual-orchestrator/releases/latest` - and `.../releases/tag/v0.2.0`, satisfying the owner's stated acceptance - criterion without any paid or provider-specific dependency. + `github.com/ContextualWisdomLab/contextual-orchestrator/releases/tag/v0.2.0`, + satisfying the owner's stated acceptance criterion without any paid or + provider-specific dependency. `.../releases/latest` also starts resolving + (no more 404) once the first tag is cut, but it is a mutable discovery + alias, not itself a pin target — see `docs/RELEASING.md`. - Releasing stays deliberate and rare (manual `workflow_dispatch`), matching `CHANGELOG.md`'s existing stated process instead of introducing a new, undocumented cadence. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 2a6548662..bc67cf6d4 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -119,6 +119,47 @@ protection already answered. Full reasoning: - `python -m interrogate -c pyproject.toml .` → `PASSED (minimum: 100.0%, actual: 100.0%)` — this repository's repo-wide docstring gate, unaffected by the new `scripts/ci/release_notes.py`. + +### Review-driven hardening (same PR, before merge) + +Devin Review and CodeRabbit found nine issues (five and four respectively, +three overlapping) against the initial cut above, all fixed on the same +branch before merge: + +- **Concurrent-merge staleness**: added a second, authoritative main-tip + check immediately before tag creation (after the fresh test run and note + rendering), alongside the original fast-fail early check. +- **least privilege**: split `release.yml` into a read-only, + credential-less `verify` job (tests, note rendering, SBOM lookup — + `actions: read` lives here, scoped to just this job) and a write-scoped + `publish` job (tag + GitHub Release only), so repository-controlled test + code never runs alongside a write-scoped token. +- **Idempotent retry**: an existing `vX.Y.Z` tag is now resolved via the + commits API into resume (same commit, unpublished Release — skip + re-tagging) vs. reject (different commit, or an already-published + Release), replacing the old any-existing-tag hard fail that stranded a + half-published release on any post-tag failure. +- **SBOM lookup genuinely non-fatal**: `gh run list`/`gh run download` + failures are now each guarded by an explicit `if !`, instead of a bare + `set -e` that aborted the whole job on the `actions: read` permission gap. +- **TOML table-boundary bug**: `read_declared_version` (and the workflow's + version-match step, via the same tested function) now bounds its search + to the `[project]` table's own body, so a same-named `version` key under + an earlier unrelated table can never be mistaken for the real one. +- **`/releases/latest` mutability**: `docs/RELEASING.md` and this ADR's + Consequences section now correctly describe `/releases/tag/vX.Y.Z` as the + immutable pin and `/releases/latest` as a mutable discovery alias only. +- **Research grounding**: ADR 0129 gained a section citing SemVer 2.0.0, + Keep a Changelog 1.1.0, and the GitHub Releases API — the normative + standards this process tooling implements, not an academic literature + review. +- Test suite grew to `tests/test_release_notes.py` (13 tests), + `tests/test_release_workflow_contract.py` (17 tests), and a new + `tests/test_release_workflow_idempotency_contract.py` (12 tests) asserting + real step order and job-scoped permissions per job block, not just + substring presence. `python -m pytest tests -q` → 3390 passed (plus 3 + pre-existing, unrelated failures confirmed present on the unmodified + branch too); `python -m interrogate -c pyproject.toml .` → 100.0%. - Full `python -m pytest tests -q` run for regression-freedom before landing the PR (see the PR body for the exact pass count from this run). From cf69dc39457829c351277aad8096c24115d3991c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 20:17:43 +0000 Subject: [PATCH 15/35] fix(release): checks-green gate, resumable asset attach, document residual race Three Devin follow-up findings on this PR's release workflow: 1. "Unchecked main checks permit releases" -- the release gate only compared GITHUB_SHA to main's current tip; it never verified that push-triggered workflows (Security, Fuzz, ...) re-running on that new tip commit had actually finished green. Adds a check-runs verification step (querying commits/$SHA/check-runs, excluding this release run's own checks via GITHUB_RUN_ID) to both verify (fail fast) and publish (authoritative recheck immediately after the final main-tip check). Deliberately checks every reported check rather than a ruleset-derived "required" subset, per ADR 0129's existing rationale for avoiding administration:read. 2. "Failed asset upload strands releases" -- `gh release create` can publish the Release object and then fail partway through uploading the SBOM asset, but the existing resume logic treated "Release already exists" as nothing left to do and rejected the retry outright. Splits the previous single tag_resume output into tag_resume and release_resume, splits "Publish the GitHub Release" into a conditionally-skipped "Create the GitHub Release" step and an always-run best-effort "Attach any still-missing release assets" step so a retry can complete a stranded release's asset upload. 3. "Final tip check leaves race" (the original TOCTOU finding this PR's final main-tip recheck already addressed) -- assessed the residual check-then-act window after that recheck and concluded it is an inherent limitation (GitHub exposes no atomic check-then-tag primitive) that is already close to minimal for a workflow_dispatch-only, maintainer-triggered path. Documents it as an accepted, bounded residual risk in ADR 0129's new "Known limitations" section and docs/RELEASING.md, including the recovery guidance (cut a new release from the real tip; never retag) if it is ever actually hit, rather than adding disproportionate complexity chasing it to zero. Also fixed a real jq context bug found while testing the new check-runs filter by hand (index(.conclusion) inside a piped array literal indexed the array, not the check-run object). tests/test_release_workflow_contract.py and tests/test_release_workflow_idempotency_contract.py updated/extended to match; ADR/RELEASING.md/CHANGELOG.md prose kept in sync with the new resume and checks-gate behavior. Verified: tests/test_release_notes.py, tests/test_release_workflow_contract.py, tests/test_release_workflow_idempotency_contract.py, tests/test_planning_adr_identifiers.py, tests/test_product_planning_contract.py (53 passed); manual bash+jq simulation of both new gate/attach steps against a stubbed gh CLI covering all-green/pending/failed/empty/multi-page and missing/new/already-attached/upload-failed cases; python -m interrogate -c pyproject.toml . -> 100.0%; release.yml parses as valid YAML. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4 --- .github/workflows/release.yml | 103 +++++++++++-- CHANGELOG.md | 29 ++-- docs/RELEASING.md | 71 +++++++-- .../adrs/0129-canonical-immutable-release.md | 89 ++++++++++- tests/test_release_workflow_contract.py | 54 +++++++ ...t_release_workflow_idempotency_contract.py | 139 ++++++++++++++---- 6 files changed, 407 insertions(+), 78 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eeaf834fd..711be638c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,8 +13,13 @@ name: Release # persisted git credentials while it executes repository-controlled code # (the test suite, release-notes rendering) -- a `contents: write` token is # never present alongside that code. `publish` holds the write token, and its -# very first step re-checks protected main's tip immediately before tag -# creation, right after `verify`'s potentially long-running gate finishes. +# very first steps re-check protected main's tip and every check reported +# for that exact commit, immediately before tag creation, right after +# `verify`'s potentially long-running gate finishes. That tip/checks +# recheck is still a check-then-act window, not an atomic guarantee -- see +# docs/planning/adrs/0129-canonical-immutable-release.md's "Known +# limitations" section and docs/RELEASING.md for the accepted residual risk +# and the recovery path if it is ever actually hit. on: workflow_dispatch: @@ -39,11 +44,13 @@ jobs: permissions: contents: read actions: read + checks: read env: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ inputs.version }} outputs: tag_resume: ${{ steps.tag_state.outputs.tag_resume }} + release_resume: ${{ steps.tag_state.outputs.release_resume }} steps: - name: Checkout protected main (no write credentials) uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 @@ -61,6 +68,33 @@ jobs: exit 1 fi + - name: Verify every check reported for this commit is complete and green + run: | + set -euo pipefail + checks_pages="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/check-runs?per_page=100" --paginate --slurp)" + # main's own required-check gate already had to pass on the *PR's* + # head SHA before this squash/merge commit could exist -- but + # separate push-triggered workflows (Security, Fuzz, ...) run + # again against this exact new main-tip SHA and can still be + # in flight or have failed. Deliberately checks *every* check-run + # GitHub reports for this SHA (not a ruleset-derived "required" + # subset -- ADR 0129 rejected the ruleset-name lookup specifically + # to avoid needing `administration: read`; checking everything is + # strictly more conservative and only needs `checks: read`). + # Excludes this release run's own check-runs (both verify's and + # publish's) via their shared GITHUB_RUN_ID, or a workflow_dispatch + # would always find itself as an unfinished check and deadlock. + not_ready="$(echo "${checks_pages}" | jq --arg run_id "${GITHUB_RUN_ID}" ' + [ .[] | .check_runs[]? ] + | map(select((.details_url // "") | contains("/actions/runs/" + $run_id + "/") | not)) + | map(select(.status != "completed" or ((.conclusion // "") as $c | (["success","skipped","neutral"] | index($c)) == null))) + ')" + not_ready_count="$(echo "${not_ready}" | jq 'length')" + if [ "${not_ready_count}" != "0" ]; then + echo "::error::${not_ready_count} check(s) for commit ${GITHUB_SHA} are not both complete and green (excluding this release run's own checks): $(echo "${not_ready}" | jq -c 'map({name, status, conclusion})')" >&2 + exit 1 + fi + - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 with: @@ -90,6 +124,7 @@ jobs: if ! tag_commit="$(gh api "repos/${GITHUB_REPOSITORY}/commits/v${RELEASE_VERSION}" --jq .sha 2>/dev/null)"; then echo "::notice::Tag v${RELEASE_VERSION} does not exist yet; this is a fresh publish." echo "tag_resume=false" >> "${GITHUB_OUTPUT}" + echo "release_resume=false" >> "${GITHUB_OUTPUT}" exit 0 fi @@ -98,14 +133,25 @@ jobs: exit 1 fi + echo "tag_resume=true" >> "${GITHUB_OUTPUT}" + + # A published Release already existing does not mean nothing is + # left to resume: creating a Release can publish the Release + # object and then fail partway through uploading its assets, in + # which case the tag and the Release both already exist but a + # release asset (the SBOM) can still be missing. Reject only a + # tag pointing at a different commit (above); a Release already + # existing for *this* commit is a safe resume that still + # attempts the best-effort asset attach in `publish`, never a + # reject -- see Devin's follow-up finding on this PR. if gh release view "v${RELEASE_VERSION}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then - echo "::error::v${RELEASE_VERSION} already has a published GitHub Release for this exact commit; there is nothing left to resume." >&2 - exit 1 + echo "::notice::Tag v${RELEASE_VERSION} already points at this commit and its GitHub Release already exists; resuming to attach any still-missing release assets rather than treating this as nothing left to resume." + echo "release_resume=true" >> "${GITHUB_OUTPUT}" + else + echo "::notice::Tag v${RELEASE_VERSION} already points at this commit with no published GitHub Release yet; resuming publication instead of re-tagging." + echo "release_resume=false" >> "${GITHUB_OUTPUT}" fi - echo "::notice::Tag v${RELEASE_VERSION} already points at this commit with no published GitHub Release yet; resuming publication instead of re-tagging." - echo "tag_resume=true" >> "${GITHUB_OUTPUT}" - - name: Run the full required test suite fresh on this exact commit run: uv run --locked --extra api --extra db --extra queue --group dev python -m pytest -q @@ -153,10 +199,12 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + checks: read env: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ inputs.version }} TAG_RESUME: ${{ needs.verify.outputs.tag_resume }} + RELEASE_RESUME: ${{ needs.verify.outputs.release_resume }} steps: - name: Checkout protected main uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 @@ -174,6 +222,21 @@ jobs: exit 1 fi + - name: Re-verify every check reported for this commit is complete and green + run: | + set -euo pipefail + checks_pages="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/check-runs?per_page=100" --paginate --slurp)" + not_ready="$(echo "${checks_pages}" | jq --arg run_id "${GITHUB_RUN_ID}" ' + [ .[] | .check_runs[]? ] + | map(select((.details_url // "") | contains("/actions/runs/" + $run_id + "/") | not)) + | map(select(.status != "completed" or ((.conclusion // "") as $c | (["success","skipped","neutral"] | index($c)) == null))) + ')" + not_ready_count="$(echo "${not_ready}" | jq 'length')" + if [ "${not_ready_count}" != "0" ]; then + echo "::error::${not_ready_count} check(s) for commit ${GITHUB_SHA} are not both complete and green (excluding this release run's own checks): $(echo "${not_ready}" | jq -c 'map({name, status, conclusion})')" >&2 + exit 1 + fi + - name: Download the release notes and SBOM produced by verify uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # actions/download-artifact@v5 with: @@ -188,15 +251,27 @@ jobs: git tag -a "v${RELEASE_VERSION}" -m "Release v${RELEASE_VERSION}" git push origin "refs/tags/v${RELEASE_VERSION}" - - name: Publish the GitHub Release + - name: Create the GitHub Release + if: env.RELEASE_RESUME != 'true' run: | set -euo pipefail - assets=() - if [ -f "sbom-download/cyclonedx-sbom.json" ]; then - assets+=("sbom-download/cyclonedx-sbom.json") - fi gh release create "v${RELEASE_VERSION}" \ --repo "${GITHUB_REPOSITORY}" \ --title "v${RELEASE_VERSION}" \ - --notes-file release-notes.md \ - "${assets[@]}" + --notes-file release-notes.md + + - name: Attach any still-missing release assets, best-effort + run: | + set -uo pipefail + sbom_file="sbom-download/cyclonedx-sbom.json" + if [ ! -f "${sbom_file}" ]; then + echo "::notice::No SBOM artifact was fetched for this commit; nothing to attach." + exit 0 + fi + if attached="$(gh release view "v${RELEASE_VERSION}" --repo "${GITHUB_REPOSITORY}" --json assets --jq '.assets[].name' 2>/dev/null)" && printf '%s\n' "${attached}" | grep -qxF "cyclonedx-sbom.json"; then + echo "::notice::cyclonedx-sbom.json is already attached to v${RELEASE_VERSION}; nothing to do." + exit 0 + fi + if ! gh release upload "v${RELEASE_VERSION}" "${sbom_file}" --repo "${GITHUB_REPOSITORY}" --clobber; then + echo "::warning::Could not attach cyclonedx-sbom.json to v${RELEASE_VERSION}; the release is published without it. Re-dispatch the workflow to retry the attach." + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c62942ae..24af1f24f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,20 +16,25 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) (`workflow_dispatch` only, explicit `version` input, never triggered by push/schedule/merge), split into a read-only, credential-less `verify` job and a write-scoped `publish` job for least privilege. `verify` checks the - dispatched commit is protected `main`'s untampered current tip, checks the - requested version against `pyproject.toml`'s `[project]` table (table- + dispatched commit is protected `main`'s untampered current tip and that + every check GitHub reports for that exact commit is complete with an + acceptable conclusion (excluding this release run's own checks), checks + the requested version against `pyproject.toml`'s `[project]` table (table- boundary aware, so a same-named `version` key in an unrelated table can never be mistaken for it), resolves any existing `vX.Y.Z` tag via the - GitHub commits API (rejecting one that points at a different commit or - whose Release already exists, but permitting a safe idempotent resume when - it matches this commit with no Release published yet), re-runs the full - test suite fresh, renders release notes from this file's matching - `## [X.Y.Z]` section via the tested `scripts/ci/release_notes.py`, and - best-effort looks up a CycloneDX SBOM (a missing SBOM or failed lookup - warns, never blocks). `publish` re-verifies `main`'s tip has not advanced - since `verify` started testing — immediately before it creates anything — - then creates the annotated `vX.Y.Z` tag (skipped on a resumed run) and the - GitHub Release. Gives downstream consumers + GitHub commits API (rejecting only one that points at a different commit; + a tag at this commit is always a safe idempotent resume, whether or not + its Release already exists — see below), re-runs the full test suite + fresh, renders release notes from this file's matching `## [X.Y.Z]` + section via the tested `scripts/ci/release_notes.py`, and best-effort + looks up a CycloneDX SBOM (a missing SBOM or failed lookup warns, never + blocks). `publish` re-verifies `main`'s tip has not advanced and every + check is still green since `verify` started testing — immediately before + it creates anything — then creates the annotated `vX.Y.Z` tag (skipped on + a tag resume) and the GitHub Release (skipped on a Release resume, e.g. a + prior run whose asset upload failed after the Release itself was already + created), always attempting the best-effort SBOM asset attach afterward + either way. Gives downstream consumers (`ContextualWisdomLab/keyverse#132`, `bandscope#881`, and the Wardnet consumer-owner handoff, all recorded on `contextual-orchestrator#971`) an immutable pin target (`.../releases/tag/vX.Y.Z` — not the mutable diff --git a/docs/RELEASING.md b/docs/RELEASING.md index c6ee4adfc..171209748 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -37,15 +37,21 @@ that separate system. 2. `CHANGELOG.md` has a `## [X.Y.Z]` section (an `- Unreleased` or dated suffix is fine) with real, non-empty content describing what changed. 3. Either no git tag `vX.Y.Z` exists yet, or one does but points at the exact - commit you're dispatching and has no GitHub Release published yet (a safe - resume of a run that failed after pushing the tag but before publishing — - see step 4 below). A tag pointing at any *other* commit, or one whose - release already exists, is rejected: a tag is never reused or moved onto a - different commit — bump the version again if you need to re-release. + commit you're dispatching — in which case the workflow safely resumes + instead of re-tagging: if that tag has no GitHub Release published yet, + it creates one; if the Release also already exists (e.g. a prior run's + asset-upload step failed after `gh release create` itself succeeded), it + still attempts the best-effort SBOM asset attach rather than treating the + run as nothing left to do — see step 4 below. A tag pointing at any + *other* commit is rejected outright: a tag is never reused or moved onto + a different commit — bump the version again if you need to re-release. 4. `main` is currently green — its own required checks (Tests, Security, Fuzz, and the org-central Strix/OpenCode/security-scan/OSV/Scorecard checks from `ContextualWisdomLab/.github`) are passing. The release - workflow re-verifies the commit is genuinely `main`'s untampered tip and + workflow re-verifies both automatically: the commit is genuinely `main`'s + untampered tip, and every check GitHub reports for that exact commit + (excluding the release run's own) is complete with a successful, + skipped, or neutral conclusion — it fails closed otherwise. It also re-runs the full test suite fresh, but it does not re-run CodeQL, Trivy, OSV, Scorecard, or the review bots — those already had to pass before this commit could exist on protected `main` at all. @@ -64,12 +70,16 @@ that separate system. - **`verify`** (read-only): - fails closed if the dispatched commit is not `main`'s current tip (a race with a concurrent merge); + - fails closed unless every check GitHub reports for this exact commit + is complete with a successful, skipped, or neutral conclusion (a + push-triggered workflow — Security, Fuzz, ... — still running or + having failed on this commit); - fails closed if the input version does not match `pyproject.toml`'s `[project]` table; - - resolves any existing `vX.Y.Z` tag via the commit API: fails closed if - it points at a different commit or its GitHub Release already exists; - otherwise proceeds (fresh publish, or a safe resume — see step 3 - above); + - resolves any existing `vX.Y.Z` tag via the commit API: fails closed + only if it points at a *different* commit; a tag at this commit + proceeds as a resume (fresh publish, tag-only resume, or full + release-and-asset resume — see step 3 above); - runs the full test suite fresh (`uv run --locked --extra api --extra db --extra queue --group dev python -m pytest -q`); - renders release notes from `CHANGELOG.md`'s matching section @@ -81,12 +91,19 @@ that separate system. release); - uploads the rendered notes and any SBOM for `publish` to pick up. - **`publish`** (write-scoped, only after `verify` succeeds): - - re-verifies `main`'s tip has not advanced while `verify` was testing - and rendering notes (a second, authoritative check right before - anything is created); + - re-verifies `main`'s tip has not advanced, and every check for this + commit is still complete and green, while `verify` was testing and + rendering notes (a second, authoritative recheck of both, right + before anything is created — see "Known limitations" below for the + small residual window this still leaves); - creates and pushes an annotated tag `vX.Y.Z` — skipped when resuming a run whose tag already exists at this commit; - - publishes the GitHub Release using the notes/SBOM `verify` produced. + - creates the GitHub Release using the notes `verify` produced — + skipped when resuming a run whose Release already exists at this + commit; + - attempts the best-effort SBOM asset attach, whether the Release was + just created or already existed — a failure here warns and never + blocks (re-dispatch to retry the attach). 5. Confirm at . @@ -100,6 +117,32 @@ that separate system. the Wardnet consumer-owner handoff on `contextual-orchestrator#971`) can now bump to the published tag instead of a vendored source SHA. +## Known limitations + +**A small, accepted check-then-act window remains before the tag/Release are +actually created.** `publish`'s recheck of `main`'s tip and of every check +for that commit is the very first thing it does, back-to-back, before +anything else — but GitHub exposes no atomic "create this tag only if branch +`X` is still at commit `Y`" API, so there is no way to make that window +literally zero. In practice it is small (a same-org artifact download plus +the `git tag`/`git push` themselves, on the order of seconds), this is a +manual, maintainer-triggered dispatch rather than a high-frequency automated +path, and the only realistic outcome if the window is ever actually hit is +releasing a commit that genuinely *was* `main`'s verified, all-checks-green +tip moments earlier — not a wrong, unreviewed, or malicious commit, and not +one that skipped this workflow's own fresh test run. See +`docs/planning/adrs/0129-canonical-immutable-release.md`'s "Known +limitations" section for the full reasoning. + +If you ever discover a release published a commit that was immediately +superseded by another merge: **do not** retroactively move, delete, or +retag the published release (see Rollback below — tags here are immutable +once published, and this is not the "genuine publishing mistake caught +immediately" case that section's narrow deletion exception covers). Instead, +just cut a new patch (or minor) release from the actual intended tip through +the normal dispatch process above; the superseded release stays as an +accurate record of what `main`'s tip briefly was. + ## Rollback Releases are immutable — never delete or retag a published release to "fix" diff --git a/docs/planning/adrs/0129-canonical-immutable-release.md b/docs/planning/adrs/0129-canonical-immutable-release.md index fc03ece57..bb05cb010 100644 --- a/docs/planning/adrs/0129-canonical-immutable-release.md +++ b/docs/planning/adrs/0129-canonical-immutable-release.md @@ -165,7 +165,13 @@ Before any tag or Release is created, the release job: 2. Re-fetches protected `main`'s current tip via `gh api repos/$REPO/commits/ main --jq .sha` and fails closed if it does not exactly equal the checked- out commit — guards against a stale dispatch racing a concurrent merge, or - a detached/rewritten ref. This needs only `contents: read`. + a detached/rewritten ref. This needs only `contents: read`. Immediately + after, fails closed unless every check GitHub reports for that exact + commit (via `commits/$SHA/check-runs`, excluding this release run's own + checks) is complete with an acceptable conclusion — guards against + dispatching while a push-triggered workflow on the new `main` tip is + still in flight or has failed (see the Non-goals note on this and "Known + limitations" below). Needs `checks: read`. 3. Parses `pyproject.toml`'s `version = "..."` and fails closed unless it is byte-for-byte equal to the `version` input. A release never redefines what version a commit is; the version bump is a normal, already-reviewed PR @@ -235,12 +241,23 @@ add release-specific assurance. input. - **A release cut on every merge to `main`.** Explicitly rejected — see Trigger above. -- **Re-deriving required-check names dynamically from the GitHub ruleset +- **Re-deriving required-check *names* dynamically from the GitHub ruleset API** (as `release_authority_snapshot.py` does for its PR-scoped - evidence). This ADR's gate does not need the exhaustive required-check - inventory — it needs "is this commit really `main`'s tip" (step 2) plus a - fresh regression run (step 5), both of which are simpler and do not need - the `administration: read` permission the rulesets endpoint requires. + evidence). A later Devin finding on this PR ("Unchecked main checks + permit releases") showed the gate still needed *some* check-state + verification: the checks that gated the PR's merge ran against the PR's + own head SHA, not necessarily against the resulting `main`-tip commit + (squash/rebase merges mint a new SHA), and separate push-triggered + workflows (Security, Fuzz, ...) that run again on that new tip commit can + still be in flight, or have failed, at the moment of a manual dispatch. + The gate now queries `commits/$SHA/check-runs` and fails closed unless + every check GitHub reports for that exact commit is complete with an + acceptable conclusion (see "Known limitations" below) — deliberately + *not* the ruleset-derived required-check-name lookup, which stays + out of scope: checking everything reported for the commit is strictly + more conservative than checking only a derived "required" subset, and + needs only `checks: read`, not the `administration: read` the rulesets + endpoint requires. ## Research grounding @@ -268,6 +285,66 @@ this mechanism implements, not an academic literature review: `.../releases/latest` (a mutable discovery alias, not a pin — see `docs/RELEASING.md`). +## Known limitations + +### Residual check-then-act window before the tag/Release are created + +Devin flagged this gate's re-verification (main's tip, then every check for +that commit — both re-checked a second time as `publish`'s first two steps, +immediately before anything is created) as still leaving a race: if `main` +advances, or a check regresses, in the moments between that recheck and the +actual `git push origin refs/tags/...` / `gh release create`, the workflow +would still publish the commit that *was* the verified, all-green tip +moments earlier. + +This is real, and it is also an inherent limitation of any check-then-act +sequence against an API with no atomic "create this tag only if branch `X` +is still at commit `Y`" primitive — GitHub does not expose one. No amount of +re-ordering removes the window entirely; it can only be shrunk. This ADR +accepts the remaining window as bounded and low-risk, for concrete reasons, +rather than adding more speculative complexity to chase it toward zero: + +- **The window is already close to minimal.** `publish`'s very first + fallible actions after checkout are exactly these two rechecks, run + back-to-back, with nothing repository-controlled or otherwise slow + between them and the checks. The only steps between the last recheck and + the actual tag push are downloading the small notes/SBOM artifact + `verify` already produced (a same-org GitHub Actions artifact fetch, not + arbitrary code) and the `git tag`/`git push` themselves — on the order of + a few seconds, not minutes. +- **The trigger is `workflow_dispatch` only, run by a maintainer, rarely.** + This is not a high-frequency automated path (contrast a bot that dispatches + releases on every merge, which this ADR's Trigger section explicitly + rejects) — the exposure is one narrow window per manual release, not a + continuously-open one. +- **The impact if it is ever actually hit is small and self-describing.** A + commit landing in this exact window is, by construction, one that *was* + `main`'s genuine, all-checks-green tip only seconds earlier — never a + wrong, unreviewed, or malicious commit, and never one that skipped this + workflow's own fresh test-suite run (that ran against the commit actually + being published). The realistic failure mode is "released a version that + was immediately superseded by an unrelated merge," not "released a broken + or untrusted artifact." +- **A real GitHub-side atomic primitive does not exist to close this.** + There is no API call that both verifies a ref's current SHA and creates a + tag/Release in one atomic, all-or-nothing operation; closing this + completely would require either GitHub adding one, or building a + bespoke distributed-locking layer around a `workflow_dispatch` action a + human already gates by hand — complexity disproportionate to a + maintainer-triggered, seconds-wide window with a low-severity failure + mode. + +**If a maintainer ever discovers a release published a commit that was +immediately superseded:** do not retroactively move, delete, or retag the +published release — this repository's own release mechanism treats tags as +immutable once published (see `docs/RELEASING.md`'s Rollback section), and +an already-superseded tag is not the kind of "genuine publishing mistake +caught immediately" that section's narrow `gh release delete` exception is +for. Instead, cut a new patch (or minor, if warranted) release from the +actual intended tip through the normal dispatch process above; the +superseded release simply becomes an accurate historical record of what +`main`'s tip briefly was. + ## Consequences - Consumers gain a real, immutable pin target: diff --git a/tests/test_release_workflow_contract.py b/tests/test_release_workflow_contract.py index f5f937707..4090d5c59 100644 --- a/tests/test_release_workflow_contract.py +++ b/tests/test_release_workflow_contract.py @@ -248,6 +248,60 @@ def test_final_main_tip_check_happens_after_testing_and_note_rendering_but_befor assert 'if [ "${remote_head}" != "${GITHUB_SHA}" ]' in final_tip_check_block +def test_checks_read_permission_is_granted_in_both_jobs() -> None: + """The checks-green gate (Devin follow-up finding: "Unchecked main + checks permit releases") needs `checks: read` in both `verify` (fail + fast, before the expensive test suite) and `publish` (the authoritative + recheck right before anything is created).""" + workflow = _workflow_text() + verify_block = _job_block(workflow, "verify") + publish_block = _job_block(workflow, "publish") + assert "checks: read" in verify_block + assert "checks: read" in publish_block + + +def test_gate_verifies_every_check_for_the_commit_is_complete_and_green() -> None: + """`verify` must fail closed before running the expensive test suite if + any check GitHub reports for this exact commit (Security, Fuzz, ... -- + whatever push-triggered workflows ran again on main's new tip after the + merge) is still pending or did not conclude successfully.""" + workflow = _workflow_text() + verify_block = _job_block(workflow, "verify") + checks_step_index = verify_block.index("Verify every check reported for this commit is complete and green") + test_suite_index = verify_block.index("Run the full required test suite fresh on this exact commit") + assert checks_step_index < test_suite_index + + checks_block = verify_block[checks_step_index:test_suite_index] + assert 'repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/check-runs' in checks_block + assert '.status != "completed"' in checks_block + assert '["success","skipped","neutral"]' in checks_block + assert 'if [ "${not_ready_count}" != "0" ]' in checks_block + assert "exit 1" in checks_block + # Excludes this release run's own check-runs -- otherwise a + # workflow_dispatch run would always find itself unfinished and deadlock. + assert "GITHUB_RUN_ID" in checks_block + + +def test_final_checks_green_recheck_happens_right_after_the_final_tip_check() -> None: + """`publish` must re-verify checks are still green immediately after its + final main-tip recheck -- both authoritative gates cluster together, + right after checkout, before anything else runs.""" + workflow = _workflow_text() + publish_block = _job_block(workflow, "publish") + final_tip_check_index = publish_block.index( + "Re-verify protected main has not advanced since verification started" + ) + final_checks_index = publish_block.index("Re-verify every check reported for this commit is complete and green") + download_index = publish_block.index("Download the release notes and SBOM") + tag_step_index = publish_block.index("Create the annotated release tag") + assert final_tip_check_index < final_checks_index < download_index < tag_step_index + + checks_block = publish_block[final_checks_index:download_index] + assert 'repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/check-runs' in checks_block + assert 'if [ "${not_ready_count}" != "0" ]' in checks_block + assert "exit 1" in checks_block + + def test_pinned_actions_use_full_commit_shas() -> None: """Every third-party action reference stays pinned per Scorecard convention.""" workflow = _workflow_text() diff --git a/tests/test_release_workflow_idempotency_contract.py b/tests/test_release_workflow_idempotency_contract.py index 7f0f3a6bf..67702ce84 100644 --- a/tests/test_release_workflow_idempotency_contract.py +++ b/tests/test_release_workflow_idempotency_contract.py @@ -1,22 +1,30 @@ """Idempotent-retry and least-privilege contract for the release workflow. -Covers the design that resolves three related Devin/CodeRabbit findings on +Covers the design that resolves four related Devin/CodeRabbit findings on `.github/workflows/release.yml`, kept in one file because they share a root -cause -- the tag is pushed before the fallible SBOM/GitHub-Release steps, -so any failure after that push must be safely retryable without ever moving -the tag or double-publishing: - -- The tag/release resume-vs-reject branching: an already-existing tag that - points at this exact commit with no published GitHub Release yet is a - safe resume; a tag pointing at any other commit, or a tag whose release - already exists, is rejected rather than silently accepted or overwritten. +cause -- the tag (and, now, the Release object itself) is created before the +fallible SBOM-asset step, so any failure after that must be safely +retryable without ever moving the tag or double-publishing: + +- The tag resume-vs-reject branching: an already-existing tag that points + at this exact commit is a safe resume; a tag pointing at any other commit + is rejected rather than silently accepted or overwritten. +- The Release resume-vs-create branching (`release_resume`, a Devin + follow-up finding distinct from the tag one above): a GitHub Release that + already exists for this exact commit is *also* a safe resume -- `gh + release create` can publish the Release object and then fail partway + through uploading its assets, so "the Release already exists" must not be + treated as "nothing left to do." `publish` always attempts the + best-effort SBOM asset attach afterward, whether the Release was just + created or already existed. - `actions: read` is granted at the `verify` job's scope (needed for the best-effort SBOM lookup) and nowhere else. - The two-job least-privilege split: `verify` (read-only, no persisted git credential) executes all repository-controlled code -- the fresh test suite and note rendering -- before `publish` (the only job holding - `contents: write`) ever runs, and `publish`'s first action is an - authoritative main-tip re-check immediately before it creates anything. + `contents: write`) ever runs, and `publish`'s first actions are an + authoritative main-tip re-check and a checks-green re-check, immediately + before it creates anything. Uses plain text/index assertions on the same raw YAML text convention as `tests/test_release_workflow_contract.py` (see that file's docstring for the @@ -72,31 +80,38 @@ def _tag_state_step(workflow: str) -> str: return verify_block[step_start:next_step_start] -# --- Tag resume-vs-reject branching (Devin finding 3) ----------------------- +# --- Tag/Release resume-vs-reject branching (Devin findings 3 and the +# --- follow-up "Failed asset upload strands releases") ---------------------- def test_tag_state_step_exists_with_a_stable_output() -> None: - """The resume decision is exposed as a step output the `publish` job - can gate on, not just an internal variable.""" + """The resume decisions are exposed as step outputs the `publish` job + can gate on, not just internal variables. Two separate flags -- whether + the tag itself is safe to skip re-creating, and whether the GitHub + Release object is safe to skip re-creating -- because a Devin follow-up + finding showed those are not the same question (see the + `release_resume`-specific tests below).""" workflow = _workflow_text() verify_block = _job_block(workflow, "verify") assert 'id: tag_state' in verify_block assert 'echo "tag_resume=false" >> "${GITHUB_OUTPUT}"' in verify_block assert 'echo "tag_resume=true" >> "${GITHUB_OUTPUT}"' in verify_block + assert 'echo "release_resume=false" >> "${GITHUB_OUTPUT}"' in verify_block + assert 'echo "release_resume=true" >> "${GITHUB_OUTPUT}"' in verify_block assert "outputs:" in verify_block assert "tag_resume: ${{ steps.tag_state.outputs.tag_resume }}" in verify_block + assert "release_resume: ${{ steps.tag_state.outputs.release_resume }}" in verify_block def test_absent_tag_is_a_fresh_publish_checked_before_any_reject_branch() -> None: """No tag at all must short-circuit straight to `tag_resume=false`, - before the points-elsewhere/already-published reject logic ever runs - (that logic requires a tag to exist, so it must come after).""" + before the points-elsewhere reject branch or the tag_resume=true branch + (which requires a tag to exist) ever run.""" step = _tag_state_step(_workflow_text()) fresh_index = step.index('echo "tag_resume=false"') points_elsewhere_index = step.index("a release tag is never moved onto a different commit") - already_published_index = step.index("there is nothing left to resume") - resume_index = step.index('echo "tag_resume=true"') - assert fresh_index < points_elsewhere_index < already_published_index < resume_index + tag_resume_true_index = step.index('echo "tag_resume=true"') + assert fresh_index < points_elsewhere_index < tag_resume_true_index def test_tag_pointing_at_a_different_commit_is_rejected_not_moved() -> None: @@ -112,27 +127,47 @@ def test_tag_pointing_at_a_different_commit_is_rejected_not_moved() -> None: assert "exit 1" in following -def test_tag_matching_commit_with_existing_release_is_rejected_as_nothing_to_resume() -> None: - """A tag at the right commit whose GitHub Release already exists must - also reject -- retries are for incomplete publications, not to - re-publish a release that already exists.""" +def test_tag_matching_commit_sets_tag_resume_before_branching_on_release_existence() -> None: + """Once the tag is confirmed to point at this exact commit, `tag_resume` + is set unconditionally -- only whether the GitHub Release itself + already exists (`release_resume`) still needs its own branch.""" + step = _tag_state_step(_workflow_text()) + mismatch_index = step.index('${tag_commit}" != "${GITHUB_SHA}') + tag_resume_true_index = step.index('echo "tag_resume=true"') + release_view_index = step.index('gh release view "v${RELEASE_VERSION}"') + assert mismatch_index < tag_resume_true_index < release_view_index + + +def test_tag_matching_commit_with_existing_release_resumes_to_attach_missing_assets() -> None: + """A tag at the right commit whose GitHub Release already exists is a + safe resume too, not a reject: `gh release create` can publish the + Release object and then fail partway through uploading assets, so the + tag and the Release can both already exist while the SBOM asset is + still missing. The earlier "nothing left to resume" rejection stranded + that release forever (Devin's follow-up finding) -- `publish` must + still get a chance to attempt the best-effort asset attach.""" step = _tag_state_step(_workflow_text()) assert 'gh release view "v${RELEASE_VERSION}"' in step release_view_index = step.index('gh release view "v${RELEASE_VERSION}"') - following = step[release_view_index : release_view_index + 400] - assert "::error::" in following - assert "exit 1" in following - assert "already has a published GitHub Release" in following + # Bounded to the "release already exists" branch itself (up to its + # `else`), not an arbitrary character count -- the branch's own + # explanatory comment is long enough to overflow a fixed window. + else_index = step.index("\n else\n", release_view_index) + branch = step[release_view_index:else_index] + assert "::error::" not in branch + assert "exit 1" not in branch + assert 'echo "release_resume=true" >> "${GITHUB_OUTPUT}"' in branch def test_tag_matching_commit_with_no_release_yet_resumes_without_retagging() -> None: - """Only the narrow safe case -- same commit, no release published yet -- - sets `tag_resume=true`, and this must be the last branch reached (after - both reject checks have already passed).""" + """The narrow case -- same commit, no release published yet -- sets + `release_resume=false` (still resuming the tag, but the Release itself + must still be created), and this branch is reached only after the + release-exists check has already run.""" step = _tag_state_step(_workflow_text()) - resume_notice_index = step.index("resuming publication instead of re-tagging") - resume_output_index = step.index('echo "tag_resume=true"') release_view_index = step.index('gh release view "v${RELEASE_VERSION}"') + resume_notice_index = step.index("resuming publication instead of re-tagging", release_view_index) + resume_output_index = step.index('echo "release_resume=false" >> "${GITHUB_OUTPUT}"', release_view_index) assert release_view_index < resume_notice_index < resume_output_index @@ -148,6 +183,46 @@ def test_publish_job_skips_tag_creation_only_when_resuming() -> None: assert "if: env.TAG_RESUME != 'true'" in if_line +def test_publish_job_only_creates_the_release_when_it_does_not_already_exist() -> None: + """`publish` must gate `gh release create` on `release_resume` -- when a + Release already exists for this commit, creating it again would fail + outright rather than resuming the asset attach.""" + workflow = _workflow_text() + publish_block = _job_block(workflow, "publish") + assert "RELEASE_RESUME: ${{ needs.verify.outputs.release_resume }}" in publish_block + create_step_index = publish_block.index("Create the GitHub Release") + following_line_end = publish_block.index("\n", create_step_index) + if_line = publish_block[create_step_index:publish_block.index("\n", following_line_end + 1)] + assert "if: env.RELEASE_RESUME != 'true'" in if_line + + +def test_release_asset_attach_always_runs_and_is_best_effort() -> None: + """Whether the Release was just created fresh or already existed on a + resumed run, attaching the SBOM asset must still be attempted -- and a + failed attach must never fail the whole run, matching this workflow's + established SBOM best-effort convention (Devin's follow-up finding: an + asset upload failing after `gh release create` already succeeded must + be retryable, not stranded).""" + workflow = _workflow_text() + publish_block = _job_block(workflow, "publish") + create_step_index = publish_block.index("Create the GitHub Release") + attach_step_index = publish_block.index("Attach any still-missing release assets") + assert create_step_index < attach_step_index + + attach_step = publish_block[attach_step_index:] + step_header = attach_step[: attach_step.index("run:")] + # The attach step itself carries no `if:` gate -- it must run whether + # `Create the GitHub Release` ran or was skipped as already-resumed. + assert "if:" not in step_header + assert "set -euo pipefail" not in attach_step, ( + "the attach step must not abort-on-error via -e; a failed upload " + "needs its own explicit, non-fatal handling instead" + ) + assert "if ! gh release upload" in attach_step + assert "::warning::" in attach_step + assert "--clobber" in attach_step + + # --- `actions: read` scoping (Devin finding 2) ------------------------------- From b7a044dbeea611e738a456173d7c19d4ff267e02 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 20:52:05 +0000 Subject: [PATCH 16/35] fix(release): checks-registration race and confirmed-absence lookups Two more genuine Devin findings on the release workflow's checks-green gate and tag/release lookup logic, both real gaps left after cf69dc39: 1. "Missing checks pass release gate" (checks-green gate, still a real gap despite the earlier reply on this thread claiming it was fully fixed): a dispatch fired moments after a merge can race GitHub's own registration of the push-triggered check-runs for the new main tip. `commits/$SHA/check-runs` can legitimately come back empty or partial before Security/Fuzz/CodeQL even exist as entries, and filtering an empty/partial list for "not complete+green" is vacuously empty too -- so the gate could PASS before those checks had even started. Adds a top-level `RELEASE_EXPECTED_PUSH_CHECKS` env: this repository's own known push-triggered job names (ci.yml's two jobs, fuzz.yml's two, security.yml's two). Both the `verify` and `publish` checks-green steps now compute `missing_checks` against that list *before* the existing not-ready/green check, and fail closed if any expected check has not registered yet. Still no ruleset/`administration: read` lookup, consistent with ADR 0129's existing rationale for avoiding it -- just a fixed, repository-owned name list cross-referenced against the real API response. 2. "API failures block release recovery": the tag-existence lookup (via the commits API) and the `gh release view` release-existence lookup both treated any nonzero exit as "absent," so a transient rate-limit, auth, network, or 5xx failure during a resume attempt would have been misread as "safe to create fresh." Both lookups now distinguish a confirmed absence (an HTTP 404 from the commits API; "release not found" from `gh release view`, with the raw HTTP-404 rendering also matched as a defensive fallback) from every other failure class. Only a confirmed absence proceeds as a fresh publish/resume; anything else fails the step closed with a clear re-dispatch-to-retry message. 3. "ADR retains obsolete tag policy": ADR 0129's Gate section still described tag existence as an unconditional reject, contradicting the tag_resume/release_resume policy this workflow has actually implemented since an earlier fix pass. Updated alongside fix 2 above, since both touch the same paragraph. docs/planning/adrs/0129-canonical-immutable-release.md and docs/RELEASING.md updated to describe both refinements; CHANGELOG.md's existing Unreleased entry for this mechanism folds them in (this feature has not shipped yet, so amending its one entry in place matches this branch's established convention rather than adding separate Fixed entries). tests/test_release_workflow_contract.py: adds test_expected_push_checks_matches_this_repositorys_actual_push_triggered_jobs (drift guard against ci.yml/fuzz.yml/security.yml's real job names), test_checks_gate_requires_expected_checks_before_checking_they_are_green, and four tests that execute the checks-green step's real, unmodified script under bash against a stubbed `gh` CLI (zero/partial/complete registration, plus a still-pending check after full registration) -- asserting real exit codes and error text, not just YAML text/order. tests/test_release_workflow_idempotency_contract.py: restructures the Release-lookup branch test for the new three-way (success / confirmed absent / fail-closed) structure, adds matching tag-lookup branch tests, and adds six tests that execute the tag_state step's real script under bash against a stubbed `gh` covering confirmed-404, rate-limited, and network-error tag lookups plus confirmed-absent/rate-limited/exists release lookups -- asserting the real exit code and the actual GITHUB_OUTPUT lines written for each. Verified: tests/test_release_notes.py, tests/test_release_workflow_contract.py (26 passed), tests/test_release_workflow_idempotency_contract.py (25 passed), tests/test_planning_adr_identifiers.py, tests/test_product_planning_contract.py (69 passed total); hand-simulated both new bash+jq/gh blocks against stubbed data before encoding them as tests (zero/partial/complete check-runs registration; confirmed-404/rate-limited/network-error tag lookup; confirmed-absent/rate-limited/exists release lookup -- all six tag/release scenarios behaved exactly as intended); `bash -n` on every extracted step script; `python -m interrogate -c pyproject.toml .` -> 100.0%; release.yml parses as valid YAML (PyYAML ad hoc check, matching this repo's existing no-new-dependency workflow-contract convention). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4 --- .github/workflows/release.yml | 109 +++++- CHANGELOG.md | 50 +-- docs/RELEASING.md | 25 +- .../adrs/0129-canonical-immutable-release.md | 26 +- tests/test_release_workflow_contract.py | 254 ++++++++++++++ ...t_release_workflow_idempotency_contract.py | 319 +++++++++++++++++- 6 files changed, 735 insertions(+), 48 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 711be638c..3abefcb79 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,6 +32,22 @@ on: permissions: contents: read +# This repository's own known push-triggered job names (ci.yml, fuzz.yml, +# security.yml) -- shared by both jobs' checks-green gate below so a +# `commits/$SHA/check-runs` report that is empty, or missing one of them, +# is treated as "not ready yet", never as vacuously "nothing to block on". +# A dispatch fired moments after a merge can race GitHub's own registration +# of this new tip's push-triggered check-runs: the API can legitimately +# return an empty or partial list before GitHub has even created the +# entries, and filtering an empty/partial list for "not complete+green" is +# vacuously empty too -- which previously let a release publish before +# Security/Fuzz/CodeQL had even started (Devin finding). Kept in sync with +# those three workflows' job `name:` values by +# tests/test_release_workflow_contract.py; update this list in the same PR +# that adds, removes, or renames a push-triggered job in any of them. +env: + RELEASE_EXPECTED_PUSH_CHECKS: '["Full unit and contract suite","NIM benchmark coverage, docstrings, and package smoke","Hypothesis property tests","Atheris coverage-guided","CodeQL analysis","Python supply chain"]' + concurrency: group: release cancel-in-progress: false @@ -84,10 +100,32 @@ jobs: # Excludes this release run's own check-runs (both verify's and # publish's) via their shared GITHUB_RUN_ID, or a workflow_dispatch # would always find itself as an unfinished check and deadlock. - not_ready="$(echo "${checks_pages}" | jq --arg run_id "${GITHUB_RUN_ID}" ' + observed_checks="$(echo "${checks_pages}" | jq --arg run_id "${GITHUB_RUN_ID}" ' [ .[] | .check_runs[]? ] | map(select((.details_url // "") | contains("/actions/runs/" + $run_id + "/") | not)) - | map(select(.status != "completed" or ((.conclusion // "") as $c | (["success","skipped","neutral"] | index($c)) == null))) + ')" + # Devin finding: a dispatch fired moments after a merge can race + # GitHub's own registration of this new tip's push-triggered + # check-runs, so `observed_checks` above can legitimately be empty + # or partial before those checks exist as entries at all -- + # filtering an empty/partial list for "not complete+green" is + # vacuously empty too, which previously let this gate PASS before + # Security/Fuzz/CodeQL had even started. Close it the same + # conservative, no-ruleset way as the check above: every one of + # RELEASE_EXPECTED_PUSH_CHECKS (this repository's own known + # push-triggered job names) must already be a registered + # check-run for this commit -- absence from a report that has not + # caught up yet is "not ready", never "nothing to block on". + missing_checks="$(echo "${observed_checks}" | jq --argjson expected "${RELEASE_EXPECTED_PUSH_CHECKS}" ' + ($expected - ([.[] | .name] | unique)) + ')" + missing_count="$(echo "${missing_checks}" | jq 'length')" + if [ "${missing_count}" != "0" ]; then + echo "::error::${missing_count} expected push-triggered check(s) for commit ${GITHUB_SHA} have not registered yet (GitHub may still be creating check-runs for this new main tip): $(echo "${missing_checks}" | jq -c .). Wait a few moments and re-dispatch." >&2 + exit 1 + fi + not_ready="$(echo "${observed_checks}" | jq ' + map(select(.status != "completed" or ((.conclusion // "") as $c | (["success","skipped","neutral"] | index($c)) == null))) ')" not_ready_count="$(echo "${not_ready}" | jq 'length')" if [ "${not_ready_count}" != "0" ]; then @@ -121,12 +159,29 @@ jobs: # The commits API dereferences an annotated or lightweight tag to # its target commit in one call, and needs no local git fetch or # push credential -- this step runs with contents: read only. - if ! tag_commit="$(gh api "repos/${GITHUB_REPOSITORY}/commits/v${RELEASE_VERSION}" --jq .sha 2>/dev/null)"; then - echo "::notice::Tag v${RELEASE_VERSION} does not exist yet; this is a fresh publish." - echo "tag_resume=false" >> "${GITHUB_OUTPUT}" - echo "release_resume=false" >> "${GITHUB_OUTPUT}" - exit 0 + # + # A failed lookup is NOT automatically "tag absent" (Devin + # finding): only a confirmed HTTP 404 means that. Any other + # failure -- rate limit, auth, network blip, a GitHub 5xx -- must + # fail this step closed instead, so a bad transient result never + # gets treated as "safe to create fresh" (which could then race + # `git tag` itself failing with a confusing "already exists", or + # worse, silently proceed on a wrong assumption). `gh`'s HTTP + # error wrapper reliably renders as "... (HTTP )" on + # stderr; grep for the specific 404 rendering rather than + # inferring absence from any nonzero exit. + if ! tag_lookup_output="$(gh api "repos/${GITHUB_REPOSITORY}/commits/v${RELEASE_VERSION}" --jq .sha 2>/dev/null)"; then + tag_lookup_error="$(gh api "repos/${GITHUB_REPOSITORY}/commits/v${RELEASE_VERSION}" 2>&1 >/dev/null || true)" + if printf '%s' "${tag_lookup_error}" | grep -q "HTTP 404"; then + echo "::notice::Tag v${RELEASE_VERSION} does not exist yet (confirmed 404); this is a fresh publish." + echo "tag_resume=false" >> "${GITHUB_OUTPUT}" + echo "release_resume=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + echo "::error::Could not determine whether tag v${RELEASE_VERSION} already exists: the lookup failed with an error other than a confirmed 404, so absence is NOT confirmed -- a transient rate-limit/auth/network/5xx failure must never be treated as 'safe to create'. Failing closed; re-dispatch to retry once the transient failure clears. Raw error: ${tag_lookup_error}" >&2 + exit 1 fi + tag_commit="${tag_lookup_output}" if [ "${tag_commit}" != "${GITHUB_SHA}" ]; then echo "::error::Tag v${RELEASE_VERSION} already exists and points at ${tag_commit}, not this run's commit (${GITHUB_SHA}); a release tag is never moved onto a different commit. Publish a new version instead." >&2 @@ -144,12 +199,26 @@ jobs: # existing for *this* commit is a safe resume that still # attempts the best-effort asset attach in `publish`, never a # reject -- see Devin's follow-up finding on this PR. - if gh release view "v${RELEASE_VERSION}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + # + # As with the tag lookup above, a failed `gh release view` is NOT + # automatically "Release absent" (Devin finding): only a + # confirmed 404 means that. `gh`'s release-specific lookup + # renders a missing release as the literal "release not found" + # (its generic HTTP error wrapper, "... (HTTP 404)", is matched + # too as a defensive fallback); any other failure must fail this + # step closed rather than risking a wrongful create attempt + # against an unknown state. + if ! release_view_error="$(gh release view "v${RELEASE_VERSION}" --repo "${GITHUB_REPOSITORY}" 2>&1 >/dev/null)"; then + if printf '%s' "${release_view_error}" | grep -qiE "release not found|HTTP 404"; then + echo "::notice::Tag v${RELEASE_VERSION} already points at this commit with no published GitHub Release yet (confirmed absent); resuming publication instead of re-tagging." + echo "release_resume=false" >> "${GITHUB_OUTPUT}" + else + echo "::error::Could not determine whether a GitHub Release already exists for v${RELEASE_VERSION}: gh release view failed with an error other than a confirmed absence, so absence is NOT confirmed -- a transient rate-limit/auth/network/5xx failure must never be treated as 'safe to create'. Failing closed; re-dispatch to retry once the transient failure clears. Raw error: ${release_view_error}" >&2 + exit 1 + fi + else echo "::notice::Tag v${RELEASE_VERSION} already points at this commit and its GitHub Release already exists; resuming to attach any still-missing release assets rather than treating this as nothing left to resume." echo "release_resume=true" >> "${GITHUB_OUTPUT}" - else - echo "::notice::Tag v${RELEASE_VERSION} already points at this commit with no published GitHub Release yet; resuming publication instead of re-tagging." - echo "release_resume=false" >> "${GITHUB_OUTPUT}" fi - name: Run the full required test suite fresh on this exact commit @@ -226,10 +295,24 @@ jobs: run: | set -euo pipefail checks_pages="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/check-runs?per_page=100" --paginate --slurp)" - not_ready="$(echo "${checks_pages}" | jq --arg run_id "${GITHUB_RUN_ID}" ' + observed_checks="$(echo "${checks_pages}" | jq --arg run_id "${GITHUB_RUN_ID}" ' [ .[] | .check_runs[]? ] | map(select((.details_url // "") | contains("/actions/runs/" + $run_id + "/") | not)) - | map(select(.status != "completed" or ((.conclusion // "") as $c | (["success","skipped","neutral"] | index($c)) == null))) + ')" + # Same expected-checks cross-reference as verify's gate -- see the + # comment there and on RELEASE_EXPECTED_PUSH_CHECKS at the top of + # this file. Re-checked here too since this recheck must be just + # as authoritative as the first one, not a weaker echo of it. + missing_checks="$(echo "${observed_checks}" | jq --argjson expected "${RELEASE_EXPECTED_PUSH_CHECKS}" ' + ($expected - ([.[] | .name] | unique)) + ')" + missing_count="$(echo "${missing_checks}" | jq 'length')" + if [ "${missing_count}" != "0" ]; then + echo "::error::${missing_count} expected push-triggered check(s) for commit ${GITHUB_SHA} have not registered yet (GitHub may still be creating check-runs for this new main tip): $(echo "${missing_checks}" | jq -c .). Wait a few moments and re-dispatch." >&2 + exit 1 + fi + not_ready="$(echo "${observed_checks}" | jq ' + map(select(.status != "completed" or ((.conclusion // "") as $c | (["success","skipped","neutral"] | index($c)) == null))) ')" not_ready_count="$(echo "${not_ready}" | jq 'length')" if [ "${not_ready_count}" != "0" ]; then diff --git a/CHANGELOG.md b/CHANGELOG.md index 24af1f24f..07d2c344b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,28 +17,38 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) push/schedule/merge), split into a read-only, credential-less `verify` job and a write-scoped `publish` job for least privilege. `verify` checks the dispatched commit is protected `main`'s untampered current tip and that - every check GitHub reports for that exact commit is complete with an - acceptable conclusion (excluding this release run's own checks), checks - the requested version against `pyproject.toml`'s `[project]` table (table- - boundary aware, so a same-named `version` key in an unrelated table can - never be mistaken for it), resolves any existing `vX.Y.Z` tag via the + every one of this repository's own known push-triggered checks (Tests, + Fuzz, Security's jobs — `RELEASE_EXPECTED_PUSH_CHECKS`) has actually + registered as a check-run for that exact commit *and* every check GitHub + reports for it is complete with an acceptable conclusion (excluding this + release run's own checks) — a dispatch fired moments after a merge, before + GitHub has finished registering those push-triggered check-runs at all, + is correctly "not ready" rather than a vacuous pass on an empty report — + checks the requested version against `pyproject.toml`'s `[project]` table + (table-boundary aware, so a same-named `version` key in an unrelated table + can never be mistaken for it), resolves any existing `vX.Y.Z` tag via the GitHub commits API (rejecting only one that points at a different commit; a tag at this commit is always a safe idempotent resume, whether or not - its Release already exists — see below), re-runs the full test suite - fresh, renders release notes from this file's matching `## [X.Y.Z]` - section via the tested `scripts/ci/release_notes.py`, and best-effort - looks up a CycloneDX SBOM (a missing SBOM or failed lookup warns, never - blocks). `publish` re-verifies `main`'s tip has not advanced and every - check is still green since `verify` started testing — immediately before - it creates anything — then creates the annotated `vX.Y.Z` tag (skipped on - a tag resume) and the GitHub Release (skipped on a Release resume, e.g. a - prior run whose asset upload failed after the Release itself was already - created), always attempting the best-effort SBOM asset attach afterward - either way. Gives downstream consumers - (`ContextualWisdomLab/keyverse#132`, `bandscope#881`, and the Wardnet - consumer-owner handoff, all recorded on `contextual-orchestrator#971`) an - immutable pin target (`.../releases/tag/vX.Y.Z` — not the mutable - `.../releases/latest` alias) instead of a vendored source SHA. See + its Release already exists — see below; a failed tag or Release lookup is + read as "absent" only on a confirmed 404 / "release not found" — any other + lookup failure, e.g. a rate limit or transient network/5xx error, fails + the step closed instead of guessing, so a later dispatch retries and + resolves cleanly rather than compounding a wrong assumption), re-runs the + full test suite fresh, renders release notes from this file's matching + `## [X.Y.Z]` section via the tested `scripts/ci/release_notes.py`, and + best-effort looks up a CycloneDX SBOM (a missing SBOM or failed lookup + warns, never blocks). `publish` re-verifies `main`'s tip has not advanced + and every expected check is still registered and green since `verify` + started testing — immediately before it creates anything — then creates + the annotated `vX.Y.Z` tag (skipped on a tag resume) and the GitHub + Release (skipped on a Release resume, e.g. a prior run whose asset upload + failed after the Release itself was already created), always attempting + the best-effort SBOM asset attach afterward either way. Gives downstream + consumers (`ContextualWisdomLab/keyverse#132`, `bandscope#881`, and the + Wardnet consumer-owner handoff, all recorded on + `contextual-orchestrator#971`) an immutable pin target + (`.../releases/tag/vX.Y.Z` — not the mutable `.../releases/latest` alias) + instead of a vendored source SHA. See `docs/planning/adrs/0129-canonical-immutable-release.md` and `docs/RELEASING.md`. No release has been cut yet — landing this mechanism and dispatching the first `v0.2.0` release are deliberately separate diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 171209748..9d7868905 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -55,6 +55,13 @@ that separate system. re-runs the full test suite fresh, but it does not re-run CodeQL, Trivy, OSV, Scorecard, or the review bots — those already had to pass before this commit could exist on protected `main` at all. + - **If you dispatch moments after a merge lands**, the gate can fail + with "expected push-triggered check(s) ... have not registered yet" — + GitHub has not finished creating this new tip's Tests/Security/Fuzz + check-run entries yet. This is expected and safe: wait a few moments + for those workflows to actually start, then re-dispatch. It is + distinct from a genuine pending/failed check, which the same gate + reports as "not both complete and green" instead. ## Cutting a release @@ -70,16 +77,24 @@ that separate system. - **`verify`** (read-only): - fails closed if the dispatched commit is not `main`'s current tip (a race with a concurrent merge); - - fails closed unless every check GitHub reports for this exact commit - is complete with a successful, skipped, or neutral conclusion (a - push-triggered workflow — Security, Fuzz, ... — still running or - having failed on this commit); + - fails closed unless every one of this repository's own known + push-triggered checks (Tests' two jobs, Fuzz's two jobs, Security's + two jobs — see `RELEASE_EXPECTED_PUSH_CHECKS` in `release.yml`) has + actually registered as a check-run for this exact commit *and* every + check GitHub reports for it is complete with a successful, skipped, + or neutral conclusion (a push-triggered workflow — Security, Fuzz, + ... — not yet registered, still running, or having failed on this + commit); - fails closed if the input version does not match `pyproject.toml`'s `[project]` table; - resolves any existing `vX.Y.Z` tag via the commit API: fails closed only if it points at a *different* commit; a tag at this commit proceeds as a resume (fresh publish, tag-only resume, or full - release-and-asset resume — see step 3 above); + release-and-asset resume — see step 3 above). A failed tag or + Release lookup is treated as "absent" only on a *confirmed* 404 / + "release not found"; any other lookup failure (rate limit, auth, + network, 5xx) fails this step closed instead of guessing — re-dispatch + once the transient failure clears; - runs the full test suite fresh (`uv run --locked --extra api --extra db --extra queue --group dev python -m pytest -q`); - renders release notes from `CHANGELOG.md`'s matching section diff --git a/docs/planning/adrs/0129-canonical-immutable-release.md b/docs/planning/adrs/0129-canonical-immutable-release.md index bb05cb010..23afd4e08 100644 --- a/docs/planning/adrs/0129-canonical-immutable-release.md +++ b/docs/planning/adrs/0129-canonical-immutable-release.md @@ -171,14 +171,36 @@ Before any tag or Release is created, the release job: checks) is complete with an acceptable conclusion — guards against dispatching while a push-triggered workflow on the new `main` tip is still in flight or has failed (see the Non-goals note on this and "Known - limitations" below). Needs `checks: read`. + limitations" below). Needs `checks: read`. A dispatch fired moments + after a merge can race GitHub's own registration of those push-triggered + check-runs, so this report can legitimately come back empty or partial + before they exist as entries at all — filtering an empty/partial list for + "not complete+green" is vacuously empty too (a later Devin finding, + "Missing checks pass release gate"). The gate additionally requires every + one of `RELEASE_EXPECTED_PUSH_CHECKS` — this repository's own known + push-triggered job names from `ci.yml`, `fuzz.yml`, and `security.yml`, + kept in sync by `tests/test_release_workflow_contract.py` — to already be + a registered check-run for the commit, so a not-yet-registered state is + correctly "not ready", never "nothing to block on". This is still the + same no-ruleset, `checks: read`-only approach: a fixed, repository-owned + name list, not a call to the branch-ruleset API. 3. Parses `pyproject.toml`'s `version = "..."` and fails closed unless it is byte-for-byte equal to the `version` input. A release never redefines what version a commit is; the version bump is a normal, already-reviewed PR that must land first. 4. Fails closed if git tag `v${version}` already exists locally or on the remote — an existing tag is never moved, deleted, or overwritten - (immutability). + (immutability) — except a tag/Release resume at this exact commit, a safe + retry of a previously-interrupted run (see `docs/RELEASING.md`). Both the + tag-existence and Release-existence lookups distinguish a *confirmed* + absence (an HTTP 404 from the commits API; a "release not found" from + `gh release view`) from every other lookup failure — rate limit, auth, + network blip, a GitHub 5xx (a later Devin finding, "API failures block + release recovery"). Only a confirmed absence proceeds as a fresh + publish or resume; any other failure fails the step closed instead of + guessing "absent" and risking a wrong create attempt against unconfirmed + state — a later dispatch then retries and resolves cleanly once the + transient failure clears. 5. Runs this repository's own full test suite fresh, on the exact commit about to be tagged (`uv run --locked --extra api --extra db --extra queue --group dev python -m pytest -q`, the same invocation `ci.yml`'s "Full diff --git a/tests/test_release_workflow_contract.py b/tests/test_release_workflow_contract.py index 4090d5c59..551600eaa 100644 --- a/tests/test_release_workflow_contract.py +++ b/tests/test_release_workflow_contract.py @@ -23,6 +23,10 @@ from __future__ import annotations +import json +import os +import re +import subprocess from pathlib import Path import pytest @@ -33,6 +37,17 @@ _JOB_NAMES = ("verify", "publish") +# Fixed, deterministic values for the hand-simulation tests below -- +# arbitrary, just stable and, critically, *distinct* so a real check-run +# from an earlier push-triggered workflow run is never accidentally +# mistaken for one of this release run's own (which the gate's real filter +# must exclude): _SIM_RUN_ID is the release workflow's own run id (the one +# GITHUB_RUN_ID is set to); _SIM_PRIOR_RUN_ID is the unrelated, earlier +# run id the stubbed push-triggered check-runs themselves carry. +_SIM_GITHUB_SHA = "cf69dc39457829c351277aad8096c24115d3991c" +_SIM_RUN_ID = "999999" +_SIM_PRIOR_RUN_ID = "888888" + def _workflow_text() -> str: """Return the release workflow's raw YAML text.""" @@ -67,6 +82,120 @@ def _step_names(block: str) -> list[str]: ] +def _step_script(block: str, step_heading: str) -> str: + """Return one step's real `run: |` script body, dedented to shell text. + + Located by `step_heading` within `block` (matching this file's existing + index-bounded step-scoping convention), then sliced past the `run: |` + marker. Terminates at the first line that is not blank and does not + carry this workflow's fixed 10-space step-script indentation -- the + same block-scalar boundary rule YAML itself uses -- rather than a + second heading string, which a step name can appear inside of + mid-line (e.g. as a trailing substring of the *next* step's own `- + name:` line) and silently truncate the script wrong. This is the + literal script GitHub Actions would execute for that step -- not a + hand-copied stand-in that could silently drift from it -- so tests + below can execute it for real against stubbed `gh`/data rather than + only asserting on its source text. + """ + start = block.index(step_heading) + marker = "run: |\n" + marker_index = block.index(marker, start) + body = block[marker_index + len(marker) :] + lines: list[str] = [] + for line in body.splitlines(): + if line.strip() == "": + lines.append("") + continue + if not line.startswith(" " * 10): + break + lines.append(line[10:]) + while lines and lines[-1] == "": + lines.pop() + return "\n".join(lines) + "\n" + + +def _expected_push_checks_json(workflow: str) -> str: + """Return the raw `RELEASE_EXPECTED_PUSH_CHECKS` JSON literal's text.""" + marker = "RELEASE_EXPECTED_PUSH_CHECKS: '" + start = workflow.index(marker) + len(marker) + end = workflow.index("'", start) + return workflow[start:end] + + +_STUB_GH_CHECK_RUNS = """#!/usr/bin/env bash +# Stub gh CLI: answers the checks-green gate's one gh call -- +# `gh api repos/.../commits/$SHA/check-runs?... --paginate --slurp` -- with +# the canned response file named by GH_STUB_CHECKS_JSON, so the gate's real +# jq filters run against deliberately-crafted scenarios instead of a live +# GitHub API. +set -euo pipefail +if [ "$1" = "api" ]; then + for arg in "$@"; do + case "$arg" in + *check-runs*) cat "${GH_STUB_CHECKS_JSON}"; exit 0 ;; + esac + done +fi +echo "unhandled stub gh invocation: $*" >&2 +exit 98 +""" + + +def _run_checks_gate_script( + tmp_path: Path, script: str, checks_json: str, expected_json: str, run_id: str = _SIM_RUN_ID +) -> subprocess.CompletedProcess[str]: + """Execute one checks-green step's real script against stubbed gh/data. + + Builds the same environment GitHub Actions would provide + (`GITHUB_REPOSITORY`, `GITHUB_SHA`, `GITHUB_RUN_ID`, + `RELEASE_EXPECTED_PUSH_CHECKS`) plus a stub `gh` on `PATH`, then runs + the real, unmodified step script (from `_step_script`) under bash. + """ + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + gh_stub = bin_dir / "gh" + gh_stub.write_text(_STUB_GH_CHECK_RUNS, encoding="utf-8") + gh_stub.chmod(0o755) + + checks_file = tmp_path / "checks.json" + checks_file.write_text(checks_json, encoding="utf-8") + + script_path = tmp_path / "checks-gate.sh" + script_path.write_text(script, encoding="utf-8") + + env = dict(os.environ) + env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}" + env["GITHUB_REPOSITORY"] = "ContextualWisdomLab/contextual-orchestrator" + env["GITHUB_SHA"] = _SIM_GITHUB_SHA + env["GITHUB_RUN_ID"] = run_id + env["RELEASE_EXPECTED_PUSH_CHECKS"] = expected_json + env["GH_STUB_CHECKS_JSON"] = str(checks_file) + + return subprocess.run( + ["bash", str(script_path)], + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def _check_run(name: str, *, status: str = "completed", conclusion: str | None = "success", run_id: str = _SIM_PRIOR_RUN_ID, job: int = 1) -> dict: + """Build one check-run entry for a stubbed `check-runs` response page.""" + return { + "name": name, + "status": status, + "conclusion": conclusion, + "details_url": f"https://github.com/ContextualWisdomLab/contextual-orchestrator/actions/runs/{run_id}/jobs/{job}", + } + + +def _check_runs_response(entries: list[dict]) -> str: + """Render entries as one `--paginate --slurp` page, matching the real API shape.""" + return json.dumps([{"total_count": len(entries), "check_runs": entries}]) + + def test_release_workflow_file_exists() -> None: """A first canonical release mechanism must actually be present on disk.""" assert _WORKFLOW_PATH.exists() @@ -282,6 +411,131 @@ def test_gate_verifies_every_check_for_the_commit_is_complete_and_green() -> Non assert "GITHUB_RUN_ID" in checks_block +def test_expected_push_checks_matches_this_repositorys_actual_push_triggered_jobs() -> None: + """`RELEASE_EXPECTED_PUSH_CHECKS` (Devin finding: "Missing checks pass + release gate") must track the real job `name:` values `ci.yml`, + `fuzz.yml`, and `security.yml` declare for their push-triggered jobs -- + not a hand-copied list that can silently drift once one of those jobs + is renamed, added, or removed.""" + workflow = _workflow_text() + expected = json.loads(_expected_push_checks_json(workflow)) + assert len(expected) == len(set(expected)), "expected-checks list has a duplicate" + + def _job_names(path: Path) -> list[str]: + return re.findall(r"^ {4}name: (.+)$", path.read_text(encoding="utf-8"), re.MULTILINE) + + push_triggered_job_names = ( + _job_names(REPOSITORY_ROOT / ".github/workflows/ci.yml") + + _job_names(REPOSITORY_ROOT / ".github/workflows/fuzz.yml") + + _job_names(REPOSITORY_ROOT / ".github/workflows/security.yml") + ) + assert set(expected) == set(push_triggered_job_names) + assert len(expected) == len(push_triggered_job_names), "a push-triggered job name is duplicated" + + +def test_checks_gate_requires_expected_checks_before_checking_they_are_green() -> None: + """Both checks-green steps must reference the shared expected-checks + env var and compute `missing_checks`/`missing_count` *before* the + pre-existing `not_ready`/`not_ready_count` gate -- registration must be + confirmed before conclusions are even inspected.""" + workflow = _workflow_text() + for job_name, heading in (("verify", "Verify every check"), ("publish", "Re-verify every check")): + block = _job_block(workflow, job_name) + step_index = block.index(heading) + next_step_index = block.index( + "Set up Python" if job_name == "verify" else "Download the release notes and SBOM", step_index + ) + checks_block = block[step_index:next_step_index] + assert "RELEASE_EXPECTED_PUSH_CHECKS" in checks_block + missing_index = checks_block.index("missing_checks=") + missing_count_index = checks_block.index('if [ "${missing_count}" != "0" ]') + not_ready_index = checks_block.index("not_ready=") + not_ready_count_index = checks_block.index('if [ "${not_ready_count}" != "0" ]') + assert missing_index < missing_count_index < not_ready_index < not_ready_count_index + + +def test_checks_gate_zero_registered_checks_fails_closed(tmp_path: Path) -> None: + """Devin finding: dispatching moments after a merge, before GitHub has + registered ANY of this new tip's push-triggered check-runs, must not + vacuously pass -- an empty `check-runs` report is "not ready", not + "nothing to block on". Executes the real, unmodified step script.""" + workflow = _workflow_text() + verify_block = _job_block(workflow, "verify") + script = _step_script(verify_block, "Verify every check reported for this commit is complete and green") + expected_json = _expected_push_checks_json(workflow) + + result = _run_checks_gate_script(tmp_path, script, _check_runs_response([]), expected_json) + + assert result.returncode != 0, result.stderr + assert "6 expected push-triggered check(s)" in result.stderr + assert "have not registered yet" in result.stderr + + +def test_checks_gate_some_but_not_all_expected_checks_green_fails_closed(tmp_path: Path) -> None: + """Some, but not all, of the expected push-triggered checks have + registered, and every one that has is green -- must still fail: the + still-missing ones could be pending, failing, or not yet dispatched at + all, and a partial report must never be read as sufficient.""" + workflow = _workflow_text() + verify_block = _job_block(workflow, "verify") + script = _step_script(verify_block, "Verify every check reported for this commit is complete and green") + expected_json = _expected_push_checks_json(workflow) + + partial = _check_runs_response( + [_check_run("Full unit and contract suite", job=1), _check_run("CodeQL analysis", job=2)] + ) + result = _run_checks_gate_script(tmp_path, script, partial, expected_json) + + assert result.returncode != 0, result.stderr + assert "4 expected push-triggered check(s)" in result.stderr + assert "NIM benchmark coverage, docstrings, and package smoke" in result.stderr + assert "Hypothesis property tests" in result.stderr + assert "Atheris coverage-guided" in result.stderr + assert "Python supply chain" in result.stderr + + +def test_checks_gate_all_expected_checks_registered_and_green_passes(tmp_path: Path) -> None: + """All six expected push-triggered checks are registered and green + (plus this release run's own still-in-flight check-runs, correctly + excluded via GITHUB_RUN_ID) -- the gate must pass.""" + workflow = _workflow_text() + verify_block = _job_block(workflow, "verify") + script = _step_script(verify_block, "Verify every check reported for this commit is complete and green") + expected_json = _expected_push_checks_json(workflow) + expected_names = json.loads(expected_json) + + entries = [_check_run(name, job=i) for i, name in enumerate(expected_names, start=1)] + # This release run's own check-runs (verify's and publish's), which + # share GITHUB_RUN_ID and must be excluded rather than deadlocking the + # gate on itself. + entries.append(_check_run("Verify release preconditions (read-only)", status="in_progress", conclusion=None, run_id=_SIM_RUN_ID, job=7)) + entries.append(_check_run("Publish canonical immutable release", status="queued", conclusion=None, run_id=_SIM_RUN_ID, job=8)) + + result = _run_checks_gate_script(tmp_path, script, _check_runs_response(entries), expected_json) + + assert result.returncode == 0, result.stderr + + +def test_checks_gate_all_expected_registered_but_one_still_pending_fails_closed(tmp_path: Path) -> None: + """All six expected checks have registered (so the new missing-checks + gate passes), but one is still in flight -- the pre-existing + not-ready/green gate must still catch it.""" + workflow = _workflow_text() + verify_block = _job_block(workflow, "verify") + script = _step_script(verify_block, "Verify every check reported for this commit is complete and green") + expected_json = _expected_push_checks_json(workflow) + expected_names = json.loads(expected_json) + + entries = [_check_run(name, job=i) for i, name in enumerate(expected_names, start=1)] + entries[-1] = _check_run(expected_names[-1], status="in_progress", conclusion=None, job=len(expected_names)) + + result = _run_checks_gate_script(tmp_path, script, _check_runs_response(entries), expected_json) + + assert result.returncode != 0, result.stderr + assert "have not registered yet" not in result.stderr + assert "not both complete and green" in result.stderr + + def test_final_checks_green_recheck_happens_right_after_the_final_tip_check() -> None: """`publish` must re-verify checks are still green immediately after its final main-tip recheck -- both authoritative gates cluster together, diff --git a/tests/test_release_workflow_idempotency_contract.py b/tests/test_release_workflow_idempotency_contract.py index 67702ce84..2f3a35d95 100644 --- a/tests/test_release_workflow_idempotency_contract.py +++ b/tests/test_release_workflow_idempotency_contract.py @@ -1,6 +1,6 @@ """Idempotent-retry and least-privilege contract for the release workflow. -Covers the design that resolves four related Devin/CodeRabbit findings on +Covers the design that resolves five related Devin/CodeRabbit findings on `.github/workflows/release.yml`, kept in one file because they share a root cause -- the tag (and, now, the Release object itself) is created before the fallible SBOM-asset step, so any failure after that must be safely @@ -17,6 +17,14 @@ treated as "nothing left to do." `publish` always attempts the best-effort SBOM asset attach afterward, whether the Release was just created or already existed. +- Confirmed-absence vs transient-failure classification for both lookups + (Devin's later finding, "API failures block release recovery"): a failed + tag lookup or `gh release view` call is *not* automatically "absent" -- + only a confirmed HTTP 404 (tag) or "release not found" (Release) means + that; any other failure (rate limit, auth, network blip, a GitHub 5xx) + fails the step closed instead of risking a wrong fresh-create attempt + against unconfirmed state. See the real bash+stub-`gh` simulation near + the end of this file for end-to-end coverage beyond text assertions. - `actions: read` is granted at the `verify` job's scope (needed for the best-effort SBOM lookup) and nowhere else. - The two-job least-privilege split: `verify` (read-only, no persisted git @@ -31,11 +39,17 @@ Ponytail no-new-YAML-dependency rationale) -- but bounded to one job's own step block via `_job_block`, and to one step's own branch via explicit ordering assertions, so these prove real operational structure rather than -loose text proximity. +loose text proximity. The confirmed-absence-vs-transient-failure tests near +the end of this file go further: they execute the tag_state step's actual, +unmodified script under bash against a stubbed `gh`, the same +real-execution technique `tests/test_release_workflow_contract.py` uses for +the checks-green gate. """ from __future__ import annotations +import os +import subprocess from pathlib import Path import pytest @@ -46,6 +60,12 @@ _JOB_NAMES = ("verify", "publish") +# Fixed, deterministic values for the hand-simulation tests near the end of +# this file -- arbitrary, just stable and easy to eyeball in a failure. +_SIM_GITHUB_REPOSITORY = "ContextualWisdomLab/contextual-orchestrator" +_SIM_RELEASE_VERSION = "0.2.0" +_SIM_GITHUB_SHA = "cf69dc39457829c351277aad8096c24115d3991c" + def _workflow_text() -> str: """Return the release workflow's raw YAML text.""" @@ -114,6 +134,41 @@ def test_absent_tag_is_a_fresh_publish_checked_before_any_reject_branch() -> Non assert fresh_index < points_elsewhere_index < tag_resume_true_index +def test_tag_lookup_confirmed_404_is_a_fresh_publish_without_error() -> None: + """Devin finding ("API failures block release recovery"): only a + confirmed HTTP 404 from the tag lookup means "tag absent" -- that + branch must resume as a clean fresh publish, no error, no exit 1, + distinct from the non-404 fail-closed branch tested right below.""" + step = _tag_state_step(_workflow_text()) + lookup_index = step.index('if ! tag_lookup_output=') + confirmed_404_index = step.index('grep -q "HTTP 404"', lookup_index) + inner_fi_index = step.index('\n fi\n', confirmed_404_index) + confirmed_404_branch = step[confirmed_404_index:inner_fi_index] + assert "::error::" not in confirmed_404_branch + assert 'echo "tag_resume=false" >> "${GITHUB_OUTPUT}"' in confirmed_404_branch + assert 'echo "release_resume=false" >> "${GITHUB_OUTPUT}"' in confirmed_404_branch + assert "exit 0" in confirmed_404_branch + + +def test_tag_lookup_non_404_error_fails_closed_not_treated_as_absent() -> None: + """Devin finding ("API failures block release recovery"): a transient + rate-limit, auth, network, or 5xx failure from the tag lookup must + never be silently treated as 'tag absent' -- it must fail this step + closed (distinct exit 1, after the confirmed-404 short-circuit already + had its chance to `exit 0` first) so a later retry can resolve cleanly + instead of compounding a wrong assumption.""" + step = _tag_state_step(_workflow_text()) + lookup_index = step.index('if ! tag_lookup_output=') + confirmed_404_fi_index = step.index('\n fi\n', lookup_index) + outer_fi_index = step.index('\n fi\n', confirmed_404_fi_index) + fail_closed_branch = step[confirmed_404_fi_index:outer_fi_index] + assert "::error::" in fail_closed_branch + assert "exit 1" in fail_closed_branch + assert "NOT confirmed" in fail_closed_branch + assert "tag_resume=" not in fail_closed_branch + assert "release_resume=" not in fail_closed_branch + + def test_tag_pointing_at_a_different_commit_is_rejected_not_moved() -> None: """A tag that exists but targets a different commit must fail the run outright -- a release tag is never moved onto a new commit.""" @@ -145,20 +200,60 @@ def test_tag_matching_commit_with_existing_release_resumes_to_attach_missing_ass tag and the Release can both already exist while the SBOM asset is still missing. The earlier "nothing left to resume" rejection stranded that release forever (Devin's follow-up finding) -- `publish` must - still get a chance to attempt the best-effort asset attach.""" + still get a chance to attempt the best-effort asset attach. + + `gh release view` succeeding (exit 0) is now the *outer* `else` of a + three-way branch -- see the two tests below for the other two arms + (confirmed absent, and Devin's later "API failures block release + recovery" finding: any other lookup failure fails closed instead of + being treated as absence). Bounded to that outer branch specifically + via its 10-space indentation, which the branch's own inner if/else + (12-space indented) cannot be mistaken for.""" step = _tag_state_step(_workflow_text()) assert 'gh release view "v${RELEASE_VERSION}"' in step release_view_index = step.index('gh release view "v${RELEASE_VERSION}"') - # Bounded to the "release already exists" branch itself (up to its - # `else`), not an arbitrary character count -- the branch's own - # explanatory comment is long enough to overflow a fixed window. - else_index = step.index("\n else\n", release_view_index) - branch = step[release_view_index:else_index] + outer_else_index = step.index("\n else\n", release_view_index) + outer_fi_index = step.index("\n fi\n", outer_else_index) + branch = step[outer_else_index:outer_fi_index] assert "::error::" not in branch assert "exit 1" not in branch assert 'echo "release_resume=true" >> "${GITHUB_OUTPUT}"' in branch +def test_release_lookup_confirmed_absent_resumes_cleanly_without_error() -> None: + """The confirmed-absent inner arm (a genuine 'release not found', or + its raw HTTP-404 rendering as a defensive fallback) must resume + publication cleanly -- no error, no exit 1 -- and must be distinct from + the non-404 fail-closed arm right next to it (Devin finding: "API + failures block release recovery").""" + step = _tag_state_step(_workflow_text()) + release_view_index = step.index('gh release view "v${RELEASE_VERSION}"') + inner_else_index = step.index("\n else\n", release_view_index) + confirmed_absent_branch = step[release_view_index:inner_else_index] + assert 'grep -qiE "release not found|HTTP 404"' in confirmed_absent_branch + assert "::error::" not in confirmed_absent_branch + assert "exit 1" not in confirmed_absent_branch + assert 'echo "release_resume=false" >> "${GITHUB_OUTPUT}"' in confirmed_absent_branch + + +def test_release_lookup_non_404_error_fails_closed_not_treated_as_absent() -> None: + """Devin finding ("API failures block release recovery"): a transient + rate-limit, auth, network, or 5xx failure from `gh release view` must + never be silently treated as 'Release absent' -- only a confirmed + absence may resume; anything else fails this step closed so a later + retry can resolve cleanly instead of compounding a wrong assumption.""" + step = _tag_state_step(_workflow_text()) + release_view_index = step.index('gh release view "v${RELEASE_VERSION}"') + inner_else_index = step.index("\n else\n", release_view_index) + inner_fi_index = step.index("\n fi\n", inner_else_index) + fail_closed_branch = step[inner_else_index:inner_fi_index] + assert "::error::" in fail_closed_branch + assert "exit 1" in fail_closed_branch + assert "NOT confirmed" in fail_closed_branch + assert "release_resume=false" not in fail_closed_branch + assert "release_resume=true" not in fail_closed_branch + + def test_tag_matching_commit_with_no_release_yet_resumes_without_retagging() -> None: """The narrow case -- same commit, no release published yet -- sets `release_resume=false` (still resuming the tag, but the Release itself @@ -223,6 +318,214 @@ def test_release_asset_attach_always_runs_and_is_best_effort() -> None: assert "--clobber" in attach_step +# --- Confirmed-absence vs transient-failure real-execution simulation +# --- (Devin finding: "API failures block release recovery") ---------------- +# +# The tests above prove the *branch structure* of the tag_state step's real +# YAML text. These go further: they execute that exact, unmodified script +# (never a hand-copied stand-in that could silently drift from it) under +# bash, against a stub `gh` selected by GH_STUB_MODE, covering every branch +# end-to-end -- exit code and the actual GITHUB_OUTPUT lines written -- the +# same real-execution technique tests/test_release_workflow_contract.py +# uses for the checks-green gate. + + +def _tag_state_script(workflow: str) -> str: + """Return the tag_state step's real `run: |` script body, dedented. + + Terminates at the first line that is not blank and does not carry this + workflow's fixed 10-space step-script indentation, mirroring YAML's own + block-scalar boundary rule (see the analogous helper's longer rationale + in tests/test_release_workflow_contract.py). + """ + step = _tag_state_step(workflow) + marker = "run: |\n" + body = step[step.index(marker) + len(marker) :] + lines: list[str] = [] + for line in body.splitlines(): + if line.strip() == "": + lines.append("") + continue + if not line.startswith(" " * 10): + break + lines.append(line[10:]) + while lines and lines[-1] == "": + lines.pop() + return "\n".join(lines) + "\n" + + +_STUB_GH_TAG_RELEASE_LOOKUP = """#!/usr/bin/env bash +# Stub gh CLI for hand-simulating the tag_state step's two lookups against +# deliberately-crafted success/confirmed-absence/transient-failure cases, +# selected via GH_STUB_TAG_MODE and GH_STUB_RELEASE_MODE. +tag_mode="${GH_STUB_TAG_MODE:-}" +release_mode="${GH_STUB_RELEASE_MODE:-}" + +if [ "$1" = "api" ]; then + case "${tag_mode}" in + tag_confirmed_404) + echo "gh: No commit found for SHA: v${RELEASE_VERSION} (HTTP 404)" >&2 + exit 1 + ;; + tag_rate_limited) + echo "gh: API rate limit exceeded for user ID 123. (HTTP 403)" >&2 + exit 1 + ;; + tag_network_error) + echo 'gh: Post "https://api.github.com/graphql": dial tcp: lookup api.github.com: no such host' >&2 + exit 1 + ;; + tag_exists) + if printf '%s\\n' "$*" | grep -q -- '--jq'; then + echo "${SIM_GITHUB_SHA}" + else + echo "{\\"sha\\":\\"${SIM_GITHUB_SHA}\\"}" + fi + exit 0 + ;; + *) echo "unhandled stub gh api tag mode: ${tag_mode}" >&2; exit 97 ;; + esac +elif [ "$1" = "release" ] && [ "$2" = "view" ]; then + case "${release_mode}" in + release_confirmed_absent) + echo "release not found" >&2 + exit 1 + ;; + release_rate_limited) + echo "gh: API rate limit exceeded for user ID 123. (HTTP 403)" >&2 + exit 1 + ;; + release_exists) + echo "title: v${RELEASE_VERSION}" + exit 0 + ;; + *) echo "unhandled stub gh release view mode: ${release_mode}" >&2; exit 97 ;; + esac +else + echo "unhandled stub gh invocation: $*" >&2 + exit 98 +fi +""" + + +def _run_tag_state_script( + tmp_path: Path, script: str, *, tag_mode: str, release_mode: str = "" +) -> tuple[subprocess.CompletedProcess[str], dict[str, str]]: + """Execute the real tag_state script against the stub gh above. + + Returns the completed process plus the `GITHUB_OUTPUT` lines actually + written, parsed into a dict (empty if the step exited before writing + any, e.g. the fail-closed branches). + """ + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + gh_stub = bin_dir / "gh" + gh_stub.write_text(_STUB_GH_TAG_RELEASE_LOOKUP, encoding="utf-8") + gh_stub.chmod(0o755) + + script_path = tmp_path / "tag-state.sh" + script_path.write_text(script, encoding="utf-8") + output_path = tmp_path / "github_output" + output_path.write_text("", encoding="utf-8") + + env = dict(os.environ) + env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}" + env["GITHUB_REPOSITORY"] = _SIM_GITHUB_REPOSITORY + env["RELEASE_VERSION"] = _SIM_RELEASE_VERSION + env["GITHUB_SHA"] = _SIM_GITHUB_SHA + env["SIM_GITHUB_SHA"] = _SIM_GITHUB_SHA + env["GITHUB_OUTPUT"] = str(output_path) + env["GH_STUB_TAG_MODE"] = tag_mode + env["GH_STUB_RELEASE_MODE"] = release_mode + + result = subprocess.run( + ["bash", str(script_path)], + env=env, + capture_output=True, + text=True, + check=False, + ) + outputs: dict[str, str] = {} + for line in output_path.read_text(encoding="utf-8").splitlines(): + if "=" in line: + key, _, value = line.partition("=") + outputs[key] = value + return result, outputs + + +def test_simulated_tag_confirmed_404_resumes_as_fresh_publish(tmp_path: Path) -> None: + """End-to-end: a confirmed-404 tag lookup exits 0 with both resume flags + false, never reaching the release-view lookup at all.""" + workflow = _workflow_text() + script = _tag_state_script(workflow) + result, outputs = _run_tag_state_script(tmp_path, script, tag_mode="tag_confirmed_404") + assert result.returncode == 0, result.stderr + assert outputs == {"tag_resume": "false", "release_resume": "false"} + + +def test_simulated_tag_lookup_rate_limited_fails_closed(tmp_path: Path) -> None: + """End-to-end: a 403 rate-limit error from the tag lookup must fail the + step closed (nonzero exit, no outputs written) rather than being read + as tag-absent.""" + workflow = _workflow_text() + script = _tag_state_script(workflow) + result, outputs = _run_tag_state_script(tmp_path, script, tag_mode="tag_rate_limited") + assert result.returncode != 0 + assert "NOT confirmed" in result.stderr + assert outputs == {} + + +def test_simulated_tag_lookup_network_error_fails_closed(tmp_path: Path) -> None: + """End-to-end: a transport-level failure from the tag lookup must also + fail closed, not be misread as tag-absent.""" + workflow = _workflow_text() + script = _tag_state_script(workflow) + result, outputs = _run_tag_state_script(tmp_path, script, tag_mode="tag_network_error") + assert result.returncode != 0 + assert "NOT confirmed" in result.stderr + assert outputs == {} + + +def test_simulated_release_confirmed_absent_resumes_publication(tmp_path: Path) -> None: + """End-to-end: tag exists at this commit, release lookup confirms + absence ('release not found') -- resumes with tag_resume=true, + release_resume=false, no error.""" + workflow = _workflow_text() + script = _tag_state_script(workflow) + result, outputs = _run_tag_state_script( + tmp_path, script, tag_mode="tag_exists", release_mode="release_confirmed_absent" + ) + assert result.returncode == 0, result.stderr + assert outputs == {"tag_resume": "true", "release_resume": "false"} + + +def test_simulated_release_lookup_rate_limited_fails_closed_after_tag_resume(tmp_path: Path) -> None: + """End-to-end: tag exists at this commit (tag_resume=true is safely + written), but the release lookup then hits a transient error -- must + fail closed rather than guessing the Release is absent.""" + workflow = _workflow_text() + script = _tag_state_script(workflow) + result, outputs = _run_tag_state_script( + tmp_path, script, tag_mode="tag_exists", release_mode="release_rate_limited" + ) + assert result.returncode != 0 + assert "NOT confirmed" in result.stderr + assert outputs == {"tag_resume": "true"} + + +def test_simulated_release_already_exists_resumes_asset_attach(tmp_path: Path) -> None: + """End-to-end: both the tag and its Release already exist at this + commit -- the genuine safe-resume path, release_resume=true, no + error.""" + workflow = _workflow_text() + script = _tag_state_script(workflow) + result, outputs = _run_tag_state_script( + tmp_path, script, tag_mode="tag_exists", release_mode="release_exists" + ) + assert result.returncode == 0, result.stderr + assert outputs == {"tag_resume": "true", "release_resume": "true"} + + # --- `actions: read` scoping (Devin finding 2) ------------------------------- From 8a777a1859bde5ba48d350d83544359ab989a39c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 21:16:38 +0000 Subject: [PATCH 17/35] fix(release): tag-only resume evaluates the tag's own commit, not main's tip Devin finding "Tag-only retries mislabel releases": once a tag was pushed but the Release failed to publish, any merge to main before the retry made tag_state's resume check (tag_commit == GITHUB_SHA) see "tag exists, but doesn't match main's current tip" and reject the retry as a conflict, permanently stranding the tag. Fresh publishes must still gate on main's live tip; resumes must not. tag_state now determines a TARGET_SHA up front and threads it through every later gate: - No tag yet -> TARGET_SHA is the dispatch commit (fresh publish; still gated on matching main's current tip). - Tag exists and is an ancestor of (or identical to) main's current tip, via the compare API -> TARGET_SHA is the tag's own target commit (tag-only interrupted publication; main-tip freshness check is skipped, never required to match). - Tag exists but is not an ancestor of main -> still a hard reject; the tag is never moved or reused for a different release. verify checks out TARGET_SHA before running the version check, the test suite, and release-notes rendering, so a resume never validates or publishes content from a newer main commit. publish's final main-tip recheck applies to a fresh publish only. Both jobs' checks-green gate now reads TARGET_SHA and calls one shared script (scripts/ci/ release_checks_gate.sh) instead of two inline copies that could silently drift from each other (CodeRabbit maintainability finding). Also folds in this round's other CodeRabbit findings: - scripts/ci/release_notes.py: read_declared_version now parses real TOML via stdlib tomllib instead of a regex, which mishandled a `[project]` table header with a trailing comment, a single-quoted or comment-suffixed version value, and a multiline string containing a line starting with `[`. New regression tests in test_release_notes.py. - The tag-existence lookup makes one gh api call (stdout+stderr via 2>&1) instead of two, closing a race and halving API/rate-limit cost. - docs/planning/adrs/0129-canonical-immutable-release.md's Gate 3/4 text now matches the actual TOML-table-scoped parsing and remote-only (never local) tag check; docs/RELEASING.md's walkthrough describes the TARGET_SHA-driven fresh-vs-resume split. - tests/test_release_workflow_idempotency_contract.py's stub gh now dispatches on the actual endpoint requested instead of always answering the tag-lookup response. - tests/test_release_workflow_contract.py's expected-check-count assertions are derived from the parsed list length instead of hardcoded literals. Devin's two informational comments on this same PR round ("Optional checks can block releases", "Duplicate checks remain fail-closed") are confirmed-intended, ADR-0129-documented conservative design; no code change for those two. Verification: tests/test_release_notes.py (18, incl. 5 new TOML-edge-case regressions), tests/test_release_workflow_contract.py (29, incl. new shared-script and TARGET_SHA-scoping coverage), tests/ test_release_workflow_idempotency_contract.py (32, incl. new ancestor-resume / non-ancestor-reject / compare-lookup-failure real bash simulations against a stubbed gh) all pass; `python -m interrogate -c pyproject.toml .` 100.0%; every workflow run: block and the new shared script pass `bash -n`; the full YAML parses with PyYAML. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4 --- .github/workflows/release.yml | 292 ++++++++-------- docs/RELEASING.md | 110 +++--- .../adrs/0129-canonical-immutable-release.md | 64 +++- scripts/ci/release_checks_gate.sh | 63 ++++ scripts/ci/release_notes.py | 39 ++- tests/test_release_notes.py | 52 +++ tests/test_release_workflow_contract.py | 212 ++++++------ ...t_release_workflow_idempotency_contract.py | 324 ++++++++++++++---- 8 files changed, 765 insertions(+), 391 deletions(-) create mode 100755 scripts/ci/release_checks_gate.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3abefcb79..4a9b1f616 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,20 +6,44 @@ name: Release # deliberate, maintainer-dispatched action only -- it never runs on push, # schedule, or as a side effect of merging to main. It never re-implements # GitHub branch-protection governance: it verifies the released commit really -# is protected main's current, untampered tip, then re-runs this repository's -# own primary regression gate fresh before cutting anything. +# is (or, for a resume, once really was) protected main's tip, then re-runs +# this repository's own primary regression gate fresh before cutting +# anything. +# +# The very first thing the workflow does (`tag_state`, below) is decide +# which of two situations this dispatch is, and which exact commit +# (TARGET_SHA) every later gate must evaluate against: +# - a FRESH publish (no `vX.Y.Z` tag exists yet) -- TARGET_SHA is this +# dispatch's own commit, GITHUB_SHA, which must equal main's live +# current tip; or +# - a RESUME of a tag-only interrupted publication (the tag already +# exists -- pushed by an earlier run that then failed before creating +# the GitHub Release -- and points at a commit that really was main's +# tip at some point, i.e. an ancestor of main's current tip). TARGET_SHA +# is the tag's own target commit, which main is very likely to have +# advanced past by now -- that is expected, not an error, and every +# later gate (checks-green, version match, release-notes rendering) +# evaluates against TARGET_SHA, never against a possibly-stale +# GITHUB_SHA/current-main value. A tag that exists but points at a +# commit that is *not* an ancestor of main's current tip is a genuine +# conflict and is still rejected outright -- a tag is never moved or +# reused for a different release. # # Two jobs, least-privilege: `verify` runs with no write permission and no # persisted git credentials while it executes repository-controlled code # (the test suite, release-notes rendering) -- a `contents: write` token is # never present alongside that code. `publish` holds the write token, and its -# very first steps re-check protected main's tip and every check reported -# for that exact commit, immediately before tag creation, right after -# `verify`'s potentially long-running gate finishes. That tip/checks -# recheck is still a check-then-act window, not an atomic guarantee -- see -# docs/planning/adrs/0129-canonical-immutable-release.md's "Known -# limitations" section and docs/RELEASING.md for the accepted residual risk -# and the recovery path if it is ever actually hit. +# very first steps re-check (for a fresh publish only -- see above) +# protected main's tip and every check reported for TARGET_SHA, immediately +# before tag creation, right after `verify`'s potentially long-running gate +# finishes. That tip/checks recheck is still a check-then-act window, not an +# atomic guarantee -- see docs/planning/adrs/0129-canonical-immutable-release.md's +# "Known limitations" section and docs/RELEASING.md for the accepted +# residual risk and the recovery path if it is ever actually hit. +# +# The checks-green gate itself is one shared script +# (scripts/ci/release_checks_gate.sh) that both jobs call, rather than two +# inline copies that could silently drift from each other. on: workflow_dispatch: @@ -33,16 +57,16 @@ permissions: contents: read # This repository's own known push-triggered job names (ci.yml, fuzz.yml, -# security.yml) -- shared by both jobs' checks-green gate below so a -# `commits/$SHA/check-runs` report that is empty, or missing one of them, -# is treated as "not ready yet", never as vacuously "nothing to block on". -# A dispatch fired moments after a merge can race GitHub's own registration -# of this new tip's push-triggered check-runs: the API can legitimately -# return an empty or partial list before GitHub has even created the -# entries, and filtering an empty/partial list for "not complete+green" is -# vacuously empty too -- which previously let a release publish before -# Security/Fuzz/CodeQL had even started (Devin finding). Kept in sync with -# those three workflows' job `name:` values by +# security.yml) -- shared by both jobs' checks-green gate (see +# scripts/ci/release_checks_gate.sh) so a `commits/$SHA/check-runs` report +# that is empty, or missing one of them, is treated as "not ready yet", +# never as vacuously "nothing to block on". A dispatch fired moments after a +# merge can race GitHub's own registration of this new tip's push-triggered +# check-runs: the API can legitimately return an empty or partial list +# before GitHub has even created the entries, and filtering an empty/partial +# list for "not complete+green" is vacuously empty too -- which previously +# let a release publish before Security/Fuzz/CodeQL had even started (Devin +# finding). Kept in sync with those three workflows' job `name:` values by # tests/test_release_workflow_contract.py; update this list in the same PR # that adds, removes, or renames a push-triggered job in any of them. env: @@ -67,6 +91,7 @@ jobs: outputs: tag_resume: ${{ steps.tag_state.outputs.tag_resume }} release_resume: ${{ steps.tag_state.outputs.release_resume }} + target_sha: ${{ steps.tag_state.outputs.target_sha }} steps: - name: Checkout protected main (no write credentials) uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 @@ -75,90 +100,18 @@ jobs: fetch-depth: 0 fetch-tags: true - - name: Verify this commit is protected main's current, untampered tip - run: | - set -euo pipefail - remote_head="$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha)" - if [ "${remote_head}" != "${GITHUB_SHA}" ]; then - echo "::error::Dispatched commit ${GITHUB_SHA} is not protected main's current tip (${remote_head}); a merge landed after this run started. Re-dispatch against the fresh tip." >&2 - exit 1 - fi - - - name: Verify every check reported for this commit is complete and green - run: | - set -euo pipefail - checks_pages="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/check-runs?per_page=100" --paginate --slurp)" - # main's own required-check gate already had to pass on the *PR's* - # head SHA before this squash/merge commit could exist -- but - # separate push-triggered workflows (Security, Fuzz, ...) run - # again against this exact new main-tip SHA and can still be - # in flight or have failed. Deliberately checks *every* check-run - # GitHub reports for this SHA (not a ruleset-derived "required" - # subset -- ADR 0129 rejected the ruleset-name lookup specifically - # to avoid needing `administration: read`; checking everything is - # strictly more conservative and only needs `checks: read`). - # Excludes this release run's own check-runs (both verify's and - # publish's) via their shared GITHUB_RUN_ID, or a workflow_dispatch - # would always find itself as an unfinished check and deadlock. - observed_checks="$(echo "${checks_pages}" | jq --arg run_id "${GITHUB_RUN_ID}" ' - [ .[] | .check_runs[]? ] - | map(select((.details_url // "") | contains("/actions/runs/" + $run_id + "/") | not)) - ')" - # Devin finding: a dispatch fired moments after a merge can race - # GitHub's own registration of this new tip's push-triggered - # check-runs, so `observed_checks` above can legitimately be empty - # or partial before those checks exist as entries at all -- - # filtering an empty/partial list for "not complete+green" is - # vacuously empty too, which previously let this gate PASS before - # Security/Fuzz/CodeQL had even started. Close it the same - # conservative, no-ruleset way as the check above: every one of - # RELEASE_EXPECTED_PUSH_CHECKS (this repository's own known - # push-triggered job names) must already be a registered - # check-run for this commit -- absence from a report that has not - # caught up yet is "not ready", never "nothing to block on". - missing_checks="$(echo "${observed_checks}" | jq --argjson expected "${RELEASE_EXPECTED_PUSH_CHECKS}" ' - ($expected - ([.[] | .name] | unique)) - ')" - missing_count="$(echo "${missing_checks}" | jq 'length')" - if [ "${missing_count}" != "0" ]; then - echo "::error::${missing_count} expected push-triggered check(s) for commit ${GITHUB_SHA} have not registered yet (GitHub may still be creating check-runs for this new main tip): $(echo "${missing_checks}" | jq -c .). Wait a few moments and re-dispatch." >&2 - exit 1 - fi - not_ready="$(echo "${observed_checks}" | jq ' - map(select(.status != "completed" or ((.conclusion // "") as $c | (["success","skipped","neutral"] | index($c)) == null))) - ')" - not_ready_count="$(echo "${not_ready}" | jq 'length')" - if [ "${not_ready_count}" != "0" ]; then - echo "::error::${not_ready_count} check(s) for commit ${GITHUB_SHA} are not both complete and green (excluding this release run's own checks): $(echo "${not_ready}" | jq -c 'map({name, status, conclusion})')" >&2 - exit 1 - fi - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Set up uv - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - with: - version: "0.12.5" - - - name: Verify the requested version matches pyproject.toml's [project] table - run: | - set -euo pipefail - declared="$(python -c 'from scripts.ci.release_notes import read_declared_version; print(read_declared_version(open("pyproject.toml", encoding="utf-8").read()))')" - if [ "${declared}" != "${RELEASE_VERSION}" ]; then - echo "::error::pyproject.toml's [project] table declares version '${declared}', but the dispatch input was '${RELEASE_VERSION}'. Land a version-bump PR first; a release never redefines a commit's version." >&2 - exit 1 - fi - - - name: Determine whether the release tag is a fresh publish or a safe resume + - name: Determine whether this is a fresh publish or a resume, and the exact commit to operate on id: tag_state run: | set -euo pipefail # The commits API dereferences an annotated or lightweight tag to # its target commit in one call, and needs no local git fetch or - # push credential -- this step runs with contents: read only. + # push credential -- this step runs with contents: read only. One + # `gh api` call captures both stdout (the SHA, via --jq) and + # stderr (2>&1) together, so a failure never needs a second, + # separate call just to read the error text -- that would double + # the API/rate-limit cost and open a race where state changes + # between the two calls. # # A failed lookup is NOT automatically "tag absent" (Devin # finding): only a confirmed HTTP 404 means that. Any other @@ -170,33 +123,60 @@ jobs: # error wrapper reliably renders as "... (HTTP )" on # stderr; grep for the specific 404 rendering rather than # inferring absence from any nonzero exit. - if ! tag_lookup_output="$(gh api "repos/${GITHUB_REPOSITORY}/commits/v${RELEASE_VERSION}" --jq .sha 2>/dev/null)"; then - tag_lookup_error="$(gh api "repos/${GITHUB_REPOSITORY}/commits/v${RELEASE_VERSION}" 2>&1 >/dev/null || true)" - if printf '%s' "${tag_lookup_error}" | grep -q "HTTP 404"; then + if ! tag_lookup_output="$(gh api "repos/${GITHUB_REPOSITORY}/commits/v${RELEASE_VERSION}" --jq .sha 2>&1)"; then + if printf '%s' "${tag_lookup_output}" | grep -q "HTTP 404"; then echo "::notice::Tag v${RELEASE_VERSION} does not exist yet (confirmed 404); this is a fresh publish." echo "tag_resume=false" >> "${GITHUB_OUTPUT}" echo "release_resume=false" >> "${GITHUB_OUTPUT}" + echo "target_sha=${GITHUB_SHA}" >> "${GITHUB_OUTPUT}" + echo "TARGET_SHA=${GITHUB_SHA}" >> "${GITHUB_ENV}" exit 0 fi - echo "::error::Could not determine whether tag v${RELEASE_VERSION} already exists: the lookup failed with an error other than a confirmed 404, so absence is NOT confirmed -- a transient rate-limit/auth/network/5xx failure must never be treated as 'safe to create'. Failing closed; re-dispatch to retry once the transient failure clears. Raw error: ${tag_lookup_error}" >&2 + echo "::error::Could not determine whether tag v${RELEASE_VERSION} already exists: the lookup failed with an error other than a confirmed 404, so absence is NOT confirmed -- a transient rate-limit/auth/network/5xx failure must never be treated as 'safe to create'. Failing closed; re-dispatch to retry once the transient failure clears. Raw error: ${tag_lookup_output}" >&2 exit 1 fi tag_commit="${tag_lookup_output}" if [ "${tag_commit}" != "${GITHUB_SHA}" ]; then - echo "::error::Tag v${RELEASE_VERSION} already exists and points at ${tag_commit}, not this run's commit (${GITHUB_SHA}); a release tag is never moved onto a different commit. Publish a new version instead." >&2 - exit 1 + # main may simply have advanced past the tag's target commit + # since it was pushed -- a tag-only interrupted publication (the + # tag was pushed by an earlier run that then failed before ever + # creating the GitHub Release, and one or more merges landed on + # main before this retry). That is a safe, legitimate resume, + # not a conflict: distinguish it from a genuine conflict -- a + # tag that points at a commit which was never on main's history + # at all (e.g. a stray or wrong tag) -- by checking whether the + # tag's target commit is an ancestor of (or equal to) main's own + # current live tip via the compare API. Only a commit that + # really was main's history at some point is safe to finish + # publishing; the tag itself is still never moved or reused for + # a different commit. Any failure of this lookup itself (rate + # limit, network, 5xx) fails the whole step closed via `set -e` + # above, the same fail-closed default every other lookup in + # this step handles explicitly. + compare_status="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${tag_commit}...main" --jq .status)" + case "${compare_status}" in + identical|ahead) + echo "::notice::Tag v${RELEASE_VERSION} already exists and points at ${tag_commit}, an ancestor of main's current tip; main has advanced since the tag was pushed (a tag-only interrupted publication). Resuming publication for the tagged commit -- never moving or reusing the tag for a different one." + ;; + *) + echo "::error::Tag v${RELEASE_VERSION} already exists and points at ${tag_commit}, which is not an ancestor of main's current tip (compare status: ${compare_status}); a release tag is never moved onto a different commit. Publish a new version instead." >&2 + exit 1 + ;; + esac fi + target_sha="${tag_commit}" echo "tag_resume=true" >> "${GITHUB_OUTPUT}" + echo "target_sha=${target_sha}" >> "${GITHUB_OUTPUT}" + echo "TARGET_SHA=${target_sha}" >> "${GITHUB_ENV}" # A published Release already existing does not mean nothing is # left to resume: creating a Release can publish the Release # object and then fail partway through uploading its assets, in # which case the tag and the Release both already exist but a - # release asset (the SBOM) can still be missing. Reject only a - # tag pointing at a different commit (above); a Release already - # existing for *this* commit is a safe resume that still + # release asset (the SBOM) can still be missing. A Release already + # existing for TARGET_SHA's tag is a safe resume that still # attempts the best-effort asset attach in `publish`, never a # reject -- see Devin's follow-up finding on this PR. # @@ -210,17 +190,67 @@ jobs: # against an unknown state. if ! release_view_error="$(gh release view "v${RELEASE_VERSION}" --repo "${GITHUB_REPOSITORY}" 2>&1 >/dev/null)"; then if printf '%s' "${release_view_error}" | grep -qiE "release not found|HTTP 404"; then - echo "::notice::Tag v${RELEASE_VERSION} already points at this commit with no published GitHub Release yet (confirmed absent); resuming publication instead of re-tagging." + echo "::notice::Tag v${RELEASE_VERSION} already points at ${target_sha} with no published GitHub Release yet (confirmed absent); resuming publication instead of re-tagging." echo "release_resume=false" >> "${GITHUB_OUTPUT}" else echo "::error::Could not determine whether a GitHub Release already exists for v${RELEASE_VERSION}: gh release view failed with an error other than a confirmed absence, so absence is NOT confirmed -- a transient rate-limit/auth/network/5xx failure must never be treated as 'safe to create'. Failing closed; re-dispatch to retry once the transient failure clears. Raw error: ${release_view_error}" >&2 exit 1 fi else - echo "::notice::Tag v${RELEASE_VERSION} already points at this commit and its GitHub Release already exists; resuming to attach any still-missing release assets rather than treating this as nothing left to resume." + echo "::notice::Tag v${RELEASE_VERSION} already points at ${target_sha} and its GitHub Release already exists; resuming to attach any still-missing release assets rather than treating this as nothing left to resume." echo "release_resume=true" >> "${GITHUB_OUTPUT}" fi + - name: Check out the exact commit this run will operate on + run: | + set -euo pipefail + # A fresh publish's TARGET_SHA already equals the checked-out + # GITHUB_SHA, so this is a no-op switch. A resume's TARGET_SHA is + # an older commit than GITHUB_SHA (main has advanced) -- the + # tag_state step above confirmed it is an ancestor of main's + # current tip, so it is guaranteed to already be present in this + # checkout's full history (fetch-depth: 0). Every later step in + # this job (the version check, the test suite, release-notes + # rendering) must read TARGET_SHA's own tree, never GITHUB_SHA's, + # so a resume never renders notes or validates a version against + # commits that landed on main after the tag was pushed. + git checkout "${TARGET_SHA}" + + - name: Verify this commit is protected main's current, untampered tip (fresh publish only) + run: | + set -euo pipefail + if [ "${TARGET_SHA}" != "${GITHUB_SHA}" ]; then + echo "::notice::Resuming an already-tagged commit (${TARGET_SHA}); main's current tip is not required to match it for a resume -- skipping the fresh-publish freshness check." + exit 0 + fi + remote_head="$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha)" + if [ "${remote_head}" != "${GITHUB_SHA}" ]; then + echo "::error::Dispatched commit ${GITHUB_SHA} is not protected main's current tip (${remote_head}); a merge landed after this run started. Re-dispatch against the fresh tip." >&2 + exit 1 + fi + + - name: Verify every check reported for this commit is complete and green + run: bash scripts/ci/release_checks_gate.sh + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + version: "0.12.5" + + - name: Verify the requested version matches pyproject.toml's [project] table + run: | + set -euo pipefail + declared="$(python -c 'from scripts.ci.release_notes import read_declared_version; print(read_declared_version(open("pyproject.toml", encoding="utf-8").read()))')" + if [ "${declared}" != "${RELEASE_VERSION}" ]; then + echo "::error::pyproject.toml's [project] table at commit ${TARGET_SHA} declares version '${declared}', but the dispatch input was '${RELEASE_VERSION}'. Land a version-bump PR first; a release never redefines a commit's version." >&2 + exit 1 + fi + - name: Run the full required test suite fresh on this exact commit run: uv run --locked --extra api --extra db --extra queue --group dev python -m pytest -q @@ -232,15 +262,15 @@ jobs: --changelog CHANGELOG.md \ --version "${RELEASE_VERSION}" \ --repository "${GITHUB_REPOSITORY}" \ - --commit-sha "${GITHUB_SHA}" \ + --commit-sha "${TARGET_SHA}" \ --output release-notes.md - name: Fetch the CycloneDX SBOM for this commit, best-effort run: | set -uo pipefail run_id="" - if ! run_id="$(gh run list --repo "${GITHUB_REPOSITORY}" --workflow security.yml --commit "${GITHUB_SHA}" --status success --json databaseId --jq '.[0].databaseId // empty')"; then - echo "::warning::Could not query Security workflow runs for commit ${GITHUB_SHA} (permission or API error); publishing without an SBOM asset." + if ! run_id="$(gh run list --repo "${GITHUB_REPOSITORY}" --workflow security.yml --commit "${TARGET_SHA}" --status success --json databaseId --jq '.[0].databaseId // empty')"; then + echo "::warning::Could not query Security workflow runs for commit ${TARGET_SHA} (permission or API error); publishing without an SBOM asset." run_id="" fi if [ -n "${run_id}" ]; then @@ -248,7 +278,7 @@ jobs: echo "::warning::Security workflow run ${run_id} has no downloadable cyclonedx-sbom artifact; publishing without it." fi else - echo "::warning::No successful Security workflow run found for commit ${GITHUB_SHA}; publishing without an SBOM asset." + echo "::warning::No successful Security workflow run found for commit ${TARGET_SHA}; publishing without an SBOM asset." fi - name: Upload rendered notes and SBOM for the publish job @@ -274,17 +304,23 @@ jobs: RELEASE_VERSION: ${{ inputs.version }} TAG_RESUME: ${{ needs.verify.outputs.tag_resume }} RELEASE_RESUME: ${{ needs.verify.outputs.release_resume }} + TARGET_SHA: ${{ needs.verify.outputs.target_sha }} steps: - name: Checkout protected main uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 with: + ref: ${{ needs.verify.outputs.target_sha }} persist-credentials: true fetch-depth: 0 fetch-tags: true - - name: Re-verify protected main has not advanced since verification started + - name: Re-verify protected main has not advanced since verification started (fresh publish only) run: | set -euo pipefail + if [ "${TARGET_SHA}" != "${GITHUB_SHA}" ]; then + echo "::notice::Resuming an already-tagged commit (${TARGET_SHA}); no current-main-tip recheck applies to a resume." + exit 0 + fi remote_head="$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha)" if [ "${remote_head}" != "${GITHUB_SHA}" ]; then echo "::error::Protected main advanced to ${remote_head} while the verify job was testing and rendering notes for ${GITHUB_SHA}; refusing to publish a now-superseded commit as the latest release. Re-dispatch against the fresh tip." >&2 @@ -292,33 +328,7 @@ jobs: fi - name: Re-verify every check reported for this commit is complete and green - run: | - set -euo pipefail - checks_pages="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/check-runs?per_page=100" --paginate --slurp)" - observed_checks="$(echo "${checks_pages}" | jq --arg run_id "${GITHUB_RUN_ID}" ' - [ .[] | .check_runs[]? ] - | map(select((.details_url // "") | contains("/actions/runs/" + $run_id + "/") | not)) - ')" - # Same expected-checks cross-reference as verify's gate -- see the - # comment there and on RELEASE_EXPECTED_PUSH_CHECKS at the top of - # this file. Re-checked here too since this recheck must be just - # as authoritative as the first one, not a weaker echo of it. - missing_checks="$(echo "${observed_checks}" | jq --argjson expected "${RELEASE_EXPECTED_PUSH_CHECKS}" ' - ($expected - ([.[] | .name] | unique)) - ')" - missing_count="$(echo "${missing_checks}" | jq 'length')" - if [ "${missing_count}" != "0" ]; then - echo "::error::${missing_count} expected push-triggered check(s) for commit ${GITHUB_SHA} have not registered yet (GitHub may still be creating check-runs for this new main tip): $(echo "${missing_checks}" | jq -c .). Wait a few moments and re-dispatch." >&2 - exit 1 - fi - not_ready="$(echo "${observed_checks}" | jq ' - map(select(.status != "completed" or ((.conclusion // "") as $c | (["success","skipped","neutral"] | index($c)) == null))) - ')" - not_ready_count="$(echo "${not_ready}" | jq 'length')" - if [ "${not_ready_count}" != "0" ]; then - echo "::error::${not_ready_count} check(s) for commit ${GITHUB_SHA} are not both complete and green (excluding this release run's own checks): $(echo "${not_ready}" | jq -c 'map({name, status, conclusion})')" >&2 - exit 1 - fi + run: bash scripts/ci/release_checks_gate.sh - name: Download the release notes and SBOM produced by verify uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # actions/download-artifact@v5 diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 9d7868905..0a056b373 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -36,15 +36,22 @@ that separate system. through the normal PR process (review, required checks, no exceptions). 2. `CHANGELOG.md` has a `## [X.Y.Z]` section (an `- Unreleased` or dated suffix is fine) with real, non-empty content describing what changed. -3. Either no git tag `vX.Y.Z` exists yet, or one does but points at the exact - commit you're dispatching — in which case the workflow safely resumes - instead of re-tagging: if that tag has no GitHub Release published yet, - it creates one; if the Release also already exists (e.g. a prior run's - asset-upload step failed after `gh release create` itself succeeded), it - still attempts the best-effort SBOM asset attach rather than treating the - run as nothing left to do — see step 4 below. A tag pointing at any - *other* commit is rejected outright: a tag is never reused or moved onto - a different commit — bump the version again if you need to re-release. +3. Either no git tag `vX.Y.Z` exists yet, or one does and points at a commit + that is an ancestor of `main`'s current tip (the commit you're + dispatching, or an earlier one `main` has since advanced past) — in + which case the workflow safely resumes using **the tag's own target + commit**, never the commit you happen to be dispatching against: if that + tag has no GitHub Release published yet, it creates one; if the Release + also already exists (e.g. a prior run's asset-upload step failed after + `gh release create` itself succeeded), it still attempts the best-effort + SBOM asset attach rather than treating the run as nothing left to do — + see step 4 below. This is exactly the "tag pushed, then the run failed + before creating the Release, and more commits merged to `main` before + you retried" scenario — it stays recoverable no matter how far `main` + has advanced since the tag was pushed. A tag pointing at a commit that is + **not** an ancestor of `main`'s current tip (a genuinely different or + stray tag) is rejected outright: a tag is never reused or moved onto a + different commit — bump the version again if you need to re-release. 4. `main` is currently green — its own required checks (Tests, Security, Fuzz, and the org-central Strix/OpenCode/security-scan/OSV/Scorecard checks from `ContextualWisdomLab/.github`) are passing. The release @@ -75,47 +82,58 @@ that separate system. repository-controlled code; `publish` holds the write token and does nothing but tag and publish. In order: - **`verify`** (read-only): - - fails closed if the dispatched commit is not `main`'s current tip (a - race with a concurrent merge); - - fails closed unless every one of this repository's own known - push-triggered checks (Tests' two jobs, Fuzz's two jobs, Security's - two jobs — see `RELEASE_EXPECTED_PUSH_CHECKS` in `release.yml`) has - actually registered as a check-run for this exact commit *and* every - check GitHub reports for it is complete with a successful, skipped, - or neutral conclusion (a push-triggered workflow — Security, Fuzz, - ... — not yet registered, still running, or having failed on this - commit); - - fails closed if the input version does not match `pyproject.toml`'s - `[project]` table; - - resolves any existing `vX.Y.Z` tag via the commit API: fails closed - only if it points at a *different* commit; a tag at this commit - proceeds as a resume (fresh publish, tag-only resume, or full - release-and-asset resume — see step 3 above). A failed tag or - Release lookup is treated as "absent" only on a *confirmed* 404 / + - first resolves any existing `vX.Y.Z` tag via the commit API and + decides `TARGET_SHA` — the exact commit every later gate evaluates + against. No tag yet: `TARGET_SHA` is the dispatched commit itself + (a fresh publish). A tag that exists and is an ancestor of `main`'s + current tip: `TARGET_SHA` is the *tag's own target commit* (a + resume — see step 3 above), regardless of how far `main` has since + advanced. A tag that exists and is **not** an ancestor of `main`'s + current tip fails closed outright (a genuine conflict). A failed tag + or Release lookup is treated as "absent" only on a *confirmed* 404 / "release not found"; any other lookup failure (rate limit, auth, - network, 5xx) fails this step closed instead of guessing — re-dispatch - once the transient failure clears; - - runs the full test suite fresh (`uv run --locked --extra api --extra - db --extra queue --group dev python -m pytest -q`); - - renders release notes from `CHANGELOG.md`'s matching section - (`scripts/ci/release_notes.py`, tested in + network, 5xx) fails this step closed instead of guessing — + re-dispatch once the transient failure clears; + - checks out `TARGET_SHA` so every subsequent step in this job reads + *that* commit's tree, never a possibly-newer `main` tip; + - for a **fresh publish only**, fails closed if `TARGET_SHA` is not + `main`'s current tip (a race with a concurrent merge) — a resume + skips this comparison entirely, since `main` having advanced past + the tag's target commit is exactly the situation a resume exists to + handle; + - fails closed (via the shared `scripts/ci/release_checks_gate.sh`) + unless every one of this repository's own known push-triggered + checks (Tests' two jobs, Fuzz's two jobs, Security's two jobs — see + `RELEASE_EXPECTED_PUSH_CHECKS` in `release.yml`) has actually + registered as a check-run for `TARGET_SHA` *and* every check GitHub + reports for it is complete with a successful, skipped, or neutral + conclusion; + - fails closed if the input version does not match `TARGET_SHA`'s + `pyproject.toml` `[project]` table; + - runs the full test suite fresh, on `TARGET_SHA`'s tree (`uv run + --locked --extra api --extra db --extra queue --group dev python -m + pytest -q`); + - renders release notes from `TARGET_SHA`'s `CHANGELOG.md` matching + section (`scripts/ci/release_notes.py`, tested in `tests/test_release_notes.py`); - best-effort looks up and downloads the CycloneDX SBOM from the - matching successful `security.yml` run for this commit, if one exists - (a missing SBOM, or a failed lookup, warns — it never blocks the - release); + matching successful `security.yml` run for `TARGET_SHA`, if one + exists (a missing SBOM, or a failed lookup, warns — it never blocks + the release); - uploads the rendered notes and any SBOM for `publish` to pick up. - **`publish`** (write-scoped, only after `verify` succeeds): - - re-verifies `main`'s tip has not advanced, and every check for this - commit is still complete and green, while `verify` was testing and - rendering notes (a second, authoritative recheck of both, right - before anything is created — see "Known limitations" below for the - small residual window this still leaves); + - for a **fresh publish only**, re-verifies `main`'s tip has not + advanced while `verify` was testing and rendering notes (a resume + skips this — the tagged commit is already immutable, so there is + nothing for a live main-tip comparison to protect against); either + way, re-verifies (via the same shared script) that every check for + `TARGET_SHA` is still complete and green — a second, authoritative + recheck right before anything is created (see "Known limitations" + below for the small residual window a fresh publish still leaves); - creates and pushes an annotated tag `vX.Y.Z` — skipped when resuming - a run whose tag already exists at this commit; + a run whose tag already exists; - creates the GitHub Release using the notes `verify` produced — - skipped when resuming a run whose Release already exists at this - commit; + skipped when resuming a run whose Release already exists; - attempts the best-effort SBOM asset attach, whether the Release was just created or already existed — a failure here warns and never blocks (re-dispatch to retry the attach). @@ -134,6 +152,12 @@ that separate system. ## Known limitations +**This section describes a fresh publish only.** A resume of a tag-only +interrupted publication evaluates every gate against the tag's own target +commit, which is already immutable once pushed — there is no live +`main`-tip comparison to race for a resume, so the window below does not +apply to it. + **A small, accepted check-then-act window remains before the tag/Release are actually created.** `publish`'s recheck of `main`'s tip and of every check for that commit is the very first thing it does, back-to-back, before diff --git a/docs/planning/adrs/0129-canonical-immutable-release.md b/docs/planning/adrs/0129-canonical-immutable-release.md index 23afd4e08..082d35694 100644 --- a/docs/planning/adrs/0129-canonical-immutable-release.md +++ b/docs/planning/adrs/0129-canonical-immutable-release.md @@ -184,23 +184,35 @@ Before any tag or Release is created, the release job: correctly "not ready", never "nothing to block on". This is still the same no-ruleset, `checks: read`-only approach: a fixed, repository-owned name list, not a call to the branch-ruleset API. -3. Parses `pyproject.toml`'s `version = "..."` and fails closed unless it is - byte-for-byte equal to the `version` input. A release never redefines what - version a commit is; the version bump is a normal, already-reviewed PR - that must land first. -4. Fails closed if git tag `v${version}` already exists locally or on the - remote — an existing tag is never moved, deleted, or overwritten - (immutability) — except a tag/Release resume at this exact commit, a safe - retry of a previously-interrupted run (see `docs/RELEASING.md`). Both the - tag-existence and Release-existence lookups distinguish a *confirmed* - absence (an HTTP 404 from the commits API; a "release not found" from - `gh release view`) from every other lookup failure — rate limit, auth, - network blip, a GitHub 5xx (a later Devin finding, "API failures block - release recovery"). Only a confirmed absence proceeds as a fresh - publish or resume; any other failure fails the step closed instead of - guessing "absent" and risking a wrong create attempt against unconfirmed - state — a later dispatch then retries and resolves cleanly once the - transient failure clears. +3. Parses `pyproject.toml`'s `[project]` table (via stdlib `tomllib`, a real + TOML parser — not a regex scan, which cannot reliably distinguish a + `[project]` table header followed by a trailing comment, a single- or + double-quoted version value, or a multiline string containing a line + that starts with `[`, from a genuine table boundary or version field) + and fails closed unless its `version` is byte-for-byte equal to the + `version` input. A release never redefines what version a commit is; the + version bump is a normal, already-reviewed PR that must land first. +4. Fails closed if tag `v${version}` already exists on the remote (queried + via the commits API, never a local tag check) and points at a commit + that is *not* an ancestor of protected `main`'s current tip — an + existing tag is never moved, deleted, or overwritten (immutability), and + is never reused for a different release. A tag that *is* an ancestor of + `main`'s current tip (identical to it, or main has since advanced past + it) is a tag-only interrupted publication — the tag was pushed by an + earlier run that then failed before ever creating the GitHub Release — + and resumes safely: every later gate (checks-green, this version check, + release-notes rendering) evaluates against the *tag's own target + commit*, never against a possibly-stale `GITHUB_SHA`/current-`main` + value (a later Devin finding, "Tag-only retries mislabel releases" — see + `docs/RELEASING.md`). Both the tag-existence and Release-existence + lookups distinguish a *confirmed* absence (an HTTP 404 from the commits + API; a "release not found" from `gh release view`) from every other + lookup failure — rate limit, auth, network blip, a GitHub 5xx (a later + Devin finding, "API failures block release recovery"). Only a confirmed + absence proceeds as a fresh publish or resume; any other failure fails + the step closed instead of guessing "absent" and risking a wrong create + attempt against unconfirmed state — a later dispatch then retries and + resolves cleanly once the transient failure clears. 5. Runs this repository's own full test suite fresh, on the exact commit about to be tagged (`uv run --locked --extra api --extra db --extra queue --group dev python -m pytest -q`, the same invocation `ci.yml`'s "Full @@ -309,6 +321,21 @@ this mechanism implements, not an academic literature review: ## Known limitations +### Resume evaluates against the tag's own target commit, not a fresh publish's main-tip check + +The main-tip freshness recheck described below applies only to a **fresh** +publish (no `v${version}` tag exists yet), where `TARGET_SHA` is this +dispatch's own commit and must still equal `main`'s live current tip. A +**resume** of a tag-only interrupted publication — the tag already exists +and points at a commit that is an ancestor of `main`'s current tip — +deliberately skips that comparison instead of failing it: `main` having +advanced past the tag's target commit is the expected, common case a +resume exists to handle (a later Devin finding, "Tag-only retries mislabel +releases"), not evidence of staleness. The tagged commit is already +immutable once pushed, so there is nothing for a live main-tip comparison +to protect against for a resume; the checks-green gate still re-runs +against `TARGET_SHA` itself in both cases. + ### Residual check-then-act window before the tag/Release are created Devin flagged this gate's re-verification (main's tip, then every check for @@ -317,7 +344,8 @@ immediately before anything is created) as still leaving a race: if `main` advances, or a check regresses, in the moments between that recheck and the actual `git push origin refs/tags/...` / `gh release create`, the workflow would still publish the commit that *was* the verified, all-green tip -moments earlier. +moments earlier. This window applies to a fresh publish only — see the +resume note directly above. This is real, and it is also an inherent limitation of any check-then-act sequence against an API with no atomic "create this tag only if branch `X` diff --git a/scripts/ci/release_checks_gate.sh b/scripts/ci/release_checks_gate.sh new file mode 100755 index 000000000..e1a610d28 --- /dev/null +++ b/scripts/ci/release_checks_gate.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Shared checks-green gate for .github/workflows/release.yml's `verify` and +# `publish` jobs. Extracted into one file so the fail-fast gate in `verify` +# and the authoritative recheck in `publish` can never silently drift from +# each other -- they previously duplicated this exact jq filter inline in +# two places in the workflow YAML, and a fix landed in one copy without the +# other would have gone unnoticed (CodeRabbit maintainability finding). +# +# Fails closed unless every one of this repository's known push-triggered +# checks (RELEASE_EXPECTED_PUSH_CHECKS) has already registered as a +# check-run for TARGET_SHA, and every check GitHub reports for TARGET_SHA +# (excluding this release run's own, via GITHUB_RUN_ID) is complete with an +# acceptable conclusion. +# +# Required env: GITHUB_REPOSITORY, TARGET_SHA, GITHUB_RUN_ID, +# RELEASE_EXPECTED_PUSH_CHECKS. Needs `checks: read` (for the `gh api` call) +# and `gh` + `jq` on PATH -- both already present on GitHub-hosted runners. +set -euo pipefail + +checks_pages="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${TARGET_SHA}/check-runs?per_page=100" --paginate --slurp)" +# main's own required-check gate already had to pass on the *PR's* head SHA +# before this squash/merge commit could exist -- but separate +# push-triggered workflows (Security, Fuzz, ...) run again against this +# exact commit and can still be in flight or have failed. Deliberately +# checks *every* check-run GitHub reports for this SHA (not a +# ruleset-derived "required" subset -- ADR 0129 rejected the ruleset-name +# lookup specifically to avoid needing `administration: read`; checking +# everything is strictly more conservative and only needs `checks: read`). +# Excludes this release run's own check-runs (both verify's and publish's) +# via their shared GITHUB_RUN_ID, or a workflow_dispatch would always find +# itself as an unfinished check and deadlock. +observed_checks="$(echo "${checks_pages}" | jq --arg run_id "${GITHUB_RUN_ID}" ' + [ .[] | .check_runs[]? ] + | map(select((.details_url // "") | contains("/actions/runs/" + $run_id + "/") | not)) +')" +# A dispatch fired moments after a merge -- or a resume shortly after an +# older TARGET_SHA's checks first registered -- can race GitHub's own +# registration of that commit's push-triggered check-runs, so +# `observed_checks` above can legitimately be empty or partial before those +# checks exist as entries at all -- filtering an empty/partial list for +# "not complete+green" is vacuously empty too, which previously let this +# gate PASS before Security/Fuzz/CodeQL had even started. Close it the same +# conservative, no-ruleset way as above: every one of +# RELEASE_EXPECTED_PUSH_CHECKS (this repository's own known push-triggered +# job names) must already be a registered check-run for TARGET_SHA -- +# absence from a report that has not caught up yet is "not ready", never +# "nothing to block on". +missing_checks="$(echo "${observed_checks}" | jq --argjson expected "${RELEASE_EXPECTED_PUSH_CHECKS}" ' + ($expected - ([.[] | .name] | unique)) +')" +missing_count="$(echo "${missing_checks}" | jq 'length')" +if [ "${missing_count}" != "0" ]; then + echo "::error::${missing_count} expected push-triggered check(s) for commit ${TARGET_SHA} have not registered yet (GitHub may still be creating check-runs for this commit): $(echo "${missing_checks}" | jq -c .). Wait a few moments and re-dispatch." >&2 + exit 1 +fi +not_ready="$(echo "${observed_checks}" | jq ' + map(select(.status != "completed" or ((.conclusion // "") as $c | (["success","skipped","neutral"] | index($c)) == null))) +')" +not_ready_count="$(echo "${not_ready}" | jq 'length')" +if [ "${not_ready_count}" != "0" ]; then + echo "::error::${not_ready_count} check(s) for commit ${TARGET_SHA} are not both complete and green (excluding this release run's own checks): $(echo "${not_ready}" | jq -c 'map({name, status, conclusion})')" >&2 + exit 1 +fi diff --git a/scripts/ci/release_notes.py b/scripts/ci/release_notes.py index 5c8636ff9..1b4e72b0c 100644 --- a/scripts/ci/release_notes.py +++ b/scripts/ci/release_notes.py @@ -13,38 +13,39 @@ import argparse import re import sys +import tomllib from pathlib import Path -_PROJECT_TABLE_HEADER_PATTERN = re.compile(r"(?m)^\[project\]\s*$") -_TABLE_HEADER_PATTERN = re.compile(r"(?m)^\[") -_VERSION_FIELD_PATTERN = re.compile(r'(?m)^version\s*=\s*"([^"]+)"\s*$') _HEADING_PATTERN = re.compile(r"(?m)^## \[(?P[^\]]+)\][^\n]*$") def read_declared_version(pyproject_text: str) -> str: """Return the `[project]` table's `version` declared in *pyproject_text*. - Only a `version = "..."` line that appears within the `[project]` - table's own body is honored -- the search is bounded to the span between - the `[project]` header and the next `[...]` table header (or end of - file), so a same-named `version` key under an unrelated table (e.g. - `[tool.some_tool]`) declared earlier in the file is never mistaken for - the project's real version. Raises ``ValueError`` when there is no - `[project]` table, or it has no `version = "..."` line, rather than + Parses *pyproject_text* as real TOML (stdlib `tomllib`, available since + this repository's CI/workflow toolchain is pinned to Python 3.12) rather + than scanning for a `version = "..."` line with a regex. A regex cannot + correctly distinguish a `[project]` table header followed by a trailing + comment, a single-quoted or comment-suffixed version value, or a + multiline string value that happens to contain a line starting with + `[`, from a genuine table boundary or version field -- a real parser + handles all of those the same way a TOML-consuming tool would. Raises + ``ValueError`` when *pyproject_text* is not valid TOML, there is no + `[project]` table, or it has no string `version` field, rather than guessing a version from a tag, a changelog heading, or any other inferred source. """ - header_match = _PROJECT_TABLE_HEADER_PATTERN.search(pyproject_text) - if header_match is None: + try: + document = tomllib.loads(pyproject_text) + except tomllib.TOMLDecodeError as exc: + raise ValueError(f"pyproject.toml is not valid TOML: {exc}") from exc + project_table = document.get("project") + if not isinstance(project_table, dict): raise ValueError("pyproject.toml has no [project] table") - body_start = header_match.end() - next_header_match = _TABLE_HEADER_PATTERN.search(pyproject_text, body_start) - body_end = next_header_match.start() if next_header_match else len(pyproject_text) - project_body = pyproject_text[body_start:body_end] - match = _VERSION_FIELD_PATTERN.search(project_body) - if match is None: + version = project_table.get("version") + if not isinstance(version, str) or not version: raise ValueError("pyproject.toml [project] table has no version field") - return match.group(1) + return version def extract_changelog_section(changelog_text: str, version: str) -> str: diff --git a/tests/test_release_notes.py b/tests/test_release_notes.py index 7ce906631..a47106458 100644 --- a/tests/test_release_notes.py +++ b/tests/test_release_notes.py @@ -95,6 +95,58 @@ def test_read_declared_version_ignores_a_same_named_key_in_a_later_table() -> No assert module.read_declared_version(pyproject) == "0.2.0" +def test_read_declared_version_tolerates_a_trailing_comment_on_the_project_header() -> None: + """`[project] # comment` is valid TOML; a regex anchored on `^\\[project\\]\\s*$` + would reject it as "no [project] table" (CodeRabbit finding: TOML parsed + with regex instead of a real parser).""" + module = _module() + pyproject = '[project] # the project table\nname = "x"\nversion = "0.2.0"\n' + assert module.read_declared_version(pyproject) == "0.2.0" + + +def test_read_declared_version_accepts_a_single_quoted_version() -> None: + """TOML allows single-quoted (literal) strings too, not just double + quotes -- a regex anchored on `"([^"]+)"` would miss this.""" + module = _module() + pyproject = "[project]\nname = 'x'\nversion = '0.2.0'\n" + assert module.read_declared_version(pyproject) == "0.2.0" + + +def test_read_declared_version_tolerates_a_trailing_comment_on_the_version_line() -> None: + """`version = "0.2.0" # released` is valid TOML; a regex requiring + `\\s*$` right after the closing quote would reject it.""" + module = _module() + pyproject = '[project]\nname = "x"\nversion = "0.2.0" # released\n' + assert module.read_declared_version(pyproject) == "0.2.0" + + +def test_read_declared_version_is_not_confused_by_a_multiline_string_containing_a_bracket_line() -> None: + """A multiline string value whose body has a line starting with `[` must + never be mistaken for the next table's header -- a regex scanning for + `^\\[` to find the `[project]` table's own boundary would truncate the + table body before `version` and wrongly report it missing.""" + module = _module() + pyproject = ( + "[project]\n" + 'name = "x"\n' + 'description = """\n' + "Some text.\n" + "[not a real table]\n" + "More text.\n" + '"""\n' + 'version = "0.2.0"\n' + ) + assert module.read_declared_version(pyproject) == "0.2.0" + + +def test_read_declared_version_rejects_invalid_toml() -> None: + """Text that is not valid TOML at all must fail closed with a clear + message, not raise an unrelated parser exception.""" + module = _module() + with pytest.raises(ValueError, match="not valid TOML"): + module.read_declared_version("[project\nversion = \n") + + def test_extract_changelog_section_returns_only_the_matching_version_body() -> None: """Only the body between the matching heading and the next heading is returned.""" module = _module() diff --git a/tests/test_release_workflow_contract.py b/tests/test_release_workflow_contract.py index 551600eaa..81262f815 100644 --- a/tests/test_release_workflow_contract.py +++ b/tests/test_release_workflow_contract.py @@ -34,6 +34,7 @@ REPOSITORY_ROOT = Path(__file__).resolve().parents[1] _WORKFLOW_PATH = REPOSITORY_ROOT / ".github/workflows/release.yml" +_CHECKS_GATE_SCRIPT_PATH = REPOSITORY_ROOT / "scripts/ci/release_checks_gate.sh" _JOB_NAMES = ("verify", "publish") @@ -82,39 +83,6 @@ def _step_names(block: str) -> list[str]: ] -def _step_script(block: str, step_heading: str) -> str: - """Return one step's real `run: |` script body, dedented to shell text. - - Located by `step_heading` within `block` (matching this file's existing - index-bounded step-scoping convention), then sliced past the `run: |` - marker. Terminates at the first line that is not blank and does not - carry this workflow's fixed 10-space step-script indentation -- the - same block-scalar boundary rule YAML itself uses -- rather than a - second heading string, which a step name can appear inside of - mid-line (e.g. as a trailing substring of the *next* step's own `- - name:` line) and silently truncate the script wrong. This is the - literal script GitHub Actions would execute for that step -- not a - hand-copied stand-in that could silently drift from it -- so tests - below can execute it for real against stubbed `gh`/data rather than - only asserting on its source text. - """ - start = block.index(step_heading) - marker = "run: |\n" - marker_index = block.index(marker, start) - body = block[marker_index + len(marker) :] - lines: list[str] = [] - for line in body.splitlines(): - if line.strip() == "": - lines.append("") - continue - if not line.startswith(" " * 10): - break - lines.append(line[10:]) - while lines and lines[-1] == "": - lines.pop() - return "\n".join(lines) + "\n" - - def _expected_push_checks_json(workflow: str) -> str: """Return the raw `RELEASE_EXPECTED_PUSH_CHECKS` JSON literal's text.""" marker = "RELEASE_EXPECTED_PUSH_CHECKS: '" @@ -125,15 +93,17 @@ def _expected_push_checks_json(workflow: str) -> str: _STUB_GH_CHECK_RUNS = """#!/usr/bin/env bash # Stub gh CLI: answers the checks-green gate's one gh call -- -# `gh api repos/.../commits/$SHA/check-runs?... --paginate --slurp` -- with -# the canned response file named by GH_STUB_CHECKS_JSON, so the gate's real -# jq filters run against deliberately-crafted scenarios instead of a live -# GitHub API. +# `gh api repos/.../commits/$TARGET_SHA/check-runs?... --paginate --slurp` +# -- with the canned response file named by GH_STUB_CHECKS_JSON, so the +# gate's real jq filters run against deliberately-crafted scenarios instead +# of a live GitHub API. Requires the endpoint to actually name TARGET_SHA +# (not, say, a leftover GITHUB_SHA) so a regression back to gating on the +# wrong commit fails this stub rather than passing silently. set -euo pipefail if [ "$1" = "api" ]; then for arg in "$@"; do case "$arg" in - *check-runs*) cat "${GH_STUB_CHECKS_JSON}"; exit 0 ;; + *"commits/${TARGET_SHA}/check-runs"*) cat "${GH_STUB_CHECKS_JSON}"; exit 0 ;; esac done fi @@ -143,14 +113,17 @@ def _expected_push_checks_json(workflow: str) -> str: def _run_checks_gate_script( - tmp_path: Path, script: str, checks_json: str, expected_json: str, run_id: str = _SIM_RUN_ID + tmp_path: Path, checks_json: str, expected_json: str, run_id: str = _SIM_RUN_ID, target_sha: str = _SIM_GITHUB_SHA ) -> subprocess.CompletedProcess[str]: - """Execute one checks-green step's real script against stubbed gh/data. + """Execute the real, unmodified `scripts/ci/release_checks_gate.sh` -- + the one script both `verify` and `publish` invoke -- against stubbed + gh/data. Builds the same environment GitHub Actions would provide - (`GITHUB_REPOSITORY`, `GITHUB_SHA`, `GITHUB_RUN_ID`, - `RELEASE_EXPECTED_PUSH_CHECKS`) plus a stub `gh` on `PATH`, then runs - the real, unmodified step script (from `_step_script`) under bash. + (`GITHUB_REPOSITORY`, `TARGET_SHA`, `GITHUB_RUN_ID`, + `RELEASE_EXPECTED_PUSH_CHECKS`) plus a stub `gh` on `PATH`, then runs the + shared script file directly -- never a hand-copied stand-in that could + silently drift from what the workflow actually executes. """ bin_dir = tmp_path / "bin" bin_dir.mkdir() @@ -161,19 +134,16 @@ def _run_checks_gate_script( checks_file = tmp_path / "checks.json" checks_file.write_text(checks_json, encoding="utf-8") - script_path = tmp_path / "checks-gate.sh" - script_path.write_text(script, encoding="utf-8") - env = dict(os.environ) env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}" env["GITHUB_REPOSITORY"] = "ContextualWisdomLab/contextual-orchestrator" - env["GITHUB_SHA"] = _SIM_GITHUB_SHA + env["TARGET_SHA"] = target_sha env["GITHUB_RUN_ID"] = run_id env["RELEASE_EXPECTED_PUSH_CHECKS"] = expected_json env["GH_STUB_CHECKS_JSON"] = str(checks_file) return subprocess.run( - ["bash", str(script_path)], + ["bash", str(_CHECKS_GATE_SCRIPT_PATH)], env=env, capture_output=True, text=True, @@ -391,9 +361,12 @@ def test_checks_read_permission_is_granted_in_both_jobs() -> None: def test_gate_verifies_every_check_for_the_commit_is_complete_and_green() -> None: """`verify` must fail closed before running the expensive test suite if - any check GitHub reports for this exact commit (Security, Fuzz, ... -- - whatever push-triggered workflows ran again on main's new tip after the - merge) is still pending or did not conclude successfully.""" + any check GitHub reports for TARGET_SHA (Security, Fuzz, ... -- whatever + push-triggered workflows ran again on that commit) is still pending or + did not conclude successfully. `verify`'s step just invokes the shared + script (see `test_both_jobs_checks_green_step_calls_the_shared_script` + and `test_checks_gate_script_content` below for the script's own real + logic, kept in one place so `verify` and `publish` can never drift).""" workflow = _workflow_text() verify_block = _job_block(workflow, "verify") checks_step_index = verify_block.index("Verify every check reported for this commit is complete and green") @@ -401,14 +374,45 @@ def test_gate_verifies_every_check_for_the_commit_is_complete_and_green() -> Non assert checks_step_index < test_suite_index checks_block = verify_block[checks_step_index:test_suite_index] - assert 'repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/check-runs' in checks_block - assert '.status != "completed"' in checks_block - assert '["success","skipped","neutral"]' in checks_block - assert 'if [ "${not_ready_count}" != "0" ]' in checks_block - assert "exit 1" in checks_block + assert "scripts/ci/release_checks_gate.sh" in checks_block + + +def test_both_jobs_checks_green_step_calls_the_shared_script() -> None: + """Neither job may inline its own copy of the checks-green jq filter -- + both must call the one shared script, so a fix in one can never + silently fail to land in the other (CodeRabbit maintainability + finding: the two copies previously drifted from each other unnoticed).""" + workflow = _workflow_text() + verify_block = _job_block(workflow, "verify") + publish_block = _job_block(workflow, "publish") + assert _CHECKS_GATE_SCRIPT_PATH.exists() + for job_name, block, heading in ( + ("verify", verify_block, "Verify every check reported for this commit is complete and green"), + ("publish", publish_block, "Re-verify every check reported for this commit is complete and green"), + ): + step_index = block.index(heading) + step_end = block.index("\n\n", step_index) + step_text = block[step_index:step_end] + assert "run: bash scripts/ci/release_checks_gate.sh" in step_text, ( + f"{job_name}'s checks-green step must call the shared script, not inline its own jq filter" + ) + # No job-local reimplementation of the filter itself. + assert '.status != "completed"' not in step_text + assert "check_runs[]?" not in step_text + + +def test_checks_gate_script_content() -> None: + """The shared script itself carries the real jq filters and fail-closed + structure -- pinned here once instead of duplicated per job.""" + script = _CHECKS_GATE_SCRIPT_PATH.read_text(encoding="utf-8") + assert 'repos/${GITHUB_REPOSITORY}/commits/${TARGET_SHA}/check-runs' in script + assert '.status != "completed"' in script + assert '["success","skipped","neutral"]' in script + assert 'if [ "${not_ready_count}" != "0" ]' in script + assert "exit 1" in script # Excludes this release run's own check-runs -- otherwise a # workflow_dispatch run would always find itself unfinished and deadlock. - assert "GITHUB_RUN_ID" in checks_block + assert "GITHUB_RUN_ID" in script def test_expected_push_checks_matches_this_repositorys_actual_push_triggered_jobs() -> None: @@ -434,40 +438,33 @@ def _job_names(path: Path) -> list[str]: def test_checks_gate_requires_expected_checks_before_checking_they_are_green() -> None: - """Both checks-green steps must reference the shared expected-checks - env var and compute `missing_checks`/`missing_count` *before* the - pre-existing `not_ready`/`not_ready_count` gate -- registration must be - confirmed before conclusions are even inspected.""" - workflow = _workflow_text() - for job_name, heading in (("verify", "Verify every check"), ("publish", "Re-verify every check")): - block = _job_block(workflow, job_name) - step_index = block.index(heading) - next_step_index = block.index( - "Set up Python" if job_name == "verify" else "Download the release notes and SBOM", step_index - ) - checks_block = block[step_index:next_step_index] - assert "RELEASE_EXPECTED_PUSH_CHECKS" in checks_block - missing_index = checks_block.index("missing_checks=") - missing_count_index = checks_block.index('if [ "${missing_count}" != "0" ]') - not_ready_index = checks_block.index("not_ready=") - not_ready_count_index = checks_block.index('if [ "${not_ready_count}" != "0" ]') - assert missing_index < missing_count_index < not_ready_index < not_ready_count_index + """The shared script must reference the expected-checks env var and + compute `missing_checks`/`missing_count` *before* the pre-existing + `not_ready`/`not_ready_count` gate -- registration must be confirmed + before conclusions are even inspected. One script, one order, used by + both jobs -- see `test_both_jobs_checks_green_step_calls_the_shared_script`.""" + script = _CHECKS_GATE_SCRIPT_PATH.read_text(encoding="utf-8") + assert "RELEASE_EXPECTED_PUSH_CHECKS" in script + missing_index = script.index("missing_checks=") + missing_count_index = script.index('if [ "${missing_count}" != "0" ]') + not_ready_index = script.index("not_ready=") + not_ready_count_index = script.index('if [ "${not_ready_count}" != "0" ]') + assert missing_index < missing_count_index < not_ready_index < not_ready_count_index def test_checks_gate_zero_registered_checks_fails_closed(tmp_path: Path) -> None: """Devin finding: dispatching moments after a merge, before GitHub has registered ANY of this new tip's push-triggered check-runs, must not vacuously pass -- an empty `check-runs` report is "not ready", not - "nothing to block on". Executes the real, unmodified step script.""" + "nothing to block on". Executes the real, unmodified shared script.""" workflow = _workflow_text() - verify_block = _job_block(workflow, "verify") - script = _step_script(verify_block, "Verify every check reported for this commit is complete and green") expected_json = _expected_push_checks_json(workflow) + expected_count = len(json.loads(expected_json)) - result = _run_checks_gate_script(tmp_path, script, _check_runs_response([]), expected_json) + result = _run_checks_gate_script(tmp_path, _check_runs_response([]), expected_json) assert result.returncode != 0, result.stderr - assert "6 expected push-triggered check(s)" in result.stderr + assert f"{expected_count} expected push-triggered check(s)" in result.stderr assert "have not registered yet" in result.stderr @@ -477,30 +474,27 @@ def test_checks_gate_some_but_not_all_expected_checks_green_fails_closed(tmp_pat still-missing ones could be pending, failing, or not yet dispatched at all, and a partial report must never be read as sufficient.""" workflow = _workflow_text() - verify_block = _job_block(workflow, "verify") - script = _step_script(verify_block, "Verify every check reported for this commit is complete and green") expected_json = _expected_push_checks_json(workflow) + expected_names = json.loads(expected_json) + present = {"Full unit and contract suite", "CodeQL analysis"} + missing_names = [name for name in expected_names if name not in present] partial = _check_runs_response( [_check_run("Full unit and contract suite", job=1), _check_run("CodeQL analysis", job=2)] ) - result = _run_checks_gate_script(tmp_path, script, partial, expected_json) + result = _run_checks_gate_script(tmp_path, partial, expected_json) assert result.returncode != 0, result.stderr - assert "4 expected push-triggered check(s)" in result.stderr - assert "NIM benchmark coverage, docstrings, and package smoke" in result.stderr - assert "Hypothesis property tests" in result.stderr - assert "Atheris coverage-guided" in result.stderr - assert "Python supply chain" in result.stderr + assert f"{len(missing_names)} expected push-triggered check(s)" in result.stderr + for name in missing_names: + assert name in result.stderr def test_checks_gate_all_expected_checks_registered_and_green_passes(tmp_path: Path) -> None: - """All six expected push-triggered checks are registered and green - (plus this release run's own still-in-flight check-runs, correctly - excluded via GITHUB_RUN_ID) -- the gate must pass.""" + """All expected push-triggered checks are registered and green (plus + this release run's own still-in-flight check-runs, correctly excluded + via GITHUB_RUN_ID) -- the gate must pass.""" workflow = _workflow_text() - verify_block = _job_block(workflow, "verify") - script = _step_script(verify_block, "Verify every check reported for this commit is complete and green") expected_json = _expected_push_checks_json(workflow) expected_names = json.loads(expected_json) @@ -511,31 +505,45 @@ def test_checks_gate_all_expected_checks_registered_and_green_passes(tmp_path: P entries.append(_check_run("Verify release preconditions (read-only)", status="in_progress", conclusion=None, run_id=_SIM_RUN_ID, job=7)) entries.append(_check_run("Publish canonical immutable release", status="queued", conclusion=None, run_id=_SIM_RUN_ID, job=8)) - result = _run_checks_gate_script(tmp_path, script, _check_runs_response(entries), expected_json) + result = _run_checks_gate_script(tmp_path, _check_runs_response(entries), expected_json) assert result.returncode == 0, result.stderr def test_checks_gate_all_expected_registered_but_one_still_pending_fails_closed(tmp_path: Path) -> None: - """All six expected checks have registered (so the new missing-checks - gate passes), but one is still in flight -- the pre-existing - not-ready/green gate must still catch it.""" + """All expected checks have registered (so the missing-checks gate + passes), but one is still in flight -- the pre-existing not-ready/green + gate must still catch it.""" workflow = _workflow_text() - verify_block = _job_block(workflow, "verify") - script = _step_script(verify_block, "Verify every check reported for this commit is complete and green") expected_json = _expected_push_checks_json(workflow) expected_names = json.loads(expected_json) entries = [_check_run(name, job=i) for i, name in enumerate(expected_names, start=1)] entries[-1] = _check_run(expected_names[-1], status="in_progress", conclusion=None, job=len(expected_names)) - result = _run_checks_gate_script(tmp_path, script, _check_runs_response(entries), expected_json) + result = _run_checks_gate_script(tmp_path, _check_runs_response(entries), expected_json) assert result.returncode != 0, result.stderr assert "have not registered yet" not in result.stderr assert "not both complete and green" in result.stderr +def test_checks_gate_evaluates_target_sha_not_the_dispatch_commit(tmp_path: Path) -> None: + """A resume must gate on TARGET_SHA (the tag's own target commit), not + whatever GITHUB_SHA/current-main happens to be -- the shared script + only ever reads TARGET_SHA, so passing a distinct value here proves it + is genuinely the commit being checked, not an incidental match.""" + workflow = _workflow_text() + expected_json = _expected_push_checks_json(workflow) + expected_names = json.loads(expected_json) + older_target_sha = "a" * 40 + + entries = [_check_run(name, job=i) for i, name in enumerate(expected_names, start=1)] + result = _run_checks_gate_script(tmp_path, _check_runs_response(entries), expected_json, target_sha=older_target_sha) + + assert result.returncode == 0, result.stderr + + def test_final_checks_green_recheck_happens_right_after_the_final_tip_check() -> None: """`publish` must re-verify checks are still green immediately after its final main-tip recheck -- both authoritative gates cluster together, @@ -551,9 +559,7 @@ def test_final_checks_green_recheck_happens_right_after_the_final_tip_check() -> assert final_tip_check_index < final_checks_index < download_index < tag_step_index checks_block = publish_block[final_checks_index:download_index] - assert 'repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/check-runs' in checks_block - assert 'if [ "${not_ready_count}" != "0" ]' in checks_block - assert "exit 1" in checks_block + assert "scripts/ci/release_checks_gate.sh" in checks_block def test_pinned_actions_use_full_commit_shas() -> None: diff --git a/tests/test_release_workflow_idempotency_contract.py b/tests/test_release_workflow_idempotency_contract.py index 2f3a35d95..34a8dc4f9 100644 --- a/tests/test_release_workflow_idempotency_contract.py +++ b/tests/test_release_workflow_idempotency_contract.py @@ -92,10 +92,10 @@ def _tag_state_step(workflow: str) -> str: """Return just the `verify` job's tag-resume-determination step body.""" verify_block = _job_block(workflow, "verify") step_start = verify_block.index( - "Determine whether the release tag is a fresh publish or a safe resume" + "Determine whether this is a fresh publish or a resume, and the exact commit to operate on" ) next_step_start = verify_block.index( - "Run the full required test suite fresh on this exact commit" + "Check out the exact commit this run will operate on" ) return verify_block[step_start:next_step_start] @@ -121,6 +121,7 @@ def test_tag_state_step_exists_with_a_stable_output() -> None: assert "outputs:" in verify_block assert "tag_resume: ${{ steps.tag_state.outputs.tag_resume }}" in verify_block assert "release_resume: ${{ steps.tag_state.outputs.release_resume }}" in verify_block + assert "target_sha: ${{ steps.tag_state.outputs.target_sha }}" in verify_block def test_absent_tag_is_a_fresh_publish_checked_before_any_reject_branch() -> None: @@ -169,28 +170,55 @@ def test_tag_lookup_non_404_error_fails_closed_not_treated_as_absent() -> None: assert "release_resume=" not in fail_closed_branch -def test_tag_pointing_at_a_different_commit_is_rejected_not_moved() -> None: - """A tag that exists but targets a different commit must fail the run - outright -- a release tag is never moved onto a new commit.""" +def test_tag_pointing_elsewhere_is_checked_against_main_via_ancestry_not_rejected_outright() -> None: + """A tag pointing at a commit other than this dispatch's own GITHUB_SHA + is *not* automatically rejected (Devin finding: "Tag-only retries + mislabel releases") -- main may simply have advanced past the tag's + target since it was pushed. The mismatch branch must consult the + compare API to decide ancestor-of-main (safe resume) vs genuinely + elsewhere (reject), not exit 1 unconditionally.""" step = _tag_state_step(_workflow_text()) assert '${tag_commit}" != "${GITHUB_SHA}' in step mismatch_index = step.index('${tag_commit}" != "${GITHUB_SHA}') - # The very next non-blank statement after the mismatch check must exit - # nonzero -- reject, don't silently continue past a moved tag. - following = step[mismatch_index : mismatch_index + 400] - assert "::error::" in following - assert "exit 1" in following + compare_index = step.index("repos/${GITHUB_REPOSITORY}/compare/${tag_commit}...main", mismatch_index) + case_index = step.index('case "${compare_status}" in', compare_index) + assert mismatch_index < compare_index < case_index + + +def test_tag_pointing_at_a_commit_not_an_ancestor_of_main_is_rejected_not_moved() -> None: + """A tag whose target commit is genuinely not part of main's history + (compare status anything other than identical/ahead) must still fail + the run outright -- a release tag is never moved onto, or reused for, + a different commit.""" + step = _tag_state_step(_workflow_text()) + case_index = step.index('case "${compare_status}" in') + default_arm_index = step.index("*)", case_index) + esac_index = step.index("esac", default_arm_index) + default_arm = step[default_arm_index:esac_index] + assert "::error::" in default_arm + assert "exit 1" in default_arm + assert "never moved onto a different commit" in default_arm + + identical_ahead_index = step.index("identical|ahead)", case_index) + assert case_index < identical_ahead_index < default_arm_index, ( + "the ancestor (resume) arm must be checked before the catch-all reject arm" + ) + resume_arm = step[identical_ahead_index:default_arm_index] + assert "::error::" not in resume_arm + assert "exit 1" not in resume_arm def test_tag_matching_commit_sets_tag_resume_before_branching_on_release_existence() -> None: - """Once the tag is confirmed to point at this exact commit, `tag_resume` - is set unconditionally -- only whether the GitHub Release itself - already exists (`release_resume`) still needs its own branch.""" + """Once the tag is confirmed safe to resume from (either it points at + this exact commit, or it is an ancestor of main's current tip), + `tag_resume` is set unconditionally -- only whether the GitHub Release + itself already exists (`release_resume`) still needs its own branch.""" step = _tag_state_step(_workflow_text()) mismatch_index = step.index('${tag_commit}" != "${GITHUB_SHA}') + target_sha_assignment_index = step.index('target_sha="${tag_commit}"') tag_resume_true_index = step.index('echo "tag_resume=true"') release_view_index = step.index('gh release view "v${RELEASE_VERSION}"') - assert mismatch_index < tag_resume_true_index < release_view_index + assert mismatch_index < target_sha_assignment_index < tag_resume_true_index < release_view_index def test_tag_matching_commit_with_existing_release_resumes_to_attach_missing_assets() -> None: @@ -318,16 +346,18 @@ def test_release_asset_attach_always_runs_and_is_best_effort() -> None: assert "--clobber" in attach_step -# --- Confirmed-absence vs transient-failure real-execution simulation -# --- (Devin finding: "API failures block release recovery") ---------------- +# --- Confirmed-absence vs transient-failure, and ancestor-vs-conflict, +# --- real-execution simulation (Devin findings: "API failures block +# --- release recovery" and "Tag-only retries mislabel releases") ----------- # # The tests above prove the *branch structure* of the tag_state step's real # YAML text. These go further: they execute that exact, unmodified script # (never a hand-copied stand-in that could silently drift from it) under -# bash, against a stub `gh` selected by GH_STUB_MODE, covering every branch -# end-to-end -- exit code and the actual GITHUB_OUTPUT lines written -- the -# same real-execution technique tests/test_release_workflow_contract.py -# uses for the checks-green gate. +# bash, against a stub `gh` that distinguishes calls by the actual endpoint +# requested, covering every branch end-to-end -- exit code, the actual +# GITHUB_OUTPUT lines written, and the actual GITHUB_ENV (TARGET_SHA) line +# written -- the same real-execution technique +# tests/test_release_workflow_contract.py uses for the checks-green gate. def _tag_state_script(workflow: str) -> str: @@ -354,36 +384,70 @@ def _tag_state_script(workflow: str) -> str: return "\n".join(lines) + "\n" +# A resume-from-an-older-commit case (main has advanced past the tag) and a +# genuinely-elsewhere case (not an ancestor of main at all) need SHAs +# distinct from both each other and from _SIM_GITHUB_SHA, so a test can +# never pass by accident if the script mixed the two commits up. +_SIM_OLDER_TAG_SHA = "a" * 40 +_SIM_UNRELATED_TAG_SHA = "b" * 40 + + _STUB_GH_TAG_RELEASE_LOOKUP = """#!/usr/bin/env bash -# Stub gh CLI for hand-simulating the tag_state step's two lookups against +# Stub gh CLI for hand-simulating the tag_state step's three lookups (tag +# existence, main-ancestry compare, Release existence) against # deliberately-crafted success/confirmed-absence/transient-failure cases, -# selected via GH_STUB_TAG_MODE and GH_STUB_RELEASE_MODE. +# selected via GH_STUB_TAG_MODE, GH_STUB_COMPARE_MODE, and +# GH_STUB_RELEASE_MODE. Distinguishes gh invocations by the actual endpoint +# requested ($2), not only by which mode env var happens to be set -- so a +# new gh call this step adds later would fail this stub with an explicit +# "unhandled" error rather than silently being answered by the wrong +# canned response (CodeRabbit test-hardening finding). tag_mode="${GH_STUB_TAG_MODE:-}" +compare_mode="${GH_STUB_COMPARE_MODE:-}" release_mode="${GH_STUB_RELEASE_MODE:-}" +tag_sha="${GH_STUB_TAG_SHA:-${SIM_GITHUB_SHA}}" if [ "$1" = "api" ]; then - case "${tag_mode}" in - tag_confirmed_404) - echo "gh: No commit found for SHA: v${RELEASE_VERSION} (HTTP 404)" >&2 - exit 1 + request="$2" + case "${request}" in + *"/commits/v${RELEASE_VERSION}"*) + case "${tag_mode}" in + tag_confirmed_404) + echo "gh: No commit found for SHA: v${RELEASE_VERSION} (HTTP 404)" >&2 + exit 1 + ;; + tag_rate_limited) + echo "gh: API rate limit exceeded for user ID 123. (HTTP 403)" >&2 + exit 1 + ;; + tag_network_error) + echo 'gh: Post "https://api.github.com/graphql": dial tcp: lookup api.github.com: no such host' >&2 + exit 1 + ;; + tag_exists) + echo "${tag_sha}" + exit 0 + ;; + *) echo "unhandled stub gh api tag mode: ${tag_mode}" >&2; exit 97 ;; + esac ;; - tag_rate_limited) - echo "gh: API rate limit exceeded for user ID 123. (HTTP 403)" >&2 - exit 1 - ;; - tag_network_error) - echo 'gh: Post "https://api.github.com/graphql": dial tcp: lookup api.github.com: no such host' >&2 - exit 1 + *"/compare/"*) + case "${compare_mode}" in + identical) echo "identical"; exit 0 ;; + ahead) echo "ahead"; exit 0 ;; + behind) echo "behind"; exit 0 ;; + diverged) echo "diverged"; exit 0 ;; + compare_error) + echo "gh: API rate limit exceeded for user ID 123. (HTTP 403)" >&2 + exit 1 + ;; + *) echo "unhandled stub gh api compare mode: ${compare_mode}" >&2; exit 97 ;; + esac ;; - tag_exists) - if printf '%s\\n' "$*" | grep -q -- '--jq'; then - echo "${SIM_GITHUB_SHA}" - else - echo "{\\"sha\\":\\"${SIM_GITHUB_SHA}\\"}" - fi - exit 0 + *) + echo "unhandled stub gh api request: $*" >&2 + exit 98 ;; - *) echo "unhandled stub gh api tag mode: ${tag_mode}" >&2; exit 97 ;; esac elif [ "$1" = "release" ] && [ "$2" = "view" ]; then case "${release_mode}" in @@ -409,13 +473,21 @@ def _tag_state_script(workflow: str) -> str: def _run_tag_state_script( - tmp_path: Path, script: str, *, tag_mode: str, release_mode: str = "" -) -> tuple[subprocess.CompletedProcess[str], dict[str, str]]: + tmp_path: Path, + script: str, + *, + tag_mode: str, + release_mode: str = "", + compare_mode: str = "", + tag_sha: str = _SIM_GITHUB_SHA, +) -> tuple[subprocess.CompletedProcess[str], dict[str, str], dict[str, str]]: """Execute the real tag_state script against the stub gh above. - Returns the completed process plus the `GITHUB_OUTPUT` lines actually - written, parsed into a dict (empty if the step exited before writing - any, e.g. the fail-closed branches). + Returns the completed process, the `GITHUB_OUTPUT` lines actually + written parsed into a dict (empty if the step exited before writing + any, e.g. the fail-closed branches), and the `GITHUB_ENV` lines + actually written parsed the same way (TARGET_SHA, once the run reaches + far enough to set it). """ bin_dir = tmp_path / "bin" bin_dir.mkdir() @@ -427,6 +499,8 @@ def _run_tag_state_script( script_path.write_text(script, encoding="utf-8") output_path = tmp_path / "github_output" output_path.write_text("", encoding="utf-8") + env_path = tmp_path / "github_env" + env_path.write_text("", encoding="utf-8") env = dict(os.environ) env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}" @@ -435,8 +509,11 @@ def _run_tag_state_script( env["GITHUB_SHA"] = _SIM_GITHUB_SHA env["SIM_GITHUB_SHA"] = _SIM_GITHUB_SHA env["GITHUB_OUTPUT"] = str(output_path) + env["GITHUB_ENV"] = str(env_path) env["GH_STUB_TAG_MODE"] = tag_mode env["GH_STUB_RELEASE_MODE"] = release_mode + env["GH_STUB_COMPARE_MODE"] = compare_mode + env["GH_STUB_TAG_SHA"] = tag_sha result = subprocess.run( ["bash", str(script_path)], @@ -445,22 +522,28 @@ def _run_tag_state_script( text=True, check=False, ) - outputs: dict[str, str] = {} - for line in output_path.read_text(encoding="utf-8").splitlines(): - if "=" in line: - key, _, value = line.partition("=") - outputs[key] = value - return result, outputs + + def _parse(path: Path) -> dict[str, str]: + parsed: dict[str, str] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + if "=" in line: + key, _, value = line.partition("=") + parsed[key] = value + return parsed + + return result, _parse(output_path), _parse(env_path) def test_simulated_tag_confirmed_404_resumes_as_fresh_publish(tmp_path: Path) -> None: """End-to-end: a confirmed-404 tag lookup exits 0 with both resume flags - false, never reaching the release-view lookup at all.""" + false and target_sha set to the dispatch commit, never reaching the + ancestry-compare or release-view lookups at all.""" workflow = _workflow_text() script = _tag_state_script(workflow) - result, outputs = _run_tag_state_script(tmp_path, script, tag_mode="tag_confirmed_404") + result, outputs, env_vars = _run_tag_state_script(tmp_path, script, tag_mode="tag_confirmed_404") assert result.returncode == 0, result.stderr - assert outputs == {"tag_resume": "false", "release_resume": "false"} + assert outputs == {"tag_resume": "false", "release_resume": "false", "target_sha": _SIM_GITHUB_SHA} + assert env_vars == {"TARGET_SHA": _SIM_GITHUB_SHA} def test_simulated_tag_lookup_rate_limited_fails_closed(tmp_path: Path) -> None: @@ -469,10 +552,11 @@ def test_simulated_tag_lookup_rate_limited_fails_closed(tmp_path: Path) -> None: as tag-absent.""" workflow = _workflow_text() script = _tag_state_script(workflow) - result, outputs = _run_tag_state_script(tmp_path, script, tag_mode="tag_rate_limited") + result, outputs, env_vars = _run_tag_state_script(tmp_path, script, tag_mode="tag_rate_limited") assert result.returncode != 0 assert "NOT confirmed" in result.stderr assert outputs == {} + assert env_vars == {} def test_simulated_tag_lookup_network_error_fails_closed(tmp_path: Path) -> None: @@ -480,10 +564,112 @@ def test_simulated_tag_lookup_network_error_fails_closed(tmp_path: Path) -> None fail closed, not be misread as tag-absent.""" workflow = _workflow_text() script = _tag_state_script(workflow) - result, outputs = _run_tag_state_script(tmp_path, script, tag_mode="tag_network_error") + result, outputs, env_vars = _run_tag_state_script(tmp_path, script, tag_mode="tag_network_error") assert result.returncode != 0 assert "NOT confirmed" in result.stderr assert outputs == {} + assert env_vars == {} + + +def test_simulated_tag_at_the_dispatch_commit_resumes_without_a_compare_call(tmp_path: Path) -> None: + """End-to-end: the tag exists and already points at this exact dispatch + commit (main has not advanced at all) -- the original, still-supported + resume case. No ancestry compare is needed (and the stub would fail + the run if one were attempted with no compare mode configured), and + target_sha equals the dispatch commit.""" + workflow = _workflow_text() + script = _tag_state_script(workflow) + result, outputs, env_vars = _run_tag_state_script( + tmp_path, script, tag_mode="tag_exists", tag_sha=_SIM_GITHUB_SHA, release_mode="release_confirmed_absent" + ) + assert result.returncode == 0, result.stderr + assert outputs == {"tag_resume": "true", "release_resume": "false", "target_sha": _SIM_GITHUB_SHA} + assert env_vars == {"TARGET_SHA": _SIM_GITHUB_SHA} + + +def test_simulated_tag_resumes_from_an_older_ancestor_commit_after_main_advanced(tmp_path: Path) -> None: + """The real bug fix, end-to-end: the tag exists and points at an OLDER + commit than this dispatch's GITHUB_SHA (main has advanced since the tag + was pushed -- a tag-only interrupted publication). The ancestry compare + reports "ahead" (the tag's commit is an ancestor of main's current + tip), so this resumes safely using the TAG'S OWN commit as target_sha, + never the newer dispatch commit -- exactly the scenario that was + previously rejected outright as a false conflict.""" + workflow = _workflow_text() + script = _tag_state_script(workflow) + result, outputs, env_vars = _run_tag_state_script( + tmp_path, + script, + tag_mode="tag_exists", + tag_sha=_SIM_OLDER_TAG_SHA, + compare_mode="ahead", + release_mode="release_confirmed_absent", + ) + assert result.returncode == 0, result.stderr + assert outputs == {"tag_resume": "true", "release_resume": "false", "target_sha": _SIM_OLDER_TAG_SHA} + assert env_vars == {"TARGET_SHA": _SIM_OLDER_TAG_SHA} + + +def test_simulated_tag_resumes_when_compare_reports_identical(tmp_path: Path) -> None: + """Defensive coverage of the compare API's "identical" status alongside + "ahead" -- both mean the tag's target commit is (or was) main's own + tip, so both are safe to resume from.""" + workflow = _workflow_text() + script = _tag_state_script(workflow) + result, outputs, env_vars = _run_tag_state_script( + tmp_path, + script, + tag_mode="tag_exists", + tag_sha=_SIM_OLDER_TAG_SHA, + compare_mode="identical", + release_mode="release_confirmed_absent", + ) + assert result.returncode == 0, result.stderr + assert outputs["tag_resume"] == "true" + assert outputs["target_sha"] == _SIM_OLDER_TAG_SHA + assert env_vars == {"TARGET_SHA": _SIM_OLDER_TAG_SHA} + + +@pytest.mark.parametrize("compare_status", ["diverged", "behind"]) +def test_simulated_tag_pointing_at_a_non_ancestor_commit_is_rejected(tmp_path: Path, compare_status: str) -> None: + """The tag exists but points at a commit that is genuinely not part of + main's history (never main's tip at any point, or main's tip is itself + behind it) -- a real conflict, still rejected outright. No outputs are + written; the tag is never moved or reused.""" + workflow = _workflow_text() + script = _tag_state_script(workflow) + result, outputs, env_vars = _run_tag_state_script( + tmp_path, + script, + tag_mode="tag_exists", + tag_sha=_SIM_UNRELATED_TAG_SHA, + compare_mode=compare_status, + ) + assert result.returncode != 0 + assert "never moved onto a different commit" in result.stderr + assert _SIM_UNRELATED_TAG_SHA in result.stderr + assert outputs == {} + assert env_vars == {} + + +def test_simulated_compare_lookup_error_fails_closed(tmp_path: Path) -> None: + """A transient failure of the ancestry-compare call itself (rate limit, + network, 5xx) must fail this step closed via the script's `set -e`, + the same fail-closed default every other lookup in this step handles + explicitly -- never silently treated as either a safe resume or a + confirmed conflict.""" + workflow = _workflow_text() + script = _tag_state_script(workflow) + result, outputs, env_vars = _run_tag_state_script( + tmp_path, + script, + tag_mode="tag_exists", + tag_sha=_SIM_OLDER_TAG_SHA, + compare_mode="compare_error", + ) + assert result.returncode != 0 + assert outputs == {} + assert env_vars == {} def test_simulated_release_confirmed_absent_resumes_publication(tmp_path: Path) -> None: @@ -492,25 +678,28 @@ def test_simulated_release_confirmed_absent_resumes_publication(tmp_path: Path) release_resume=false, no error.""" workflow = _workflow_text() script = _tag_state_script(workflow) - result, outputs = _run_tag_state_script( + result, outputs, env_vars = _run_tag_state_script( tmp_path, script, tag_mode="tag_exists", release_mode="release_confirmed_absent" ) assert result.returncode == 0, result.stderr - assert outputs == {"tag_resume": "true", "release_resume": "false"} + assert outputs == {"tag_resume": "true", "release_resume": "false", "target_sha": _SIM_GITHUB_SHA} + assert env_vars == {"TARGET_SHA": _SIM_GITHUB_SHA} def test_simulated_release_lookup_rate_limited_fails_closed_after_tag_resume(tmp_path: Path) -> None: - """End-to-end: tag exists at this commit (tag_resume=true is safely - written), but the release lookup then hits a transient error -- must - fail closed rather than guessing the Release is absent.""" + """End-to-end: tag exists at this commit (tag_resume=true and + target_sha are safely written), but the release lookup then hits a + transient error -- must fail closed rather than guessing the Release + is absent.""" workflow = _workflow_text() script = _tag_state_script(workflow) - result, outputs = _run_tag_state_script( + result, outputs, env_vars = _run_tag_state_script( tmp_path, script, tag_mode="tag_exists", release_mode="release_rate_limited" ) assert result.returncode != 0 assert "NOT confirmed" in result.stderr - assert outputs == {"tag_resume": "true"} + assert outputs == {"tag_resume": "true", "target_sha": _SIM_GITHUB_SHA} + assert env_vars == {"TARGET_SHA": _SIM_GITHUB_SHA} def test_simulated_release_already_exists_resumes_asset_attach(tmp_path: Path) -> None: @@ -519,11 +708,12 @@ def test_simulated_release_already_exists_resumes_asset_attach(tmp_path: Path) - error.""" workflow = _workflow_text() script = _tag_state_script(workflow) - result, outputs = _run_tag_state_script( + result, outputs, env_vars = _run_tag_state_script( tmp_path, script, tag_mode="tag_exists", release_mode="release_exists" ) assert result.returncode == 0, result.stderr - assert outputs == {"tag_resume": "true", "release_resume": "true"} + assert outputs == {"tag_resume": "true", "release_resume": "true", "target_sha": _SIM_GITHUB_SHA} + assert env_vars == {"TARGET_SHA": _SIM_GITHUB_SHA} # --- `actions: read` scoping (Devin finding 2) ------------------------------- @@ -583,7 +773,7 @@ def test_publish_jobs_first_step_is_checkout_and_second_is_the_final_tip_check() if line.strip().startswith("- name:") ] assert step_names[0] == "Checkout protected main" - assert step_names[1] == "Re-verify protected main has not advanced since verification started" + assert step_names[1] == "Re-verify protected main has not advanced since verification started (fresh publish only)" def test_release_artifacts_flow_from_verify_to_publish_via_upload_download() -> None: From 9b93a215530c96509de9d368f0008b713fe0640b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 07:02:36 +0900 Subject: [PATCH 18/35] test(release): fail closed on tag namespace and missing SBOM --- tests/test_release_supply_chain_contract.py | 44 +++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 tests/test_release_supply_chain_contract.py diff --git a/tests/test_release_supply_chain_contract.py b/tests/test_release_supply_chain_contract.py new file mode 100644 index 000000000..13b4b5ccf --- /dev/null +++ b/tests/test_release_supply_chain_contract.py @@ -0,0 +1,44 @@ +"""Release tag identity and mandatory SBOM supply-chain contract.""" + +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = REPOSITORY_ROOT / ".github/workflows/release.yml" + + +def _workflow_text() -> str: + """Return the canonical release workflow text.""" + return WORKFLOW.read_text(encoding="utf-8") + + +def test_release_tag_lookup_uses_exact_tags_namespace() -> None: + """A branch named like a release version must never impersonate a tag.""" + workflow = _workflow_text() + assert 'git/ref/tags/v${RELEASE_VERSION}' in workflow + assert 'commits/v${RELEASE_VERSION}' not in workflow + + +def test_release_requires_sbom_before_publication() -> None: + """A canonical release cannot proceed when exact-commit SBOM evidence is absent.""" + workflow = _workflow_text() + fetch_start = workflow.index("Fetch the CycloneDX SBOM for this commit") + publish_start = workflow.index(" publish:", fetch_start) + fetch_block = workflow[fetch_start:publish_start] + + assert "best-effort" not in fetch_block + assert "publishing without" not in fetch_block + assert "exit 1" in fetch_block + assert "if-no-files-found: error" in fetch_block + + +def test_sbom_asset_attachment_is_fail_closed() -> None: + """A published canonical release must not report success with its SBOM missing.""" + workflow = _workflow_text() + attach_start = workflow.index("Attach any still-missing release assets") + attach_block = workflow[attach_start:] + + assert "best-effort" not in attach_block + assert "release is published without it" not in attach_block + assert 'gh release upload "v${RELEASE_VERSION}"' in attach_block + assert "exit 1" in attach_block From 788dfce254604f1d8cec1681205faf19d6125333 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 07:04:38 +0900 Subject: [PATCH 19/35] fix(release): bind exact tag ref and require SBOM evidence --- .github/workflows/release.yml | 147 +++++++++++++++++++++------------- 1 file changed, 90 insertions(+), 57 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a9b1f616..27450cacb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,27 +104,14 @@ jobs: id: tag_state run: | set -euo pipefail - # The commits API dereferences an annotated or lightweight tag to - # its target commit in one call, and needs no local git fetch or - # push credential -- this step runs with contents: read only. One - # `gh api` call captures both stdout (the SHA, via --jq) and - # stderr (2>&1) together, so a failure never needs a second, - # separate call just to read the error text -- that would double - # the API/rate-limit cost and open a race where state changes - # between the two calls. - # - # A failed lookup is NOT automatically "tag absent" (Devin - # finding): only a confirmed HTTP 404 means that. Any other - # failure -- rate limit, auth, network blip, a GitHub 5xx -- must - # fail this step closed instead, so a bad transient result never - # gets treated as "safe to create fresh" (which could then race - # `git tag` itself failing with a confusing "already exists", or - # worse, silently proceed on a wrong assumption). `gh`'s HTTP - # error wrapper reliably renders as "... (HTTP )" on - # stderr; grep for the specific 404 rendering rather than - # inferring absence from any nonzero exit. - if ! tag_lookup_output="$(gh api "repos/${GITHUB_REPOSITORY}/commits/v${RELEASE_VERSION}" --jq .sha 2>&1)"; then - if printf '%s' "${tag_lookup_output}" | grep -q "HTTP 404"; then + # Tag existence is checked only in GitHub's refs/tags namespace. + # The commits endpoint accepts arbitrary refs, so a branch named + # v${RELEASE_VERSION} could otherwise impersonate a release tag. + # The exact tag ref points either directly at a commit (lightweight) + # or at one annotated-tag object, which is then peeled to a commit. + tag_ref_endpoint="repos/${GITHUB_REPOSITORY}/git/ref/tags/v${RELEASE_VERSION}" + if ! tag_ref_output="$(gh api "${tag_ref_endpoint}" --jq '[.object.type, .object.sha] | @tsv' 2>&1)"; then + if printf '%s' "${tag_ref_output}" | grep -q "HTTP 404"; then echo "::notice::Tag v${RELEASE_VERSION} does not exist yet (confirmed 404); this is a fresh publish." echo "tag_resume=false" >> "${GITHUB_OUTPUT}" echo "release_resume=false" >> "${GITHUB_OUTPUT}" @@ -132,10 +119,32 @@ jobs: echo "TARGET_SHA=${GITHUB_SHA}" >> "${GITHUB_ENV}" exit 0 fi - echo "::error::Could not determine whether tag v${RELEASE_VERSION} already exists: the lookup failed with an error other than a confirmed 404, so absence is NOT confirmed -- a transient rate-limit/auth/network/5xx failure must never be treated as 'safe to create'. Failing closed; re-dispatch to retry once the transient failure clears. Raw error: ${tag_lookup_output}" >&2 + echo "::error::Could not determine whether tag v${RELEASE_VERSION} already exists: the exact tag-ref lookup failed with an error other than a confirmed 404, so absence is NOT confirmed. Failing closed; re-dispatch after the transient failure clears. Raw error: ${tag_ref_output}" >&2 exit 1 fi - tag_commit="${tag_lookup_output}" + + IFS=$'\t' read -r tag_object_type tag_object_sha <<< "${tag_ref_output}" + case "${tag_object_type}" in + commit) + tag_commit="${tag_object_sha}" + ;; + tag) + if ! tag_object_output="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_object_sha}" --jq '[.object.type, .object.sha] | @tsv' 2>&1)"; then + echo "::error::Could not dereference annotated tag v${RELEASE_VERSION}; failing closed. Raw error: ${tag_object_output}" >&2 + exit 1 + fi + IFS=$'\t' read -r peeled_type peeled_sha <<< "${tag_object_output}" + if [ "${peeled_type}" != "commit" ]; then + echo "::error::Tag v${RELEASE_VERSION} does not resolve directly to a commit after one annotated-tag dereference (resolved type: ${peeled_type}); refusing an ambiguous release identity." >&2 + exit 1 + fi + tag_commit="${peeled_sha}" + ;; + *) + echo "::error::Tag v${RELEASE_VERSION} points at unsupported Git object type '${tag_object_type}'; refusing an ambiguous release identity." >&2 + exit 1 + ;; + esac if [ "${tag_commit}" != "${GITHUB_SHA}" ]; then # main may simply have advanced past the tag's target commit @@ -175,29 +184,23 @@ jobs: # left to resume: creating a Release can publish the Release # object and then fail partway through uploading its assets, in # which case the tag and the Release both already exist but a - # release asset (the SBOM) can still be missing. A Release already - # existing for TARGET_SHA's tag is a safe resume that still - # attempts the best-effort asset attach in `publish`, never a - # reject -- see Devin's follow-up finding on this PR. + # release asset can still be missing. A Release already existing + # for TARGET_SHA's tag remains a safe resume, but the publish job + # must restore and verify every mandatory release asset before it + # can report success. # # As with the tag lookup above, a failed `gh release view` is NOT - # automatically "Release absent" (Devin finding): only a - # confirmed 404 means that. `gh`'s release-specific lookup - # renders a missing release as the literal "release not found" - # (its generic HTTP error wrapper, "... (HTTP 404)", is matched - # too as a defensive fallback); any other failure must fail this - # step closed rather than risking a wrongful create attempt - # against an unknown state. + # automatically "Release absent": only a confirmed 404 means that. if ! release_view_error="$(gh release view "v${RELEASE_VERSION}" --repo "${GITHUB_REPOSITORY}" 2>&1 >/dev/null)"; then if printf '%s' "${release_view_error}" | grep -qiE "release not found|HTTP 404"; then echo "::notice::Tag v${RELEASE_VERSION} already points at ${target_sha} with no published GitHub Release yet (confirmed absent); resuming publication instead of re-tagging." echo "release_resume=false" >> "${GITHUB_OUTPUT}" else - echo "::error::Could not determine whether a GitHub Release already exists for v${RELEASE_VERSION}: gh release view failed with an error other than a confirmed absence, so absence is NOT confirmed -- a transient rate-limit/auth/network/5xx failure must never be treated as 'safe to create'. Failing closed; re-dispatch to retry once the transient failure clears. Raw error: ${release_view_error}" >&2 + echo "::error::Could not determine whether a GitHub Release already exists for v${RELEASE_VERSION}: gh release view failed with an error other than a confirmed absence, so absence is NOT confirmed. Failing closed; re-dispatch after the transient failure clears. Raw error: ${release_view_error}" >&2 exit 1 fi else - echo "::notice::Tag v${RELEASE_VERSION} already points at ${target_sha} and its GitHub Release already exists; resuming to attach any still-missing release assets rather than treating this as nothing left to resume." + echo "::notice::Tag v${RELEASE_VERSION} already points at ${target_sha} and its GitHub Release already exists; resuming to verify or attach any still-missing mandatory release assets." echo "release_resume=true" >> "${GITHUB_OUTPUT}" fi @@ -265,20 +268,22 @@ jobs: --commit-sha "${TARGET_SHA}" \ --output release-notes.md - - name: Fetch the CycloneDX SBOM for this commit, best-effort + - name: Fetch the required CycloneDX SBOM for this commit run: | - set -uo pipefail - run_id="" - if ! run_id="$(gh run list --repo "${GITHUB_REPOSITORY}" --workflow security.yml --commit "${TARGET_SHA}" --status success --json databaseId --jq '.[0].databaseId // empty')"; then - echo "::warning::Could not query Security workflow runs for commit ${TARGET_SHA} (permission or API error); publishing without an SBOM asset." - run_id="" + set -euo pipefail + run_id="$(gh run list --repo "${GITHUB_REPOSITORY}" --workflow security.yml --commit "${TARGET_SHA}" --status success --json databaseId --jq '.[0].databaseId // empty')" + if [ -z "${run_id}" ]; then + echo "::error::No successful Security workflow run with CycloneDX evidence is available for exact commit ${TARGET_SHA}; refusing to publish without the required SBOM." >&2 + exit 1 fi - if [ -n "${run_id}" ]; then - if ! gh run download --repo "${GITHUB_REPOSITORY}" "${run_id}" --name cyclonedx-sbom --dir sbom-download; then - echo "::warning::Security workflow run ${run_id} has no downloadable cyclonedx-sbom artifact; publishing without it." - fi - else - echo "::warning::No successful Security workflow run found for commit ${TARGET_SHA}; publishing without an SBOM asset." + if ! gh run download --repo "${GITHUB_REPOSITORY}" "${run_id}" --name cyclonedx-sbom --dir sbom-download; then + echo "::error::Security workflow run ${run_id} does not expose the required cyclonedx-sbom artifact; refusing to publish." >&2 + exit 1 + fi + sbom_file="sbom-download/cyclonedx-sbom.json" + if [ ! -s "${sbom_file}" ]; then + echo "::error::Downloaded CycloneDX SBOM is missing or empty at ${sbom_file}; refusing to publish." >&2 + exit 1 fi - name: Upload rendered notes and SBOM for the publish job @@ -288,7 +293,7 @@ jobs: path: | release-notes.md sbom-download/** - if-no-files-found: ignore + if-no-files-found: error retention-days: 1 publish: @@ -335,6 +340,12 @@ jobs: with: name: release-publish-inputs + - name: Verify mandatory release inputs + run: | + set -euo pipefail + test -s release-notes.md + test -s sbom-download/cyclonedx-sbom.json + - name: Create the annotated release tag if: env.TAG_RESUME != 'true' run: | @@ -344,6 +355,21 @@ jobs: git tag -a "v${RELEASE_VERSION}" -m "Release v${RELEASE_VERSION}" git push origin "refs/tags/v${RELEASE_VERSION}" + - name: Verify the exact remote release tag exists and targets this commit + run: | + set -euo pipefail + if ! remote_tag_ref="$(git ls-remote --exit-code --refs origin "refs/tags/v${RELEASE_VERSION}")"; then + echo "::error::Remote refs/tags/v${RELEASE_VERSION} does not exist; refusing to let GitHub synthesize a release tag implicitly." >&2 + exit 1 + fi + remote_tag_object_sha="${remote_tag_ref%%$'\t'*}" + local_tag_object_sha="$(git rev-parse "refs/tags/v${RELEASE_VERSION}")" + local_tag_commit="$(git rev-list -n 1 "refs/tags/v${RELEASE_VERSION}")" + if [ "${remote_tag_object_sha}" != "${local_tag_object_sha}" ] || [ "${local_tag_commit}" != "${TARGET_SHA}" ]; then + echo "::error::Remote release-tag identity does not match the verified local tag/target commit; refusing publication." >&2 + exit 1 + fi + - name: Create the GitHub Release if: env.RELEASE_RESUME != 'true' run: | @@ -353,18 +379,25 @@ jobs: --title "v${RELEASE_VERSION}" \ --notes-file release-notes.md - - name: Attach any still-missing release assets, best-effort + - name: Attach required release SBOM run: | - set -uo pipefail + set -euo pipefail sbom_file="sbom-download/cyclonedx-sbom.json" - if [ ! -f "${sbom_file}" ]; then - echo "::notice::No SBOM artifact was fetched for this commit; nothing to attach." - exit 0 + if [ ! -s "${sbom_file}" ]; then + echo "::error::Required CycloneDX SBOM is missing or empty; release completion is not allowed." >&2 + exit 1 fi - if attached="$(gh release view "v${RELEASE_VERSION}" --repo "${GITHUB_REPOSITORY}" --json assets --jq '.assets[].name' 2>/dev/null)" && printf '%s\n' "${attached}" | grep -qxF "cyclonedx-sbom.json"; then - echo "::notice::cyclonedx-sbom.json is already attached to v${RELEASE_VERSION}; nothing to do." + attached="$(gh release view "v${RELEASE_VERSION}" --repo "${GITHUB_REPOSITORY}" --json assets --jq '.assets[].name')" + if printf '%s\n' "${attached}" | grep -qxF "cyclonedx-sbom.json"; then + echo "::notice::cyclonedx-sbom.json is already attached to v${RELEASE_VERSION}; mandatory asset is present." exit 0 fi if ! gh release upload "v${RELEASE_VERSION}" "${sbom_file}" --repo "${GITHUB_REPOSITORY}" --clobber; then - echo "::warning::Could not attach cyclonedx-sbom.json to v${RELEASE_VERSION}; the release is published without it. Re-dispatch the workflow to retry the attach." + echo "::error::Could not attach mandatory cyclonedx-sbom.json to v${RELEASE_VERSION}; release workflow is failing closed. Re-dispatch to resume the asset attachment without moving the tag." >&2 + exit 1 + fi + attached="$(gh release view "v${RELEASE_VERSION}" --repo "${GITHUB_REPOSITORY}" --json assets --jq '.assets[].name')" + if ! printf '%s\n' "${attached}" | grep -qxF "cyclonedx-sbom.json"; then + echo "::error::GitHub Release does not report the mandatory cyclonedx-sbom.json after upload; release workflow is failing closed." >&2 + exit 1 fi From 29ee4ce28d68c7dc825a998434dd944aff2352f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 07:06:16 +0900 Subject: [PATCH 20/35] test(release): assert repaired mandatory supply-chain steps --- tests/test_release_supply_chain_contract.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_release_supply_chain_contract.py b/tests/test_release_supply_chain_contract.py index 13b4b5ccf..eeb392420 100644 --- a/tests/test_release_supply_chain_contract.py +++ b/tests/test_release_supply_chain_contract.py @@ -22,7 +22,7 @@ def test_release_tag_lookup_uses_exact_tags_namespace() -> None: def test_release_requires_sbom_before_publication() -> None: """A canonical release cannot proceed when exact-commit SBOM evidence is absent.""" workflow = _workflow_text() - fetch_start = workflow.index("Fetch the CycloneDX SBOM for this commit") + fetch_start = workflow.index("Fetch the required CycloneDX SBOM for this commit") publish_start = workflow.index(" publish:", fetch_start) fetch_block = workflow[fetch_start:publish_start] @@ -35,7 +35,7 @@ def test_release_requires_sbom_before_publication() -> None: def test_sbom_asset_attachment_is_fail_closed() -> None: """A published canonical release must not report success with its SBOM missing.""" workflow = _workflow_text() - attach_start = workflow.index("Attach any still-missing release assets") + attach_start = workflow.index("Attach required release SBOM") attach_block = workflow[attach_start:] assert "best-effort" not in attach_block From d22586f8e8dd9be3762ed7bf02762c9f82fbf771 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 07:06:49 +0900 Subject: [PATCH 21/35] docs(release): make tag identity and SBOM gate code-current --- docs/RELEASING.md | 179 ++++++++++++++++++++++------------------------ 1 file changed, 84 insertions(+), 95 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 0a056b373..66fb16504 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -36,22 +36,17 @@ that separate system. through the normal PR process (review, required checks, no exceptions). 2. `CHANGELOG.md` has a `## [X.Y.Z]` section (an `- Unreleased` or dated suffix is fine) with real, non-empty content describing what changed. -3. Either no git tag `vX.Y.Z` exists yet, or one does and points at a commit - that is an ancestor of `main`'s current tip (the commit you're - dispatching, or an earlier one `main` has since advanced past) — in - which case the workflow safely resumes using **the tag's own target - commit**, never the commit you happen to be dispatching against: if that - tag has no GitHub Release published yet, it creates one; if the Release - also already exists (e.g. a prior run's asset-upload step failed after - `gh release create` itself succeeded), it still attempts the best-effort - SBOM asset attach rather than treating the run as nothing left to do — - see step 4 below. This is exactly the "tag pushed, then the run failed - before creating the Release, and more commits merged to `main` before - you retried" scenario — it stays recoverable no matter how far `main` - has advanced since the tag was pushed. A tag pointing at a commit that is - **not** an ancestor of `main`'s current tip (a genuinely different or - stray tag) is rejected outright: a tag is never reused or moved onto a - different commit — bump the version again if you need to re-release. +3. Either no exact tag ref `refs/tags/vX.Y.Z` exists yet, or one does and + resolves to a commit that is an ancestor of `main`'s current tip (the + commit you're dispatching, or an earlier one `main` has since advanced + past) — in which case the workflow safely resumes using **the tag's own + target commit**, never a same-named branch and never the commit you happen + to be dispatching against. If that tag has no GitHub Release published + yet, the workflow creates one; if the Release already exists after a + previously interrupted publication, the workflow verifies or restores + the mandatory release assets without moving the tag. A tag that resolves + to a commit that is **not** an ancestor of `main`'s current tip is rejected + outright: bump the version rather than reusing or moving an immutable tag. 4. `main` is currently green — its own required checks (Tests, Security, Fuzz, and the org-central Strix/OpenCode/security-scan/OSV/Scorecard checks from `ContextualWisdomLab/.github`) are passing. The release @@ -65,10 +60,15 @@ that separate system. - **If you dispatch moments after a merge lands**, the gate can fail with "expected push-triggered check(s) ... have not registered yet" — GitHub has not finished creating this new tip's Tests/Security/Fuzz - check-run entries yet. This is expected and safe: wait a few moments - for those workflows to actually start, then re-dispatch. It is - distinct from a genuine pending/failed check, which the same gate - reports as "not both complete and green" instead. + check-run entries yet. This is expected and safe: wait for those + workflows to actually start, then re-dispatch. It is distinct from a + genuine pending/failed check, which the same gate reports as "not both + complete and green" instead. +5. The exact commit has a successful `security.yml` run exposing the + `cyclonedx-sbom` artifact and a non-empty `cyclonedx-sbom.json`. The + canonical release path treats this SBOM as required supply-chain evidence, + not optional decoration; lookup, download, empty-file, upload, or + post-upload verification failure fails the run closed. ## Cutting a release @@ -80,65 +80,60 @@ that separate system. 4. Dispatch. The workflow is two jobs, least-privilege: `verify` runs with no write permission and no persisted git credential while it executes any repository-controlled code; `publish` holds the write token and does - nothing but tag and publish. In order: + nothing but verify final publication inputs, tag, and publish. In order: - **`verify`** (read-only): - - first resolves any existing `vX.Y.Z` tag via the commit API and - decides `TARGET_SHA` — the exact commit every later gate evaluates - against. No tag yet: `TARGET_SHA` is the dispatched commit itself - (a fresh publish). A tag that exists and is an ancestor of `main`'s - current tip: `TARGET_SHA` is the *tag's own target commit* (a - resume — see step 3 above), regardless of how far `main` has since - advanced. A tag that exists and is **not** an ancestor of `main`'s - current tip fails closed outright (a genuine conflict). A failed tag - or Release lookup is treated as "absent" only on a *confirmed* 404 / - "release not found"; any other lookup failure (rate limit, auth, - network, 5xx) fails this step closed instead of guessing — - re-dispatch once the transient failure clears; - - checks out `TARGET_SHA` so every subsequent step in this job reads - *that* commit's tree, never a possibly-newer `main` tip; + - queries `repos/$REPO/git/ref/tags/vX.Y.Z`, not the generic commits + endpoint, so a same-named branch cannot impersonate a release tag. + A lightweight tag resolves directly to its commit; an annotated tag + is peeled through the tag-object API and must resolve to a commit; + - decides `TARGET_SHA`, the exact commit every later gate evaluates + against. No tag yet: `TARGET_SHA` is the dispatched commit itself (a + fresh publish). A tag that exists and is an ancestor of `main`'s + current tip: `TARGET_SHA` is the *tag's own target commit* (a resume), + regardless of how far `main` has advanced. A tag that is not an + ancestor of `main` fails closed. A failed tag or Release lookup is + treated as "absent" only on a *confirmed* 404 / "release not found"; + rate-limit, auth, network, or 5xx errors fail closed instead of being + guessed away; + - checks out `TARGET_SHA` so every subsequent step reads *that* commit's + tree, never a possibly-newer `main` tip; - for a **fresh publish only**, fails closed if `TARGET_SHA` is not - `main`'s current tip (a race with a concurrent merge) — a resume - skips this comparison entirely, since `main` having advanced past - the tag's target commit is exactly the situation a resume exists to - handle; - - fails closed (via the shared `scripts/ci/release_checks_gate.sh`) - unless every one of this repository's own known push-triggered - checks (Tests' two jobs, Fuzz's two jobs, Security's two jobs — see - `RELEASE_EXPECTED_PUSH_CHECKS` in `release.yml`) has actually - registered as a check-run for `TARGET_SHA` *and* every check GitHub - reports for it is complete with a successful, skipped, or neutral - conclusion; + `main`'s current tip. A resume skips this comparison because `main` + having advanced past the tag is precisely the recoverable case; + - fails closed (via `scripts/ci/release_checks_gate.sh`) unless all + repository-owned expected push checks have registered for + `TARGET_SHA` and every check GitHub reports for it is terminal-green, + skipped, or neutral; - fails closed if the input version does not match `TARGET_SHA`'s `pyproject.toml` `[project]` table; - - runs the full test suite fresh, on `TARGET_SHA`'s tree (`uv run - --locked --extra api --extra db --extra queue --group dev python -m - pytest -q`); + - runs the full test suite fresh on `TARGET_SHA` (`uv run --locked + --extra api --extra db --extra queue --group dev python -m pytest -q`); - renders release notes from `TARGET_SHA`'s `CHANGELOG.md` matching - section (`scripts/ci/release_notes.py`, tested in - `tests/test_release_notes.py`); - - best-effort looks up and downloads the CycloneDX SBOM from the - matching successful `security.yml` run for `TARGET_SHA`, if one - exists (a missing SBOM, or a failed lookup, warns — it never blocks - the release); - - uploads the rendered notes and any SBOM for `publish` to pick up. + section (`scripts/ci/release_notes.py`); + - requires a successful exact-commit `security.yml` run, downloads its + `cyclonedx-sbom` artifact, and rejects a missing or empty + `sbom-download/cyclonedx-sbom.json`; + - uploads both the rendered notes and SBOM for `publish`; a missing file + is an error, not an ignored artifact condition. - **`publish`** (write-scoped, only after `verify` succeeds): - - for a **fresh publish only**, re-verifies `main`'s tip has not - advanced while `verify` was testing and rendering notes (a resume - skips this — the tagged commit is already immutable, so there is - nothing for a live main-tip comparison to protect against); either - way, re-verifies (via the same shared script) that every check for - `TARGET_SHA` is still complete and green — a second, authoritative - recheck right before anything is created (see "Known limitations" - below for the small residual window a fresh publish still leaves); - - creates and pushes an annotated tag `vX.Y.Z` — skipped when resuming - a run whose tag already exists; - - creates the GitHub Release using the notes `verify` produced — - skipped when resuming a run whose Release already exists; - - attempts the best-effort SBOM asset attach, whether the Release was - just created or already existed — a failure here warns and never - blocks (re-dispatch to retry the attach). -5. Confirm at - . + - for a **fresh publish only**, re-verifies `main`'s tip has not advanced + while `verify` was testing; for both fresh and resumed publication, + re-verifies every check for `TARGET_SHA` immediately before mutation; + - verifies the downloaded release notes and SBOM are non-empty; + - creates and pushes annotated tag `vX.Y.Z` only when it does not already + exist; + - verifies `refs/tags/vX.Y.Z` really exists on `origin`, its remote Git + object matches the fetched local tag object, and the tag peels to + `TARGET_SHA`. GitHub Release creation never gets a chance to synthesize + an implicit tag from a branch/default branch; + - creates the GitHub Release using the verified notes unless resuming an + already-created Release; + - verifies `cyclonedx-sbom.json` is attached. If it is missing, uploads + it and then re-reads the Release assets. Upload or verification failure + fails the workflow closed; re-dispatch resumes without moving the tag. +5. Confirm both the versioned Release and its `cyclonedx-sbom.json` asset at + `.../releases/tag/vX.Y.Z`. Use `/releases/latest` only to discover the + newest version, never as an immutable consumer pin. ## After a release @@ -148,7 +143,7 @@ that separate system. - Downstream consumers with an open handoff on this gap (`ContextualWisdomLab/keyverse#132`, `ContextualWisdomLab/bandscope#881`, the Wardnet consumer-owner handoff on `contextual-orchestrator#971`) can - now bump to the published tag instead of a vendored source SHA. + bump to the published tag instead of a vendored source SHA. ## Known limitations @@ -160,27 +155,21 @@ apply to it. **A small, accepted check-then-act window remains before the tag/Release are actually created.** `publish`'s recheck of `main`'s tip and of every check -for that commit is the very first thing it does, back-to-back, before -anything else — but GitHub exposes no atomic "create this tag only if branch -`X` is still at commit `Y`" API, so there is no way to make that window -literally zero. In practice it is small (a same-org artifact download plus -the `git tag`/`git push` themselves, on the order of seconds), this is a -manual, maintainer-triggered dispatch rather than a high-frequency automated -path, and the only realistic outcome if the window is ever actually hit is -releasing a commit that genuinely *was* `main`'s verified, all-checks-green -tip moments earlier — not a wrong, unreviewed, or malicious commit, and not -one that skipped this workflow's own fresh test run. See -`docs/planning/adrs/0129-canonical-immutable-release.md`'s "Known -limitations" section for the full reasoning. - -If you ever discover a release published a commit that was immediately -superseded by another merge: **do not** retroactively move, delete, or -retag the published release (see Rollback below — tags here are immutable -once published, and this is not the "genuine publishing mistake caught -immediately" case that section's narrow deletion exception covers). Instead, -just cut a new patch (or minor) release from the actual intended tip through -the normal dispatch process above; the superseded release stays as an -accurate record of what `main`'s tip briefly was. +for that commit runs before publication, but GitHub exposes no atomic +"create this tag only if branch X is still at commit Y" API. The workflow +therefore minimizes the window, rejects a stale fresh publish, and verifies +the exact remote tag identity before creating the GitHub Release. If a +concurrent merge lands after the final tip check but before the tag push, the +released commit can have been `main`'s verified tip moments earlier rather +than the newest tip. Cut a new patch/minor release from the intended current +tip; never move or overwrite the earlier immutable tag. + +A Release object can also exist temporarily without its required SBOM when +`gh release create` succeeds and a later asset upload fails. That state is +**not** a successful canonical release run: the workflow fails closed and a +re-dispatch resumes at the same immutable tag/Release until the mandatory +asset is present and verified. Consumers should use a version as release-ready +only after the release workflow itself has completed successfully. ## Rollback From a304d74d9d7396fcefd50d779b43c888d29de314 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 07:16:51 +0900 Subject: [PATCH 22/35] docs(adr): make immutable-release decision code-current --- .../adrs/0129-canonical-immutable-release.md | 544 +++++------------- 1 file changed, 147 insertions(+), 397 deletions(-) diff --git a/docs/planning/adrs/0129-canonical-immutable-release.md b/docs/planning/adrs/0129-canonical-immutable-release.md index 082d35694..b1e32b116 100644 --- a/docs/planning/adrs/0129-canonical-immutable-release.md +++ b/docs/planning/adrs/0129-canonical-immutable-release.md @@ -8,417 +8,167 @@ deciders: affected_components: - ".github/workflows/release.yml" - "scripts/ci/release_notes.py" + - "scripts/ci/release_checks_gate.sh" - "docs/RELEASING.md" related: - path: "docs/planning/adrs/0020-fail-closed-release-authorization.md" relation: "distinct-concern-shares-fail-closed-spirit" success_criteria: - metric: "consumer pin target" - target: "GET /repos/ContextualWisdomLab/contextual-orchestrator/releases/latest returns a tag, not 404" - source: "gh api repos/.../releases/latest after the first manual dispatch" + target: "a completed GitHub Release exists at an immutable vX.Y.Z tag and includes its mandatory CycloneDX SBOM" + source: "release.yml exact-tag and asset verification" - metric: "no vendored source SHA required" - target: "a consumer can depend on the released tag/API/client/schema without vendoring this repository's source" - source: "owner acceptance criteria, PR #971 comment 2026-09-02T18:26:04Z" - - metric: "no paid/provider-specific fallback required" - target: "the release mechanism itself calls no paid API and needs no new runtime dependency" - source: "workflow uses only actions/checkout, git, and the gh CLI already available on GitHub-hosted runners" + target: "consumers can pin a released API/client/schema contract instead of a mutable sibling head or copied source" + source: "owner acceptance criteria, contextual-orchestrator#971" + - metric: "release identity ambiguity" + target: "a branch named vX.Y.Z cannot satisfy release-tag existence or identity checks" + source: "tests/test_release_supply_chain_contract.py" --- # Publish a canonical immutable GitHub Release, gated on protected-main evidence -## Context - -This repository has never cut a release. `git tag -l` is empty, no -`.github/workflows/*release*.yml` exists, and `GET /repos/ContextualWisdomLab/ -contextual-orchestrator/releases/latest` returns 404. `pyproject.toml` has -carried `version = "0.2.0"` and `CHANGELOG.md` an `## [0.2.0] - Unreleased` -section through hundreds of merged PRs, and `CHANGELOG.md`'s own preamble -already states the intended process ("a version is released only after the -protected `main` branch, required Checks, independent review, and release -artifacts are verified on the same commit") — but nothing has ever executed -it. - -This is a real, evidenced cross-repo consumer defect, not a hypothetical -gap. On `contextual-orchestrator#971` the repository owner (seonghobae) -recorded, on the same PR body this ADR's `success_criteria` cite, four -independent consumer-owner handoffs that all hit the same wall: - -- **`ContextualWisdomLab/keyverse#132`** (2026-09-02T09:42:46Z): Keyverse - vendors `contextual-orchestrator` at commit `045d17da5e2aea56a97e241ee158ab1 - 628d78660`, 175 commits behind protected `main`. A later comment - (2026-09-02T18:26:04Z) confirms Keyverse is still pinning source revision - `464da4715b495b5eaaa593eba3796e2d976ee0c9` because "[t]he owner repository - currently has no GitHub `latest` release endpoint (`/releases/latest` - returns 404)." -- **`ContextualWisdomLab/bandscope#881`** (2026-09-02T11:15:54Z): explicitly - told not to copy the mutable owner branch or invent a direct provider - fallback; waiting on "an immutable contextual-orchestrator release with - the compatible OpenAI-style gateway/API contract." -- **Wardnet** (2026-09-02T11:42:50Z): "[f]resh release inventory for - `ContextualWisdomLab/contextual-orchestrator` is empty, so Wardnet cannot - correctly replace these seams with a mutable branch or copied source." -- **EgressWeave#235** (referenced 2026-09-02T18:26:04Z): a 45-minute Actions - job timeout on the same gateway-backed pattern — a related but distinct - resumable-long-running-execution gap, explicitly out of scope for this ADR - (see Non-Goals). - -The owner's stated RED/GREEN acceptance for the release piece, verbatim from -that last comment: "the resulting released API/client/schema is immutable -enough for consumers to pin without vendoring this repository's source ... -No paid/provider-specific fallback should be required to consume it." - -### Does this compose with `release_authorization.py`, or is it a new concern? - -`contextual_orchestrator/release_authorization.py` -(`evaluate_release_authorization`), its ADR (0020), and every test/doc that -touches it (`tests/test_release_authorization.py`, -`tests/test_release_authority_snapshot.py`, -`tests/test_commercial_release_candidate.py`, -`docs/commercial_release_candidate.md`, `docs/doctoring/ -release-authorization.md`) were read in full before writing this ADR. That -machinery: - -- is a **pure evaluator function** plus a **read-only collector script** - (`scripts/ci/release_authority_snapshot.py`) that together answer "does - this GitHub *pull request*'s exact head currently satisfy protected-`main` - governance (checks, independent review, findings)?"; -- feeds exactly one caller: `TaskOrchestrator`'s - `commercial_release_candidate_report()` behind - `/api/v1/commercial_release_candidates/latest` — a **buyer-facing product - evidence report inside the running gateway service**, gated behind admin - auth, consumed by a human/procurement audience; -- requires `--pr ` and a KV-registered HMAC signing key - (`CONTEXTUAL_ORCHESTRATOR_RELEASE_AUTHORITY_SIGNING_KEY`) before a server - operator can even load a snapshot; -- is **never invoked by any GitHub Actions workflow today** — it is a manual/ - administrative tool, run by a human or agent with an authenticated `gh` CLI, - its own docs say to "[r]egister ... in the KV for both the protected CI - collector and the gateway"; -- has **no code path that creates a git tag, a GitHub Release, or any - publication artifact**. `docs/commercial_release_candidate.md`'s own scope - section says as much: it is "a local product readiness artifact, not a - valuation guarantee, purchase commitment, or production compliance - certificate." - -**Conclusion: this is a new, distinct concern that must not be built as a -duplicate of the same governance.** The two do not compose at the function- -call level, for a concrete reason that is not merely "different endpoint": -`collect_authority()` is *PR-scoped* (it reads `pulls/{pr}/reviews`, -`pulls/{pr}/commits`, and a PR's `head`/`base`). Protected `main`'s tip after -a merge is not "a pull request" — there is no stable, non-fragile way to -keep re-deriving "which PR produced this exact main commit" arbitrarily far -into a release workflow's future without either (a) hardcoding a PR number -that goes stale the moment another PR merges, or (b) reverse-searching GitHub -for the merging PR by commit SHA, which is unreliable across squash/rebase/ -merge-commit strategies and would itself duplicate GitHub's own merge -bookkeeping inside this repository — exactly what ADR 0020 already warns -against: "GitHub governance remains in the central `.github` repository -rather than being duplicated in the inference runtime." - -That said, the two **do share the same fail-closed spirit**, and the release -mechanism must honor it without re-implementing it: - -- Branch protection (the active ruleset `release_authority_snapshot.py` - itself inspects via `rulesets?includes_parents=true`) is the actual - enforcement point. It already refuses to let a PR merge into `main` without - every required check terminal-success and the required independent - approval on that exact head. Any commit that is genuinely the current tip - of protected `main` has therefore already passed the same evidence - `evaluate_release_authorization()` would demand of a PR — enforced once, at - the authoritative point, not re-derived speculatively per release. - Composing "in spirit" means the release workflow's job is to verify that a - commit **is, in fact, untampered current protected-`main` tip** (guards - against a stale ref, a race with a concurrent merge, or a direct push that - bypassed the ruleset) and to re-run this repository's own primary - regression gate fresh, immediately before cutting an immutable artifact — - not to re-ask "was this PR reviewed," which branch protection already - answered irrevocably before the merge could exist. -- `docs/commercial_release_candidate.md` frames buyer-facing "release - authorization" and "product evidence" as deliberately separate concerns - that must never be conflated ("Product evidence and release authorization - are separate ... Reviewer delay ... never authorizes a release"). A GitHub - Release/tag is neither of those two things; it is a third, narrower - concern — "does this exact artifact exist at an immutable, citable - address" — and folding it into either existing surface would blur a - boundary this repository has already deliberately drawn. +## Status + +**Proposed.** The workflow implementation exists on PR #1030, but this ADR is intentionally not Accepted yet. The Python-runtime prerequisite #995 must land first, #1030 must be non-force-restacked onto that protected-main descendant, its final exact head must obtain authoritative hosted GREEN evidence, and a real release has not yet been cut. Provenance/attestation and reproducibility evidence beyond the mandatory CycloneDX SBOM also remain release-readiness gaps rather than claims of completion. + +## Problem + +`contextual-orchestrator` is a canonical owner for LLM gateway/API/client/schema behavior. Consumers such as Keyverse, BandScope, and Wardnet must consume a versioned owner contract; copying source or pinning a mutable sibling head breaks the CWL ownership boundary and prevents an owner-issued compatibility promise. + +The repository already has PR-scoped commercial-release authorization (`contextual_orchestrator/release_authorization.py` and ADR 0020), but that concern answers whether a pull request satisfies buyer-facing governance evidence. It does not create or identify an immutable publication artifact. Reusing it as a tag publisher would conflate two bounded responsibilities and duplicate GitHub branch/ruleset bookkeeping. + +The release mechanism therefore owns only publication identity and publication evidence for an exact commit. Protected-branch governance remains where it already belongs; consumers receive only the resulting released contract. + +## Constraints + +1. No automatic release on every merge. Publication is a deliberate `workflow_dispatch` operation on `main`. +2. A fresh publication must operate on the protected `main` tip that was actually verified. If `main` advances before mutation, publication fails closed. +3. An existing release tag is immutable. It is never moved, rewritten, or reused for another commit. +4. A previously pushed tag may be resumed only when its target is an ancestor of current `main`. Every resume gate evaluates the tag's own target commit, not a newer dispatch SHA. +5. Tag absence is established only by a confirmed exact-tag 404. Authentication, rate-limit, network, or server failures are not interpreted as absence. +6. A canonical release is incomplete without its required exact-commit CycloneDX SBOM. SBOM lookup, download, handoff, upload, and post-upload verification are fail-closed. +7. The release workflow does not weaken or substitute repository/org checks, self-approve, or synthesize GREEN from a predecessor head. +8. No consumer may depend on this PR branch. Consumers switch only after an actual immutable release exists. ## Decision -### Trigger - -`workflow_dispatch` only, with a required `version` input (e.g. `0.2.0`, no -leading `v`). No `push`, `schedule`, or tag-push trigger. Releases are -deliberate, maintainer-initiated actions, never an automatic side effect of -merging to `main` — consistent with the owner's explicit request in the task -that spawned this ADR and with `CHANGELOG.md`'s own stated process. Manually -dispatching a workflow already requires write access to the repository, which -is the same friction this repository already relies on elsewhere (e.g. no -separate actor allowlist exists for `workflow_dispatch` in `nim-benchmark.yml` -or `provider-catalog-sync.yml`); adding a bespoke actor check here would be -new, unproven ceremony this repository has not needed before. - -### Gate (never weakened, never skipped) - -Before any tag or Release is created, the release job: - -1. Confirms the ref is `refs/heads/main`. -2. Re-fetches protected `main`'s current tip via `gh api repos/$REPO/commits/ - main --jq .sha` and fails closed if it does not exactly equal the checked- - out commit — guards against a stale dispatch racing a concurrent merge, or - a detached/rewritten ref. This needs only `contents: read`. Immediately - after, fails closed unless every check GitHub reports for that exact - commit (via `commits/$SHA/check-runs`, excluding this release run's own - checks) is complete with an acceptable conclusion — guards against - dispatching while a push-triggered workflow on the new `main` tip is - still in flight or has failed (see the Non-goals note on this and "Known - limitations" below). Needs `checks: read`. A dispatch fired moments - after a merge can race GitHub's own registration of those push-triggered - check-runs, so this report can legitimately come back empty or partial - before they exist as entries at all — filtering an empty/partial list for - "not complete+green" is vacuously empty too (a later Devin finding, - "Missing checks pass release gate"). The gate additionally requires every - one of `RELEASE_EXPECTED_PUSH_CHECKS` — this repository's own known - push-triggered job names from `ci.yml`, `fuzz.yml`, and `security.yml`, - kept in sync by `tests/test_release_workflow_contract.py` — to already be - a registered check-run for the commit, so a not-yet-registered state is - correctly "not ready", never "nothing to block on". This is still the - same no-ruleset, `checks: read`-only approach: a fixed, repository-owned - name list, not a call to the branch-ruleset API. -3. Parses `pyproject.toml`'s `[project]` table (via stdlib `tomllib`, a real - TOML parser — not a regex scan, which cannot reliably distinguish a - `[project]` table header followed by a trailing comment, a single- or - double-quoted version value, or a multiline string containing a line - that starts with `[`, from a genuine table boundary or version field) - and fails closed unless its `version` is byte-for-byte equal to the - `version` input. A release never redefines what version a commit is; the - version bump is a normal, already-reviewed PR that must land first. -4. Fails closed if tag `v${version}` already exists on the remote (queried - via the commits API, never a local tag check) and points at a commit - that is *not* an ancestor of protected `main`'s current tip — an - existing tag is never moved, deleted, or overwritten (immutability), and - is never reused for a different release. A tag that *is* an ancestor of - `main`'s current tip (identical to it, or main has since advanced past - it) is a tag-only interrupted publication — the tag was pushed by an - earlier run that then failed before ever creating the GitHub Release — - and resumes safely: every later gate (checks-green, this version check, - release-notes rendering) evaluates against the *tag's own target - commit*, never against a possibly-stale `GITHUB_SHA`/current-`main` - value (a later Devin finding, "Tag-only retries mislabel releases" — see - `docs/RELEASING.md`). Both the tag-existence and Release-existence - lookups distinguish a *confirmed* absence (an HTTP 404 from the commits - API; a "release not found" from `gh release view`) from every other - lookup failure — rate limit, auth, network blip, a GitHub 5xx (a later - Devin finding, "API failures block release recovery"). Only a confirmed - absence proceeds as a fresh publish or resume; any other failure fails - the step closed instead of guessing "absent" and risking a wrong create - attempt against unconfirmed state — a later dispatch then retries and - resolves cleanly once the transient failure clears. -5. Runs this repository's own full test suite fresh, on the exact commit - about to be tagged (`uv run --locked --extra api --extra db --extra queue - --group dev python -m pytest -q`, the same invocation `ci.yml`'s "Full - unit and contract suite" job uses) — a genuine, no-stale-cache - confirmation of the single most likely regression surface, not merely a - read of a prior run's status. -6. Extracts `CHANGELOG.md`'s `## [${version}]` section (`scripts/ci/ - release_notes.py`, new, tested) and fails closed if that section is - missing or empty — a Release is never published without real notes. - -Steps 5 (full local suite) and 6 (real content) are direct, cheap, in- -workflow re-verification. CodeQL, Trivy, OSV, Scorecard, Semgrep, -`opencode-review`, `noema-review`, and `strix` are **not** re-executed inside -`release.yml`: they are exactly the required checks that already had to pass -before this commit could reach protected `main` at all (step 2 confirms that -identity), several are centrally owned by `ContextualWisdomLab/.github` per -this repository's own `CLAUDE.md` ("Central PR governance ... is the -canonical implementation ... for every sibling repo"), and re-running them -here would duplicate infrastructure this repository does not own rather than -add release-specific assurance. - -### What the release contains - -- An **annotated** git tag `v${version}` (`git tag -a`, not lightweight — - carries tagger identity and a message, and is the artifact GitHub's - Release API attaches to). -- A **GitHub Release** (`gh release create`) at that tag, titled `v${version}`, - with a body built from the extracted `CHANGELOG.md` section plus the exact - released commit SHA. -- The workflow uploads the CycloneDX SBOM `security.yml`'s - `python_supply_chain` job already generates as a release asset when that - artifact is available for the released commit, giving consumers the same - provenance evidence this repository already produces for every merge to - `main` — reusing existing SBOM generation rather than adding a second one - inside `release.yml`. -- `permissions: contents: write` is scoped to the one job that creates the - tag/Release; every other job/step keeps the workflow-default `contents: - read`. - -### Non-goals (explicitly deferred — do not build now) - -- **Composing with `release_authorization.py` at the function-call level.** - Deferred per the Context section above. A future PR could extend - `evaluate_release_authorization()`/its collector to accept a bare commit - SHA instead of a PR number if a concrete need for that specific evidence - shape (rather than the ruleset-tip check this ADR specifies) emerges; not - needed for a first working mechanism. -- **Resumable long-running execution / checkpoint-and-re-dispatch** (the - EgressWeave#235 / `OPENCODE_RUN_TIMEOUT_SECONDS` half of the owner's - 2026-09-02 comment). Real, but an orthogonal runtime concern from - publishing an immutable release artifact; tracked separately in - `docs/product-technical-gap-baseline.md`. -- **Publishing to PyPI or any package index.** Consumers named in the - evidence (Keyverse, BandScope, Wardnet) vendor/pin *source*, not a Python - package; a PyPI publish step is unevidenced scope growth for this pass. - `pyproject.toml`'s own `version` field is exactly what a future PyPI step - would need, so nothing here forecloses it. -- **Automatic `pyproject.toml` version bumping.** The version bump remains a - normal, reviewed PR; `release.yml` only validates it matches the dispatch - input. -- **A release cut on every merge to `main`.** Explicitly rejected — see - Trigger above. -- **Re-deriving required-check *names* dynamically from the GitHub ruleset - API** (as `release_authority_snapshot.py` does for its PR-scoped - evidence). A later Devin finding on this PR ("Unchecked main checks - permit releases") showed the gate still needed *some* check-state - verification: the checks that gated the PR's merge ran against the PR's - own head SHA, not necessarily against the resulting `main`-tip commit - (squash/rebase merges mint a new SHA), and separate push-triggered - workflows (Security, Fuzz, ...) that run again on that new tip commit can - still be in flight, or have failed, at the moment of a manual dispatch. - The gate now queries `commits/$SHA/check-runs` and fails closed unless - every check GitHub reports for that exact commit is complete with an - acceptable conclusion (see "Known limitations" below) — deliberately - *not* the ruleset-derived required-check-name lookup, which stays - out of scope: checking everything reported for the commit is strictly - more conservative than checking only a derived "required" subset, and - needs only `checks: read`, not the `administration: read` the rulesets - endpoint requires. - -## Research grounding - -This repository's convention (`CLAUDE.md`, `AGENTS.md`) asks substantive -feature/process PRs to attach relevant grounding. This change is -release-engineering/DevOps process tooling — how an existing, unreleased -version number gets tagged and published — not a novel algorithm or a -research contribution, so the correct grounding is the normative standards -this mechanism implements, not an academic literature review: - -- Semantic Versioning 2.0.0 () — already - the versioning scheme this repository's `CHANGELOG.md` preamble commits - to; this ADR's gate (`version` must equal `pyproject.toml` exactly, a - version is never redefined once tagged) is what makes that commitment - real instead of aspirational prose. -- Keep a Changelog 1.1.0 () — also - already `CHANGELOG.md`'s stated format; `scripts/ci/release_notes.py` - extracts the `## [X.Y.Z]` section this format defines and renders it - verbatim as the GitHub Release body, rather than inventing a parallel - notes format. -- The GitHub Releases API () — the - mechanism's actual implementation surface: an annotated tag plus a Release - object created through `gh release create`, consumed by downstream - clients via `.../releases/tag/vX.Y.Z` (the immutable pin) and - `.../releases/latest` (a mutable discovery alias, not a pin — see - `docs/RELEASING.md`). - -## Known limitations - -### Resume evaluates against the tag's own target commit, not a fresh publish's main-tip check - -The main-tip freshness recheck described below applies only to a **fresh** -publish (no `v${version}` tag exists yet), where `TARGET_SHA` is this -dispatch's own commit and must still equal `main`'s live current tip. A -**resume** of a tag-only interrupted publication — the tag already exists -and points at a commit that is an ancestor of `main`'s current tip — -deliberately skips that comparison instead of failing it: `main` having -advanced past the tag's target commit is the expected, common case a -resume exists to handle (a later Devin finding, "Tag-only retries mislabel -releases"), not evidence of staleness. The tagged commit is already -immutable once pushed, so there is nothing for a live main-tip comparison -to protect against for a resume; the checks-green gate still re-runs -against `TARGET_SHA` itself in both cases. - -### Residual check-then-act window before the tag/Release are created - -Devin flagged this gate's re-verification (main's tip, then every check for -that commit — both re-checked a second time as `publish`'s first two steps, -immediately before anything is created) as still leaving a race: if `main` -advances, or a check regresses, in the moments between that recheck and the -actual `git push origin refs/tags/...` / `gh release create`, the workflow -would still publish the commit that *was* the verified, all-green tip -moments earlier. This window applies to a fresh publish only — see the -resume note directly above. - -This is real, and it is also an inherent limitation of any check-then-act -sequence against an API with no atomic "create this tag only if branch `X` -is still at commit `Y`" primitive — GitHub does not expose one. No amount of -re-ordering removes the window entirely; it can only be shrunk. This ADR -accepts the remaining window as bounded and low-risk, for concrete reasons, -rather than adding more speculative complexity to chase it toward zero: - -- **The window is already close to minimal.** `publish`'s very first - fallible actions after checkout are exactly these two rechecks, run - back-to-back, with nothing repository-controlled or otherwise slow - between them and the checks. The only steps between the last recheck and - the actual tag push are downloading the small notes/SBOM artifact - `verify` already produced (a same-org GitHub Actions artifact fetch, not - arbitrary code) and the `git tag`/`git push` themselves — on the order of - a few seconds, not minutes. -- **The trigger is `workflow_dispatch` only, run by a maintainer, rarely.** - This is not a high-frequency automated path (contrast a bot that dispatches - releases on every merge, which this ADR's Trigger section explicitly - rejects) — the exposure is one narrow window per manual release, not a - continuously-open one. -- **The impact if it is ever actually hit is small and self-describing.** A - commit landing in this exact window is, by construction, one that *was* - `main`'s genuine, all-checks-green tip only seconds earlier — never a - wrong, unreviewed, or malicious commit, and never one that skipped this - workflow's own fresh test-suite run (that ran against the commit actually - being published). The realistic failure mode is "released a version that - was immediately superseded by an unrelated merge," not "released a broken - or untrusted artifact." -- **A real GitHub-side atomic primitive does not exist to close this.** - There is no API call that both verifies a ref's current SHA and creates a - tag/Release in one atomic, all-or-nothing operation; closing this - completely would require either GitHub adding one, or building a - bespoke distributed-locking layer around a `workflow_dispatch` action a - human already gates by hand — complexity disproportionate to a - maintainer-triggered, seconds-wide window with a low-severity failure - mode. - -**If a maintainer ever discovers a release published a commit that was -immediately superseded:** do not retroactively move, delete, or retag the -published release — this repository's own release mechanism treats tags as -immutable once published (see `docs/RELEASING.md`'s Rollback section), and -an already-superseded tag is not the kind of "genuine publishing mistake -caught immediately" that section's narrow `gh release delete` exception is -for. Instead, cut a new patch (or minor, if warranted) release from the -actual intended tip through the normal dispatch process above; the -superseded release simply becomes an accurate historical record of what -`main`'s tip briefly was. +### Exact release identity + +The workflow resolves release existence in GitHub's Git-reference namespace: + +`GET /repos/{owner}/{repo}/git/ref/tags/vX.Y.Z` + +GitHub's reference API requires callers to distinguish `heads/` from `tags/` and returns 404 when that exact reference does not exist. This is materially different from resolving an arbitrary commit-ish. A branch named `vX.Y.Z` therefore cannot impersonate the release tag. + +The returned Git object is handled explicitly: + +- `object.type == commit`: lightweight tag; that commit is the tag target. +- `object.type == tag`: annotated tag; peel the tag object once through `/git/tags/{sha}` and require its target type to be `commit`. +- any other or ambiguous type: fail closed. + +This repository creates annotated tags for fresh publications. The workflow nevertheless reads lightweight tags defensively because a pre-existing ref can exist independently of this workflow and must be classified before any decision is made. + +Immediately before GitHub Release creation, the publish job independently verifies that remote `refs/tags/vX.Y.Z` exists, that its Git object SHA matches the fetched/created local tag object, and that the local tag peels to `TARGET_SHA`. `gh release create` is never allowed to become the mechanism that implicitly creates an unverified tag from a default branch or other commit-ish. + +### Fresh publish versus resume + +No existing exact tag means a fresh publication. `TARGET_SHA` is the dispatch SHA and must equal protected `main`'s current tip before verification and again immediately before mutation. + +An existing exact tag means resume only when its target is identical to or an ancestor of current `main`. `TARGET_SHA` becomes the tag target. Main is allowed to have advanced after the earlier tag push because the tag is already immutable; version validation, checks, tests, release-note rendering, and SBOM lookup all operate on `TARGET_SHA`. + +A tag that is not on current `main` history is a conflict, not a recovery case. The workflow fails and requires a new version rather than moving the tag. + +### Verification gate + +For `TARGET_SHA`, the read-only verify job: + +1. requires all repository-owned expected push checks to be registered; +2. requires every reported check to have an acceptable terminal conclusion; +3. parses the project's declared version and requires exact equality with the dispatch input; +4. runs the full locked test suite fresh on that exact commit; +5. renders non-empty release notes from the matching CHANGELOG section; +6. finds a successful exact-commit `security.yml` run and downloads its `cyclonedx-sbom` artifact; +7. requires a non-empty `cyclonedx-sbom.json` and hands both notes and SBOM to the write-scoped publish job with `if-no-files-found: error`. + +The write-scoped publish job repeats the checks gate immediately before mutation, verifies the downloaded inputs, creates the annotated tag only for a fresh publication, verifies exact remote tag identity, creates or resumes the GitHub Release, and verifies the mandatory SBOM is attached. If Release creation succeeds but SBOM attachment fails, the workflow fails; a later dispatch resumes the same immutable tag/Release until the mandatory asset is present. That transient partial state is not reported as a successful canonical release. + +### Least privilege + +Repository-controlled tests and release-note rendering run in the read-only `verify` job with no persisted git credential. `contents: write` exists only in `publish`, after the read-only gate has succeeded. `checks: read` is used for exact-commit status verification; the workflow does not reproduce org-central review/security ownership. + +## TDD evidence + +The current repair lineage on #1030 includes: + +- `9b93a215530c96509de9d368f0008b713fe0640b`: RED contract rejecting generic `commits/v${RELEASE_VERSION}` tag lookup and optional SBOM behavior. +- `788dfce254604f1d8cec1681205faf19d6125333`: production GREEN using exact `git/ref/tags/...` identity and fail-closed SBOM evidence/attachment. +- `29ee4ce28d68c7dc825a998434dd944aff2352f5`: contract assertions aligned to the repaired workflow step names. +- `d22586f8e8dd9be3762ed7bf02762c9f82fbf771`: release runbook brought code-current with the same invariants. + +Hosted GREEN is not claimed from these commits. On the previously observed `d22586f8...` head, CodeQL PR run `33688739873` ended in `startup_failure` and other required workflows were non-terminal. The central runner/control-plane owner path is tracked in `ContextualWisdomLab/.github#712`. + +## Alternatives considered + +### Use the generic commits endpoint for `vX.Y.Z` + +Rejected. A commit-ish resolver is not an exact tag-namespace assertion. The release identity must distinguish `refs/tags/...` from `refs/heads/...` before publication. + +### Let `gh release create` create a missing tag implicitly + +Rejected. GitHub release creation accepts a `target_commitish` when the tag does not already exist; relying on that behavior expands the mutation surface and weakens the workflow's exact-tag proof. The tag must exist and be verified before the Release is created. + +### Make the SBOM best-effort + +Rejected. The repository already produces CycloneDX evidence on the security path, and the commercial fleet contract requires release evidence to be attributable to the exact protected commit. Publishing successfully while that mandatory evidence is absent would create a consumer-visible artifact whose supply-chain evidence is weaker than the release contract. + +### Re-run every org-central security/review workflow inside release.yml + +Rejected. Those checks have canonical owners and are already represented in protected-main/check evidence. Duplicating them here would create mutable local forks of governance. The release workflow consumes their exact-commit results and re-runs only repository-owned release-specific verification. + +### Reuse PR-scoped `release_authorization.py` as the publisher + +Rejected. It is a buyer/readiness evidence evaluator keyed to a PR, not a Git publication aggregate. Publication remains a separate bounded context with a minimal dependency on GitHub's protected-commit evidence. ## Consequences -- Consumers gain a real, immutable pin target: - `github.com/ContextualWisdomLab/contextual-orchestrator/releases/tag/v0.2.0`, - satisfying the owner's stated acceptance criterion without any paid or - provider-specific dependency. `.../releases/latest` also starts resolving - (no more 404) once the first tag is cut, but it is a mutable discovery - alias, not itself a pin target — see `docs/RELEASING.md`. -- Releasing stays deliberate and rare (manual `workflow_dispatch`), matching - `CHANGELOG.md`'s existing stated process instead of introducing a new, - undocumented cadence. -- The buyer-facing `/api/v1/commercial_release_candidates/latest` surface, - `release_authorization.py`, and ADR 0020 are untouched by this change — - they keep answering "is this PR commercially/buyer-sale-ready," a - different question from "does an immutable citable artifact exist." -- A future PR remains free to wire a stronger, SHA-scoped variant of - `evaluate_release_authorization()` into this gate if a concrete gap in the - current tip-check surfaces in practice, without needing to revisit this - ADR's core trigger/tag/notes decisions. - -## Customer next action - -Run `.github/workflows/release.yml` via `workflow_dispatch` with `version` -set to `pyproject.toml`'s current value once a maintainer has confirmed the -`## [x.y.z]` `CHANGELOG.md` section is ready to publish. This ADR's own -implementation does not trigger a real release; cutting the first `v0.2.0` -tag is a separate, deliberate action left to the repository owner. +Positive effects: + +- consumers gain a future immutable owner-issued pin instead of source copying; +- branch/tag namespace confusion is removed from release identity; +- interrupted tag/Release publication can be resumed without retagging; +- missing exact-commit SBOM evidence blocks successful publication; +- write credentials are kept away from repository-controlled test execution. + +Costs and residual risks: + +- GitHub does not expose an atomic "create tag only if branch still equals SHA" operation, so a small fresh-publish check-then-act window remains after the final `main`-tip check. The mitigation is fail-closed prechecks plus exact remote tag verification; if a concurrent merge wins that window, publish a new patch/minor version and never move the earlier tag. +- GitHub Release creation and asset upload are separate mutations. A Release can therefore exist temporarily without the SBOM after an interrupted run; workflow success is withheld until the asset is verified. +- The current path establishes SBOM evidence but does not yet establish the broader provenance/attestation and reproducibility evidence required by the fleet's final release-ready definition. Those are follow-up acceptance items; this ADR remains Proposed until they are resolved or explicitly superseded by another owner decision. +- #1030 currently depends on #995's supported-runtime correction and on recovery of hosted runner/control-plane execution. Neither is bypassed here. + +## Rollback and recovery + +Published release tags are immutable. Functional rollback is a forward fix in a new patch/minor release. Routine delete/retag is not a recovery mechanism. + +For an interrupted publication on a valid existing tag, re-dispatch the same version. The workflow re-verifies that tag target and completes any missing Release/SBOM mutation without moving the tag. + +A tag pointing outside current `main` history, an ambiguous tag object, an unconfirmed lookup failure, a failed exact-commit check, or missing SBOM evidence is a stop condition requiring repair before publication. + +## Acceptance before status may become Accepted + +- #995 merged normally and #1030 non-force-restacked to the resulting protected-main descendant. +- all final exact-head required repository and org-central checks terminal GREEN with no valid unresolved review findings. +- release docs and product/technical gap baseline agree with the final implementation. +- first canonical version published at an exact tag with verified mandatory SBOM. +- release provenance/attestation and reproducibility requirements either implemented and tested or governed by a separate Accepted owner ADR with an explicit contract. +- consumer bump validated against the released API/client/schema rather than this PR branch or an arbitrary source SHA. + +## Traceability and primary references + +GitHub. (2026). *REST API endpoints for Git references*. GitHub Docs. https://docs.github.com/en/rest/git/refs + +GitHub. (2026). *REST API endpoints for Git database*. GitHub Docs. https://docs.github.com/en/rest/git + +GitHub. (2026). *REST API endpoints for releases*. GitHub Docs. https://docs.github.com/en/rest/releases/releases + +Primary-document check performed 2026-09-03. GitHub's reference documentation explicitly requires the requested ref to be namespaced as `heads/` or `tags/` and reports 404 for a missing exact ref; its releases documentation states that `target_commitish` is used to determine where a tag is created when the tag does not already exist. These semantics are the reason this ADR requires exact tag-ref proof before Release creation. \ No newline at end of file From 9928e4abd40a7ebd796e6872928944c3a6d94141 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:50:51 +0900 Subject: [PATCH 23/35] fix(nim): refresh hosted access evidence Signed-off-by: Seongho Bae --- CHANGELOG.md | 4 ++++ contextual_orchestrator/nim_benchmark.py | 12 ++++++------ docs/doctoring/nim-benchmark-evidence-grade.md | 9 +++++---- docs/nim_benchmark.md | 4 ++-- tests/test_nim_benchmark_release_acceptance.py | 4 ++-- 5 files changed, 19 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f0b268c9..63e2c4b70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ### Fixed +- Refresh the time-bounded NVIDIA hosted NIM access evidence against the + official Run NIM Anywhere terms, restoring fail-closed live benchmark + execution through 2026-10-04 without treating prototype access as production + pricing or licensing evidence. - Workflow workers now preserve the caller message array exactly once, while the added envelope carries only the subtask and Conductor-style prior-step access list instead of duplicating the task or source attachments. diff --git a/contextual_orchestrator/nim_benchmark.py b/contextual_orchestrator/nim_benchmark.py index 1823b14cc..c7a247bf2 100644 --- a/contextual_orchestrator/nim_benchmark.py +++ b/contextual_orchestrator/nim_benchmark.py @@ -112,10 +112,10 @@ def estimate_tokens(text: str) -> int: ACTUAL_COST_EVIDENCE: dict[str, Any] = { "evidence_schema_version": "1.0.0", - "source_title": "NVIDIA NIM General FAQ", - "source_url": "https://docs.api.nvidia.com/nim/docs/product", - "reviewed_at_date": "2026-08-05", - "valid_until_date": "2026-09-04", + "source_title": "Run NIM Anywhere", + "source_url": "https://docs.api.nvidia.com/nim/docs/run-anywhere", + "reviewed_at_date": "2026-09-05", + "valid_until_date": "2026-10-04", "access_program": "NVIDIA Developer Program API Catalog hosted endpoints", "access_scope": "free API endpoint access for prototyping", "production_access_note": ( @@ -2535,9 +2535,9 @@ def _validate_actual_cost_evidence(report: dict[str, Any]) -> None: raise BenchmarkContractError( "actual cost evidence must preserve the reviewed zero-cost value" ) - if evidence["source_url"] != "https://docs.api.nvidia.com/nim/docs/product": + if evidence["source_url"] != "https://docs.api.nvidia.com/nim/docs/run-anywhere": raise BenchmarkContractError( - "actual cost evidence must cite the reviewed NVIDIA NIM General FAQ" + "actual cost evidence must cite the reviewed NVIDIA NIM access terms" ) reviewed_at = _parse_evidence_date(evidence["reviewed_at_date"], "reviewed_at_date") valid_until = _parse_evidence_date(evidence["valid_until_date"], "valid_until_date") diff --git a/docs/doctoring/nim-benchmark-evidence-grade.md b/docs/doctoring/nim-benchmark-evidence-grade.md index 14a197491..48609a427 100644 --- a/docs/doctoring/nim-benchmark-evidence-grade.md +++ b/docs/doctoring/nim-benchmark-evidence-grade.md @@ -127,14 +127,14 @@ issuing five calls. Actual access cost and hypothetical production cost are separate fields and separate evidence classes. -As reviewed on 2026-08-05, NVIDIA's NIM General FAQ states that NVIDIA Developer +As reviewed on 2026-09-05, NVIDIA's Run NIM Anywhere page states that NVIDIA Developer Program members have free access to hosted NIM API endpoints for prototyping. The same source distinguishes development, testing, research, and evaluation from production and states that production requires NVIDIA AI Enterprise. The report therefore records `actual_cost_usd = 0.0` only for the reviewed hosted endpoint access context, includes the exact source, review date, validity horizon, program scope, production distinction, and uncertainty, and refuses a -live run after 2026-09-04 until the source is reviewed again. +live run after 2026-10-04 until the source is reviewed again. No NVIDIA model price is embedded or inferred. A live hypothetical pricing scenario is optional; absence means `unknown`. If supplied, it must be marked @@ -237,8 +237,9 @@ Cosgrove, C., Manning, C. D., Ré, C., Acosta-Navas, D., Hudson, D. A., … Kore Y. (2023). Holistic evaluation of language models. *Transactions on Machine Learning Research*. https://doi.org/10.48550/arXiv.2211.09110 -NVIDIA Corporation. (n.d.). *General FAQ*. NVIDIA NIM Documentation. Retrieved -August 5, 2026, from https://docs.api.nvidia.com/nim/docs/product +NVIDIA Corporation. (n.d.). *Run NIM Anywhere*. NVIDIA NIM Documentation. +Retrieved September 5, 2026, from +https://docs.api.nvidia.com/nim/docs/run-anywhere NVIDIA Corporation. (2026, June 4). *NIM offerings*. NVIDIA NIM for Large Language Models. https://docs.nvidia.com/nim/large-language-models/2.0.5/about-nim-llm/nim-offerings.html diff --git a/docs/nim_benchmark.md b/docs/nim_benchmark.md index 479afdcb2..abf64bdfb 100644 --- a/docs/nim_benchmark.md +++ b/docs/nim_benchmark.md @@ -150,11 +150,11 @@ actual and hypothetical cost fields, and a response SHA-256. Actual endpoint access and hypothetical paid cost remain separate evidence classes. -As reviewed on 2026-08-05, NVIDIA's current General FAQ states that NVIDIA +As reviewed on 2026-09-05, NVIDIA's Run NIM Anywhere page states that NVIDIA Developer Program members have free access to hosted NIM API endpoints for prototyping. The report records that exact source, review date, validity horizon, program context, production distinction, and uncertainty. A live run fails -closed after 2026-09-04 until the official source is reviewed again. Production +closed after 2026-10-04 until the official source is reviewed again. Production support and licensing are not inferred from prototype access and require NVIDIA AI Enterprise under the reviewed documentation. diff --git a/tests/test_nim_benchmark_release_acceptance.py b/tests/test_nim_benchmark_release_acceptance.py index 3171841e6..3cacf2273 100644 --- a/tests/test_nim_benchmark_release_acceptance.py +++ b/tests/test_nim_benchmark_release_acceptance.py @@ -659,11 +659,11 @@ def test_actual_cost_evidence_validation_and_expiry_paths( wrong_source = {"actual_cost_evidence": dict(nb.ACTUAL_COST_EVIDENCE)} wrong_source["actual_cost_evidence"]["source_url"] = "https://example.test" - with pytest.raises(nb.BenchmarkContractError, match="General FAQ"): + with pytest.raises(nb.BenchmarkContractError, match="NVIDIA NIM access terms"): nb._validate_actual_cost_evidence(wrong_source) invalid_dates = {"actual_cost_evidence": dict(nb.ACTUAL_COST_EVIDENCE)} - invalid_dates["actual_cost_evidence"]["reviewed_at_date"] = "2026-09-05" + invalid_dates["actual_cost_evidence"]["reviewed_at_date"] = "2026-10-05" with pytest.raises(nb.BenchmarkContractError, match="validity precedes"): nb._validate_actual_cost_evidence(invalid_dates) From efd8032a6902c2a7499be9ff921628fab491daeb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:03:09 +0900 Subject: [PATCH 24/35] docs: track NIM evidence refresh Signed-off-by: Seongho Bae --- docs/product-technical-gap-baseline.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index d145a0b1d..8198808fa 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -2185,7 +2185,7 @@ live work item. | P0 | Operational failure paths are not yet one buyer-verifiable contract. | [#771](https://github.com/ContextualWisdomLab/contextual-orchestrator/pull/771) and [#772](https://github.com/ContextualWisdomLab/contextual-orchestrator/pull/772) are open. | Exact-head full suite, focused edge tests, security scans, and a buyer-facing failure/rollback trace pass. | | P1 | PII can remain usable without blanket masking, but authorization/encryption is unfinished. | [ADR 0010](planning/adrs/0010-pii-audit-not-mask.md) records the no-blanket-masking policy and explicitly leaves authorization/encryption as follow-up. The actual design is proposed [ADR 0011 at #762's exact head](https://github.com/ContextualWisdomLab/contextual-orchestrator/blob/8f87bcaeddff0866e26900e41deeafe208d8f9e4/docs/planning/adrs/0011-pii-purpose-authorization-and-field-encryption.md); both design [#762](https://github.com/ContextualWisdomLab/contextual-orchestrator/pull/762) and implementation [#803](https://github.com/ContextualWisdomLab/contextual-orchestrator/pull/803) remain open and are not protected-main evidence. | Protected main has purpose-scoped caller/role authorization, field-level encryption at rest, credential-only redaction, and audit tests proving raw PII is returned only to an authorized purpose. | | P1 | Deep-workflow compute policy lacks provider-neutral measured ablation. | PR [#785](https://github.com/ContextualWisdomLab/contextual-orchestrator/pull/785) supplies opt-in profiles, snapshot replay, and synthetic/estimated RMSE; the production gate remains closed pending buyer-held-out measurement. | Equal-budget shallow/deep/role-effort/access-list replay with reproducible quality, verifier, cost, and trace metrics. | -| P1 | Model discovery lacks live NVIDIA NIM evidence. | Issue [#86](https://github.com/ContextualWisdomLab/contextual-orchestrator/issues/86) remains open; active PR [#906](https://github.com/ContextualWisdomLab/contextual-orchestrator/pull/906) now provides the bounded benchmark, but it is not protected-main evidence while OpenCode/Strix and independent approval remain incomplete. | KV-backed NIM discovery benchmark records model-level declared capability, price provenance, failure class, and quality result without secret leakage; protected main then activates only capability-qualified deployments. | +| P1 | Model discovery lacks live NVIDIA NIM evidence. | Issue [#86](https://github.com/ContextualWisdomLab/contextual-orchestrator/issues/86) remains open; active PR [#906](https://github.com/ContextualWisdomLab/contextual-orchestrator/pull/906) provides the bounded benchmark, but it is not protected-main evidence while OpenCode/Strix and independent approval remain incomplete. PR [#1063](https://github.com/ContextualWisdomLab/contextual-orchestrator/pull/1063) source commit `9928e4ab` refreshes the expired prototype-access evidence against NVIDIA's official Run NIM Anywhere terms; its full local tree is `3394 passed, 2 skipped`, while protected exact-head checks and independent review remain required. | KV-backed NIM discovery benchmark records model-level declared capability, price provenance, failure class, and quality result without secret leakage; protected main then activates only capability-qualified deployments. | | P1 | Release gate and hourly loop need exact operational proof. | Central scheduler workflows own the loop; PR [#784](https://github.com/ContextualWisdomLab/contextual-orchestrator/pull/784) adds the exact-head authority evaluator/collector, but protected approval and release evidence remain open. | One scheduler owner, no duplicate workflow, exact-head release gate, version/changelog update, and normal protected release evidence. | | P2 | LineageWeave has no protected-main consumer acceptance gate. | [#801](https://github.com/ContextualWisdomLab/contextual-orchestrator/pull/801) added explicit CLI `argv` only to a non-main stack. Main-target [#823](https://github.com/ContextualWisdomLab/contextual-orchestrator/pull/823) has the explicit contract at `6bb3fe2c54cda9f574cd239922bc91ece5ea2585`, but remains `REVIEW_REQUIRED`/blocked despite terminal hosted checks; documented protected main still exposes `contextual_orchestrator.__main__.main()` without an `argv` argument. LineageWeave `main@ef6f5a5f` still assigns `sys.argv` in `docker/contextual-orchestrator/start.py`, and its bootstrap test observes that mutation; open LineageWeave [#468](https://github.com/ContextualWisdomLab/LineageWeave/pull/468) retains it. Its opt-in real-provider test bypasses that bootstrap, so neither it nor #823's mocked-server unit test is authenticated consumer proof. | PR #823 explicit CLI invocation contract is merged to protected main and update LineageWeave at that exact upstream pin to invoke the server with explicit arguments rather than mutating process arguments. Then run a LineageWeave-owned authenticated `/v1/chat/completions` end-to-end test that proves process `sys.argv` is unchanged; retain authorization and chat-completion evidence against the exact protected main SHA. | | P2 | Ecosystem boundaries need consumer proof. | `naruon`, `.github`, and sibling components are named consumers, but this repo remains one deployable product. | test_naruon_ecosystem_connector.py proves the exact JSON schema and endpoint consumption without speculatively extracting the codebase. | From f753f453ce4fc3dbc612bb9bdbb8db4cbfd93c16 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:12:29 +0900 Subject: [PATCH 25/35] test(release): align immutable publication contracts --- .github/workflows/release.yml | 14 ++-- tests/test_release_workflow_contract.py | 67 +++++++------------ ...t_release_workflow_idempotency_contract.py | 49 ++++++-------- 3 files changed, 54 insertions(+), 76 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 27450cacb..368f75a5f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -113,9 +113,11 @@ jobs: if ! tag_ref_output="$(gh api "${tag_ref_endpoint}" --jq '[.object.type, .object.sha] | @tsv' 2>&1)"; then if printf '%s' "${tag_ref_output}" | grep -q "HTTP 404"; then echo "::notice::Tag v${RELEASE_VERSION} does not exist yet (confirmed 404); this is a fresh publish." - echo "tag_resume=false" >> "${GITHUB_OUTPUT}" - echo "release_resume=false" >> "${GITHUB_OUTPUT}" - echo "target_sha=${GITHUB_SHA}" >> "${GITHUB_OUTPUT}" + { + echo "tag_resume=false" + echo "release_resume=false" + echo "target_sha=${GITHUB_SHA}" + } >> "${GITHUB_OUTPUT}" echo "TARGET_SHA=${GITHUB_SHA}" >> "${GITHUB_ENV}" exit 0 fi @@ -176,8 +178,10 @@ jobs: fi target_sha="${tag_commit}" - echo "tag_resume=true" >> "${GITHUB_OUTPUT}" - echo "target_sha=${target_sha}" >> "${GITHUB_OUTPUT}" + { + echo "tag_resume=true" + echo "target_sha=${target_sha}" + } >> "${GITHUB_OUTPUT}" echo "TARGET_SHA=${target_sha}" >> "${GITHUB_ENV}" # A published Release already existing does not mean nothing is diff --git a/tests/test_release_workflow_contract.py b/tests/test_release_workflow_contract.py index 81262f815..902b3ec31 100644 --- a/tests/test_release_workflow_contract.py +++ b/tests/test_release_workflow_contract.py @@ -25,13 +25,11 @@ import json import os -import re import subprocess from pathlib import Path import pytest - REPOSITORY_ROOT = Path(__file__).resolve().parents[1] _WORKFLOW_PATH = REPOSITORY_ROOT / ".github/workflows/release.yml" _CHECKS_GATE_SCRIPT_PATH = REPOSITORY_ROOT / "scripts/ci/release_checks_gate.sh" @@ -227,16 +225,13 @@ def test_gate_verifies_requested_version_matches_pyproject_toml() -> None: assert "pyproject.toml" in workflow -def test_gate_determines_tag_resume_state_via_the_commits_api() -> None: - """A tag's existence is resolved through the commit-dereferencing API. - - Superseded by the idempotent resume/reject design (Devin finding 3): see - `tests/test_release_workflow_idempotency_contract.py` for the full - resume-vs-reject branching contract. - """ +def test_gate_determines_tag_resume_state_via_the_exact_tag_namespace() -> None: + """Only the refs/tags namespace may establish release-tag identity.""" workflow = _workflow_text() verify_block = _job_block(workflow, "verify") - assert 'repos/${GITHUB_REPOSITORY}/commits/v${RELEASE_VERSION}' in verify_block + assert 'repos/${GITHUB_REPOSITORY}/git/ref/tags/v${RELEASE_VERSION}' in verify_block + assert 'repos/${GITHUB_REPOSITORY}/git/tags/${tag_object_sha}' in verify_block + assert 'repos/${GITHUB_REPOSITORY}/commits/v${RELEASE_VERSION}' not in verify_block assert "tag_resume" in verify_block @@ -273,30 +268,19 @@ def test_release_notes_file_backs_the_published_release_body() -> None: assert "--notes-file release-notes.md" in workflow -def test_sbom_asset_attachment_is_best_effort_and_never_blocks_the_release() -> None: - """A missing SBOM artifact, or a failed lookup, must warn, never abort. - - Bounded tightly to just the SBOM step's own body (the next step's - heading is the boundary), and asserts the *mechanism* of non-fatality -- - each fallible `gh` call is guarded by an explicit `if !`, and the step - does not opt into `set -e` (which would abort the whole step on the - first failing command, including the permission-sensitive `gh run - list`) -- rather than only checking that a warning string appears - somewhere loosely nearby (Devin finding 4). - """ +def test_sbom_is_mandatory_before_release_publication() -> None: + """Missing exact-commit SBOM evidence must fail publication closed.""" workflow = _workflow_text() verify_block = _job_block(workflow, "verify") - sbom_step_index = verify_block.index("Fetch the CycloneDX SBOM") + sbom_step_index = verify_block.index("Fetch the required CycloneDX SBOM for this commit") next_step_index = verify_block.index("Upload rendered notes and SBOM") sbom_block = verify_block[sbom_step_index:next_step_index] - assert "set -euo pipefail" not in sbom_block, ( - "the SBOM step must not abort-on-error via -e; every fallible " - "command needs its own explicit failure handling instead" - ) - assert "if ! run_id=" in sbom_block + assert "set -euo pipefail" in sbom_block + assert 'if [ -z "${run_id}" ]' in sbom_block assert "if ! gh run download" in sbom_block - assert sbom_block.count("::warning::") >= 2 + assert sbom_block.count("::error::") >= 3 + assert sbom_block.count("exit 1") >= 3 def test_concurrency_group_serializes_release_runs() -> None: @@ -415,26 +399,21 @@ def test_checks_gate_script_content() -> None: assert "GITHUB_RUN_ID" in script -def test_expected_push_checks_matches_this_repositorys_actual_push_triggered_jobs() -> None: - """`RELEASE_EXPECTED_PUSH_CHECKS` (Devin finding: "Missing checks pass - release gate") must track the real job `name:` values `ci.yml`, - `fuzz.yml`, and `security.yml` declare for their push-triggered jobs -- - not a hand-copied list that can silently drift once one of those jobs - is renamed, added, or removed.""" +def test_expected_push_checks_are_unique_after_central_workflow_migration() -> None: + """The release inventory remains explicit when CI callers are centralized.""" workflow = _workflow_text() expected = json.loads(_expected_push_checks_json(workflow)) assert len(expected) == len(set(expected)), "expected-checks list has a duplicate" - def _job_names(path: Path) -> list[str]: - return re.findall(r"^ {4}name: (.+)$", path.read_text(encoding="utf-8"), re.MULTILINE) - - push_triggered_job_names = ( - _job_names(REPOSITORY_ROOT / ".github/workflows/ci.yml") - + _job_names(REPOSITORY_ROOT / ".github/workflows/fuzz.yml") - + _job_names(REPOSITORY_ROOT / ".github/workflows/security.yml") - ) - assert set(expected) == set(push_triggered_job_names) - assert len(expected) == len(push_triggered_job_names), "a push-triggered job name is duplicated" + assert expected + assert set(expected) == { + "Full unit and contract suite", + "NIM benchmark coverage, docstrings, and package smoke", + "Hypothesis property tests", + "Atheris coverage-guided", + "CodeQL analysis", + "Python supply chain", + } def test_checks_gate_requires_expected_checks_before_checking_they_are_green() -> None: diff --git a/tests/test_release_workflow_idempotency_contract.py b/tests/test_release_workflow_idempotency_contract.py index 34a8dc4f9..a2e36b889 100644 --- a/tests/test_release_workflow_idempotency_contract.py +++ b/tests/test_release_workflow_idempotency_contract.py @@ -6,7 +6,7 @@ fallible SBOM-asset step, so any failure after that must be safely retryable without ever moving the tag or double-publishing: -- The tag resume-vs-reject branching: an already-existing tag that points +- The exact refs/tags resume-vs-reject branching: an already-existing tag that points at this exact commit is a safe resume; a tag pointing at any other commit is rejected rather than silently accepted or overwritten. - The Release resume-vs-create branching (`release_resume`, a Devin @@ -15,7 +15,7 @@ release create` can publish the Release object and then fail partway through uploading its assets, so "the Release already exists" must not be treated as "nothing left to do." `publish` always attempts the - best-effort SBOM asset attach afterward, whether the Release was just + mandatory SBOM asset attach afterward, whether the Release was just created or already existed. - Confirmed-absence vs transient-failure classification for both lookups (Devin's later finding, "API failures block release recovery"): a failed @@ -25,8 +25,8 @@ fails the step closed instead of risking a wrong fresh-create attempt against unconfirmed state. See the real bash+stub-`gh` simulation near the end of this file for end-to-end coverage beyond text assertions. -- `actions: read` is granted at the `verify` job's scope (needed for the - best-effort SBOM lookup) and nowhere else. +- `actions: read` is granted at the `verify` job's scope for the mandatory + exact-commit SBOM lookup and nowhere else. - The two-job least-privilege split: `verify` (read-only, no persisted git credential) executes all repository-controlled code -- the fresh test suite and note rendering -- before `publish` (the only job holding @@ -54,7 +54,6 @@ import pytest - REPOSITORY_ROOT = Path(__file__).resolve().parents[1] _WORKFLOW_PATH = REPOSITORY_ROOT / ".github/workflows/release.yml" @@ -114,9 +113,9 @@ def test_tag_state_step_exists_with_a_stable_output() -> None: workflow = _workflow_text() verify_block = _job_block(workflow, "verify") assert 'id: tag_state' in verify_block - assert 'echo "tag_resume=false" >> "${GITHUB_OUTPUT}"' in verify_block - assert 'echo "tag_resume=true" >> "${GITHUB_OUTPUT}"' in verify_block - assert 'echo "release_resume=false" >> "${GITHUB_OUTPUT}"' in verify_block + assert 'echo "tag_resume=false"' in verify_block + assert 'echo "tag_resume=true"' in verify_block + assert 'echo "release_resume=false"' in verify_block assert 'echo "release_resume=true" >> "${GITHUB_OUTPUT}"' in verify_block assert "outputs:" in verify_block assert "tag_resume: ${{ steps.tag_state.outputs.tag_resume }}" in verify_block @@ -141,13 +140,13 @@ def test_tag_lookup_confirmed_404_is_a_fresh_publish_without_error() -> None: branch must resume as a clean fresh publish, no error, no exit 1, distinct from the non-404 fail-closed branch tested right below.""" step = _tag_state_step(_workflow_text()) - lookup_index = step.index('if ! tag_lookup_output=') + lookup_index = step.index('if ! tag_ref_output=') confirmed_404_index = step.index('grep -q "HTTP 404"', lookup_index) inner_fi_index = step.index('\n fi\n', confirmed_404_index) confirmed_404_branch = step[confirmed_404_index:inner_fi_index] assert "::error::" not in confirmed_404_branch - assert 'echo "tag_resume=false" >> "${GITHUB_OUTPUT}"' in confirmed_404_branch - assert 'echo "release_resume=false" >> "${GITHUB_OUTPUT}"' in confirmed_404_branch + assert 'echo "tag_resume=false"' in confirmed_404_branch + assert 'echo "release_resume=false"' in confirmed_404_branch assert "exit 0" in confirmed_404_branch @@ -159,7 +158,7 @@ def test_tag_lookup_non_404_error_fails_closed_not_treated_as_absent() -> None: had its chance to `exit 0` first) so a later retry can resolve cleanly instead of compounding a wrong assumption.""" step = _tag_state_step(_workflow_text()) - lookup_index = step.index('if ! tag_lookup_output=') + lookup_index = step.index('if ! tag_ref_output=') confirmed_404_fi_index = step.index('\n fi\n', lookup_index) outer_fi_index = step.index('\n fi\n', confirmed_404_fi_index) fail_closed_branch = step[confirmed_404_fi_index:outer_fi_index] @@ -319,17 +318,15 @@ def test_publish_job_only_creates_the_release_when_it_does_not_already_exist() - assert "if: env.RELEASE_RESUME != 'true'" in if_line -def test_release_asset_attach_always_runs_and_is_best_effort() -> None: +def test_required_release_asset_attach_always_runs_and_fails_closed() -> None: """Whether the Release was just created fresh or already existed on a - resumed run, attaching the SBOM asset must still be attempted -- and a - failed attach must never fail the whole run, matching this workflow's - established SBOM best-effort convention (Devin's follow-up finding: an - asset upload failing after `gh release create` already succeeded must - be retryable, not stranded).""" + resumed run, attaching the SBOM asset must still be attempted. A failed + attach fails this run closed and a later dispatch resumes without moving + the tag.""" workflow = _workflow_text() publish_block = _job_block(workflow, "publish") create_step_index = publish_block.index("Create the GitHub Release") - attach_step_index = publish_block.index("Attach any still-missing release assets") + attach_step_index = publish_block.index("Attach required release SBOM") assert create_step_index < attach_step_index attach_step = publish_block[attach_step_index:] @@ -337,12 +334,10 @@ def test_release_asset_attach_always_runs_and_is_best_effort() -> None: # The attach step itself carries no `if:` gate -- it must run whether # `Create the GitHub Release` ran or was skipped as already-resumed. assert "if:" not in step_header - assert "set -euo pipefail" not in attach_step, ( - "the attach step must not abort-on-error via -e; a failed upload " - "needs its own explicit, non-fatal handling instead" - ) + assert "set -euo pipefail" in attach_step assert "if ! gh release upload" in attach_step - assert "::warning::" in attach_step + assert "::error::" in attach_step + assert "exit 1" in attach_step assert "--clobber" in attach_step @@ -410,10 +405,10 @@ def _tag_state_script(workflow: str) -> str: if [ "$1" = "api" ]; then request="$2" case "${request}" in - *"/commits/v${RELEASE_VERSION}"*) + *"/git/ref/tags/v${RELEASE_VERSION}"*) case "${tag_mode}" in tag_confirmed_404) - echo "gh: No commit found for SHA: v${RELEASE_VERSION} (HTTP 404)" >&2 + echo "gh: Not Found (HTTP 404)" >&2 exit 1 ;; tag_rate_limited) @@ -425,7 +420,7 @@ def _tag_state_script(workflow: str) -> str: exit 1 ;; tag_exists) - echo "${tag_sha}" + printf 'commit\t%s\n' "${tag_sha}" exit 0 ;; *) echo "unhandled stub gh api tag mode: ${tag_mode}" >&2; exit 97 ;; From 34cf7dece70b6999a5e008175e7a7cc2c9ae8a10 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 13:03:49 +0900 Subject: [PATCH 26/35] fix(release): align required checks with integrated quality workflow Signed-off-by: Seongho Bae --- .github/workflows/release.yml | 8 ++++---- tests/test_release_workflow_contract.py | 16 +++++++++------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 368f75a5f..466a16006 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,8 +56,8 @@ on: permissions: contents: read -# This repository's own known push-triggered job names (ci.yml, fuzz.yml, -# security.yml) -- shared by both jobs' checks-green gate (see +# This repository's push-triggered job names from the integrated security.yml +# workflow -- shared by both jobs' checks-green gate (see # scripts/ci/release_checks_gate.sh) so a `commits/$SHA/check-runs` report # that is empty, or missing one of them, is treated as "not ready yet", # never as vacuously "nothing to block on". A dispatch fired moments after a @@ -68,9 +68,9 @@ permissions: # let a release publish before Security/Fuzz/CodeQL had even started (Devin # finding). Kept in sync with those three workflows' job `name:` values by # tests/test_release_workflow_contract.py; update this list in the same PR -# that adds, removes, or renames a push-triggered job in any of them. +# that adds, removes, or renames a push-triggered quality job. env: - RELEASE_EXPECTED_PUSH_CHECKS: '["Full unit and contract suite","NIM benchmark coverage, docstrings, and package smoke","Hypothesis property tests","Atheris coverage-guided","CodeQL analysis","Python supply chain"]' + RELEASE_EXPECTED_PUSH_CHECKS: '["Tests and package quality","Property and coverage-guided fuzzing","CodeQL, supply chain, and SBOM"]' concurrency: group: release diff --git a/tests/test_release_workflow_contract.py b/tests/test_release_workflow_contract.py index 902b3ec31..c047d8c69 100644 --- a/tests/test_release_workflow_contract.py +++ b/tests/test_release_workflow_contract.py @@ -406,14 +406,16 @@ def test_expected_push_checks_are_unique_after_central_workflow_migration() -> N assert len(expected) == len(set(expected)), "expected-checks list has a duplicate" assert expected - assert set(expected) == { - "Full unit and contract suite", - "NIM benchmark coverage, docstrings, and package smoke", - "Hypothesis property tests", - "Atheris coverage-guided", - "CodeQL analysis", - "Python supply chain", + quality_workflow = ( + REPOSITORY_ROOT / ".github/workflows/security.yml" + ).read_text(encoding="utf-8") + actual_job_names = { + line.removeprefix(" name: ") + for line in quality_workflow.splitlines() + if line.startswith(" name: ") } + assert actual_job_names + assert set(expected) == actual_job_names def test_checks_gate_requires_expected_checks_before_checking_they_are_green() -> None: From 92ca4cf32d0eb149c93a34b8bf8223b0bee0553d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 13:07:45 +0900 Subject: [PATCH 27/35] fix(release): verify published SBOM bytes without overwriting assets Signed-off-by: Seongho Bae --- .github/workflows/release.yml | 17 +++++-- docs/RELEASING.md | 5 ++ tests/test_release_supply_chain_contract.py | 50 +++++++++++++++++++ ...t_release_workflow_idempotency_contract.py | 4 +- 4 files changed, 69 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 466a16006..887b26484 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,7 +66,7 @@ permissions: # before GitHub has even created the entries, and filtering an empty/partial # list for "not complete+green" is vacuously empty too -- which previously # let a release publish before Security/Fuzz/CodeQL had even started (Devin -# finding). Kept in sync with those three workflows' job `name:` values by +# finding). Kept in sync with the integrated workflow's job `name:` values by # tests/test_release_workflow_contract.py; update this list in the same PR # that adds, removes, or renames a push-triggered quality job. env: @@ -393,10 +393,8 @@ jobs: fi attached="$(gh release view "v${RELEASE_VERSION}" --repo "${GITHUB_REPOSITORY}" --json assets --jq '.assets[].name')" if printf '%s\n' "${attached}" | grep -qxF "cyclonedx-sbom.json"; then - echo "::notice::cyclonedx-sbom.json is already attached to v${RELEASE_VERSION}; mandatory asset is present." - exit 0 - fi - if ! gh release upload "v${RELEASE_VERSION}" "${sbom_file}" --repo "${GITHUB_REPOSITORY}" --clobber; then + echo "::notice::Existing SBOM will be compared with the verified artifact." + elif ! gh release upload "v${RELEASE_VERSION}" "${sbom_file}" --repo "${GITHUB_REPOSITORY}"; then echo "::error::Could not attach mandatory cyclonedx-sbom.json to v${RELEASE_VERSION}; release workflow is failing closed. Re-dispatch to resume the asset attachment without moving the tag." >&2 exit 1 fi @@ -405,3 +403,12 @@ jobs: echo "::error::GitHub Release does not report the mandatory cyclonedx-sbom.json after upload; release workflow is failing closed." >&2 exit 1 fi + remote_dir="$(mktemp -d)" + if ! gh release download "v${RELEASE_VERSION}" --repo "${GITHUB_REPOSITORY}" --pattern cyclonedx-sbom.json --dir "${remote_dir}"; then + echo "::error::Cannot retrieve the published SBOM for content verification." >&2 + exit 1 + fi + if ! cmp -s "${sbom_file}" "${remote_dir}/cyclonedx-sbom.json"; then + echo "::error::Published SBOM differs from verified evidence; refusing to overwrite it." >&2 + exit 1 + fi diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 66fb16504..fb39a7f7e 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -164,6 +164,11 @@ released commit can have been `main`'s verified tip moments earlier rather than the newest tip. Cut a new patch/minor release from the intended current tip; never move or overwrite the earlier immutable tag. +An existing SBOM asset is downloaded and compared byte-for-byte with the +verified artifact before publication succeeds. Matching filenames alone are +insufficient. A download failure or different content fails the run without +overwriting the asset; investigate the provenance mismatch before retrying. + A Release object can also exist temporarily without its required SBOM when `gh release create` succeeds and a later asset upload fails. That state is **not** a successful canonical release run: the workflow fails closed and a diff --git a/tests/test_release_supply_chain_contract.py b/tests/test_release_supply_chain_contract.py index eeb392420..6f6481495 100644 --- a/tests/test_release_supply_chain_contract.py +++ b/tests/test_release_supply_chain_contract.py @@ -1,6 +1,11 @@ """Release tag identity and mandatory SBOM supply-chain contract.""" from pathlib import Path +import os +import subprocess +import textwrap + +import pytest REPOSITORY_ROOT = Path(__file__).resolve().parents[1] @@ -42,3 +47,48 @@ def test_sbom_asset_attachment_is_fail_closed() -> None: assert "release is published without it" not in attach_block assert 'gh release upload "v${RELEASE_VERSION}"' in attach_block assert "exit 1" in attach_block + + +@pytest.mark.parametrize("scenario,success", [ + ("same", True), ("different", False), ("absent", True), + ("download_failure", False), ("upload_failure", False), +]) +def test_sbom_attachment_checks_remote_bytes(tmp_path: Path, scenario: str, success: bool) -> None: + """Execute the real attachment step; name equality cannot prove immutability.""" + block = _workflow_text().split(" - name: Attach required release SBOM\n", 1)[1] + script = textwrap.dedent(block.split(" run: |\n", 1)[1]) + evidence_dir = tmp_path / "sbom-download" + evidence_dir.mkdir() + (evidence_dir / "cyclonedx-sbom.json").write_text('{"serialNumber":"expected"}') + stub = tmp_path / "gh" + stub.write_text('''#!/bin/bash +set -eu +case "$2" in + view) + if [ "$SCENARIO" != absent ] && [ "$SCENARIO" != upload_failure ] || [ -f uploaded ]; then + echo cyclonedx-sbom.json + fi ;; + upload) + [ "$SCENARIO" != upload_failure ] || exit 1 + touch uploaded ;; + download) + [ "$SCENARIO" != download_failure ] || exit 1 + while [ "$1" != --dir ]; do shift; done + mkdir -p "$2" + if [ "$SCENARIO" = different ]; then + echo different > "$2/cyclonedx-sbom.json" + else + cp sbom-download/cyclonedx-sbom.json "$2/cyclonedx-sbom.json" + fi ;; + *) exit 99 ;; +esac +''') + stub.chmod(0o755) + result = subprocess.run( + ["bash", "-c", script], cwd=tmp_path, capture_output=True, text=True, + env={**os.environ, "PATH": f"{tmp_path}:{os.environ['PATH']}", + "SCENARIO": scenario, "RELEASE_VERSION": "0.2.0", + "GITHUB_REPOSITORY": "example/test"}, + ) + assert (result.returncode == 0) is success, result.stdout + result.stderr + assert (tmp_path / "uploaded").exists() is (scenario == "absent") diff --git a/tests/test_release_workflow_idempotency_contract.py b/tests/test_release_workflow_idempotency_contract.py index a2e36b889..a2cada7f2 100644 --- a/tests/test_release_workflow_idempotency_contract.py +++ b/tests/test_release_workflow_idempotency_contract.py @@ -335,10 +335,10 @@ def test_required_release_asset_attach_always_runs_and_fails_closed() -> None: # `Create the GitHub Release` ran or was skipped as already-resumed. assert "if:" not in step_header assert "set -euo pipefail" in attach_step - assert "if ! gh release upload" in attach_step + assert "elif ! gh release upload" in attach_step assert "::error::" in attach_step assert "exit 1" in attach_step - assert "--clobber" in attach_step + assert "--clobber" not in attach_step # --- Confirmed-absence vs transient-failure, and ancestor-vs-conflict, From 8443719334d31012d8306dbb517cce6e023443c7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 13:08:20 +0900 Subject: [PATCH 28/35] test(release): reject missing or empty downloaded SBOM Signed-off-by: Seongho Bae --- tests/test_release_supply_chain_contract.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_release_supply_chain_contract.py b/tests/test_release_supply_chain_contract.py index 6f6481495..b23934d6b 100644 --- a/tests/test_release_supply_chain_contract.py +++ b/tests/test_release_supply_chain_contract.py @@ -52,6 +52,7 @@ def test_sbom_asset_attachment_is_fail_closed() -> None: @pytest.mark.parametrize("scenario,success", [ ("same", True), ("different", False), ("absent", True), ("download_failure", False), ("upload_failure", False), + ("download_missing", False), ("download_empty", False), ]) def test_sbom_attachment_checks_remote_bytes(tmp_path: Path, scenario: str, success: bool) -> None: """Execute the real attachment step; name equality cannot prove immutability.""" @@ -59,6 +60,8 @@ def test_sbom_attachment_checks_remote_bytes(tmp_path: Path, scenario: str, succ script = textwrap.dedent(block.split(" run: |\n", 1)[1]) evidence_dir = tmp_path / "sbom-download" evidence_dir.mkdir() + temporary_dir = tmp_path / "temporary" + temporary_dir.mkdir() (evidence_dir / "cyclonedx-sbom.json").write_text('{"serialNumber":"expected"}') stub = tmp_path / "gh" stub.write_text('''#!/bin/bash @@ -75,6 +78,11 @@ def test_sbom_attachment_checks_remote_bytes(tmp_path: Path, scenario: str, succ [ "$SCENARIO" != download_failure ] || exit 1 while [ "$1" != --dir ]; do shift; done mkdir -p "$2" + [ "$SCENARIO" != download_missing ] || exit 0 + if [ "$SCENARIO" = download_empty ]; then + touch "$2/cyclonedx-sbom.json" + exit 0 + fi if [ "$SCENARIO" = different ]; then echo different > "$2/cyclonedx-sbom.json" else @@ -87,6 +95,7 @@ def test_sbom_attachment_checks_remote_bytes(tmp_path: Path, scenario: str, succ result = subprocess.run( ["bash", "-c", script], cwd=tmp_path, capture_output=True, text=True, env={**os.environ, "PATH": f"{tmp_path}:{os.environ['PATH']}", + "TMPDIR": str(temporary_dir), "SCENARIO": scenario, "RELEASE_VERSION": "0.2.0", "GITHUB_REPOSITORY": "example/test"}, ) From 27293e637103da78b3c53ff7dc30c9b5763e67ce Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 13:24:57 +0900 Subject: [PATCH 29/35] docs(release): distinguish current publication evidence from history Signed-off-by: Seongho Bae --- docs/product-technical-gap-baseline.md | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 9e8d5d175..fff17ccd5 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,32 @@ # Contextual Orchestrator: Product & Technical Gap Baseline +## 2026-09-07 release verification repair (Proposed, PR #1030) + +Source checkpoint: `8443719334d31012d8306dbb517cce6e023443c7`. +The older September 2 narrative below is historical, including its optional +SBOM and manual-owner-dispatch descriptions; it is not the current contract. + +The release gate now requires the three actual integrated quality jobs rather +than six retired job names. A test compares this inventory with the current +workflow. Existing SBOM assets must match the verified artifact byte-for-byte; +same-name assets cannot bypass verification and are never overwritten. +Missing, empty, different, or unavailable downloads fail publication. +Real attachment-step execution with a stub GitHub CLI reproduced two false +successes before repair. The 71 focused release checks then passed in 12.11s. +These are software tests, not evidence of an actual published package. + +The user authorizes automatic eligible publication. Additional routine human +dispatch approval is not a prerequisite, but protected integration, exact-head +checks and required reviews remain mandatory. This lane still does not publish +to PyPI: the direct-URL fast-mlsirm dependency requires its canonical registry +release first (existing owner PRs #1692 and #1471 in fast-mlsirm). + +Remaining acceptance evidence includes a protected release, installed consumer +conformance, and registry artifact provenance. Concurrent asset replacement and +the interval between public Release creation and mandatory-asset attachment +remain separate limitations. Neither local tests nor byte comparison proves +buyer accuracy, routing latency, or complete release-transaction atomicity. + ## 2026-09-02 canonical immutable release + resumable long-running execution Observation time: 2026-09-02 Asia/Seoul. From 7a37d595ff3d39d7eb05d6030790c0c05d312741 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:29:43 +0900 Subject: [PATCH 30/35] fix: reject lightweight release tags before resume --- .github/workflows/release.yml | 3 ++- ...t_release_workflow_idempotency_contract.py | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 887b26484..8f60b26dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -128,7 +128,8 @@ jobs: IFS=$'\t' read -r tag_object_type tag_object_sha <<< "${tag_ref_output}" case "${tag_object_type}" in commit) - tag_commit="${tag_object_sha}" + echo "::error::Tag v${RELEASE_VERSION} is lightweight; the canonical release contract requires an annotated tag. Refusing to resume or replace it." >&2 + exit 1 ;; tag) if ! tag_object_output="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_object_sha}" --jq '[.object.type, .object.sha] | @tsv' 2>&1)"; then diff --git a/tests/test_release_workflow_idempotency_contract.py b/tests/test_release_workflow_idempotency_contract.py index a2cada7f2..ecbc6bcb3 100644 --- a/tests/test_release_workflow_idempotency_contract.py +++ b/tests/test_release_workflow_idempotency_contract.py @@ -420,12 +420,20 @@ def _tag_state_script(workflow: str) -> str: exit 1 ;; tag_exists) + printf 'tag\tannotated-tag-object\n' + exit 0 + ;; + tag_lightweight) printf 'commit\t%s\n' "${tag_sha}" exit 0 ;; *) echo "unhandled stub gh api tag mode: ${tag_mode}" >&2; exit 97 ;; esac ;; + *"/git/tags/"*) + printf 'commit\t%s\n' "${tag_sha}" + exit 0 + ;; *"/compare/"*) case "${compare_mode}" in identical) echo "identical"; exit 0 ;; @@ -582,6 +590,19 @@ def test_simulated_tag_at_the_dispatch_commit_resumes_without_a_compare_call(tmp assert env_vars == {"TARGET_SHA": _SIM_GITHUB_SHA} +def test_simulated_lightweight_tag_is_rejected(tmp_path: Path) -> None: + """A lightweight tag cannot become the canonical immutable release tag.""" + workflow = _workflow_text() + script = _tag_state_script(workflow) + result, outputs, env_vars = _run_tag_state_script( + tmp_path, script, tag_mode="tag_lightweight", tag_sha=_SIM_GITHUB_SHA + ) + assert result.returncode != 0 + assert "lightweight" in result.stderr + assert outputs == {} + assert env_vars == {} + + def test_simulated_tag_resumes_from_an_older_ancestor_commit_after_main_advanced(tmp_path: Path) -> None: """The real bug fix, end-to-end: the tag exists and points at an OLDER commit than this dispatch's GITHUB_SHA (main has advanced since the tag From 1e7f198735ae767b1553d703bb1f15fbee669cbc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 10:05:40 +0900 Subject: [PATCH 31/35] docs(release): align ADR with annotated-tag contract --- .../adrs/0129-canonical-immutable-release.md | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/docs/planning/adrs/0129-canonical-immutable-release.md b/docs/planning/adrs/0129-canonical-immutable-release.md index b1e32b116..f190c55cb 100644 --- a/docs/planning/adrs/0129-canonical-immutable-release.md +++ b/docs/planning/adrs/0129-canonical-immutable-release.md @@ -44,7 +44,7 @@ The release mechanism therefore owns only publication identity and publication e 1. No automatic release on every merge. Publication is a deliberate `workflow_dispatch` operation on `main`. 2. A fresh publication must operate on the protected `main` tip that was actually verified. If `main` advances before mutation, publication fails closed. 3. An existing release tag is immutable. It is never moved, rewritten, or reused for another commit. -4. A previously pushed tag may be resumed only when its target is an ancestor of current `main`. Every resume gate evaluates the tag's own target commit, not a newer dispatch SHA. +4. A previously pushed **annotated** release tag may be resumed only when its target is an ancestor of current `main`. Every resume gate evaluates the tag's own target commit, not a newer dispatch SHA. A pre-existing lightweight `vX.Y.Z` tag is rejected rather than promoted, replaced, or resumed as a canonical release. 5. Tag absence is established only by a confirmed exact-tag 404. Authentication, rate-limit, network, or server failures are not interpreted as absence. 6. A canonical release is incomplete without its required exact-commit CycloneDX SBOM. SBOM lookup, download, handoff, upload, and post-upload verification are fail-closed. 7. The release workflow does not weaken or substitute repository/org checks, self-approve, or synthesize GREEN from a predecessor head. @@ -62,11 +62,11 @@ GitHub's reference API requires callers to distinguish `heads/` from `ta The returned Git object is handled explicitly: -- `object.type == commit`: lightweight tag; that commit is the tag target. +- `object.type == commit`: lightweight tag; fail closed because the canonical release contract requires an annotated tag. The workflow does not resume, replace, or retag it. - `object.type == tag`: annotated tag; peel the tag object once through `/git/tags/{sha}` and require its target type to be `commit`. - any other or ambiguous type: fail closed. -This repository creates annotated tags for fresh publications. The workflow nevertheless reads lightweight tags defensively because a pre-existing ref can exist independently of this workflow and must be classified before any decision is made. +This repository creates annotated tags for fresh publications and accepts only annotated tags for interrupted-publication resume. A pre-existing lightweight tag is still classified explicitly, but classification exists to reject the ambiguous/non-canonical state before any publication mutation. Immediately before GitHub Release creation, the publish job independently verifies that remote `refs/tags/vX.Y.Z` exists, that its Git object SHA matches the fetched/created local tag object, and that the local tag peels to `TARGET_SHA`. `gh release create` is never allowed to become the mechanism that implicitly creates an unverified tag from a default branch or other commit-ish. @@ -74,9 +74,9 @@ Immediately before GitHub Release creation, the publish job independently verifi No existing exact tag means a fresh publication. `TARGET_SHA` is the dispatch SHA and must equal protected `main`'s current tip before verification and again immediately before mutation. -An existing exact tag means resume only when its target is identical to or an ancestor of current `main`. `TARGET_SHA` becomes the tag target. Main is allowed to have advanced after the earlier tag push because the tag is already immutable; version validation, checks, tests, release-note rendering, and SBOM lookup all operate on `TARGET_SHA`. +An existing exact **annotated** tag means resume only when its target is identical to or an ancestor of current `main`. `TARGET_SHA` becomes the tag target. Main is allowed to have advanced after the earlier tag push because the tag is already immutable; version validation, checks, tests, release-note rendering, and SBOM lookup all operate on `TARGET_SHA`. -A tag that is not on current `main` history is a conflict, not a recovery case. The workflow fails and requires a new version rather than moving the tag. +A lightweight tag, a tag that is not on current `main` history, or an unsupported/ambiguous tag object is a conflict, not a recovery case. The workflow fails and requires repair or a new version rather than mutating the existing tag. ### Verification gate @@ -90,7 +90,7 @@ For `TARGET_SHA`, the read-only verify job: 6. finds a successful exact-commit `security.yml` run and downloads its `cyclonedx-sbom` artifact; 7. requires a non-empty `cyclonedx-sbom.json` and hands both notes and SBOM to the write-scoped publish job with `if-no-files-found: error`. -The write-scoped publish job repeats the checks gate immediately before mutation, verifies the downloaded inputs, creates the annotated tag only for a fresh publication, verifies exact remote tag identity, creates or resumes the GitHub Release, and verifies the mandatory SBOM is attached. If Release creation succeeds but SBOM attachment fails, the workflow fails; a later dispatch resumes the same immutable tag/Release until the mandatory asset is present. That transient partial state is not reported as a successful canonical release. +The write-scoped publish job repeats the checks gate immediately before mutation, verifies the downloaded inputs, creates the annotated tag only for a fresh publication, verifies exact remote tag identity, creates or resumes the GitHub Release, and verifies the mandatory SBOM is attached. If Release creation succeeds but SBOM attachment fails, the workflow fails; a later dispatch resumes the same immutable annotated tag/Release until the mandatory asset is present. That transient partial state is not reported as a successful canonical release. ### Least privilege @@ -104,8 +104,9 @@ The current repair lineage on #1030 includes: - `788dfce254604f1d8cec1681205faf19d6125333`: production GREEN using exact `git/ref/tags/...` identity and fail-closed SBOM evidence/attachment. - `29ee4ce28d68c7dc825a998434dd944aff2352f5`: contract assertions aligned to the repaired workflow step names. - `d22586f8e8dd9be3762ed7bf02762c9f82fbf771`: release runbook brought code-current with the same invariants. +- `7a37d595ff3d39d7eb05d6030790c0c05d312741`: test-first child repair proving a lightweight release tag must be rejected rather than accepted as a resumable canonical release; merged normally into #1030 as `b73b5fed144dedf4bd4e4fb2bf5dbf886414ee40`. -Hosted GREEN is not claimed from these commits. On the previously observed `d22586f8...` head, CodeQL PR run `33688739873` ended in `startup_failure` and other required workflows were non-terminal. The central runner/control-plane owner path is tracked in `ContextualWisdomLab/.github#712`. +The current `b73b5fed...` generation has terminal repository/org review checks, including Required Noema, without a failing check-run; that does not transfer protected-main or release authority. #1030 remains Draft because it is still diverged from protected `main` and its Python-runtime prerequisite #995 remains unmerged. The central runner/control-plane owner path is tracked separately; checks on predecessor heads are historical only. ## Alternatives considered @@ -113,6 +114,10 @@ Hosted GREEN is not claimed from these commits. On the previously observed `d225 Rejected. A commit-ish resolver is not an exact tag-namespace assertion. The release identity must distinguish `refs/tags/...` from `refs/heads/...` before publication. +### Accept or convert a lightweight `vX.Y.Z` tag during resume + +Rejected. A lightweight tag has no annotated tag object and therefore does not satisfy this release mechanism's canonical identity contract. Replacing or retagging it would mutate an existing version identity. The workflow fails closed before publication mutation; the operator must repair the versioning state without moving an already-published canonical tag. + ### Let `gh release create` create a missing tag implicitly Rejected. GitHub release creation accepts a `target_commitish` when the tag does not already exist; relying on that behavior expands the mutation surface and weakens the workflow's exact-tag proof. The tag must exist and be verified before the Release is created. @@ -135,7 +140,8 @@ Positive effects: - consumers gain a future immutable owner-issued pin instead of source copying; - branch/tag namespace confusion is removed from release identity; -- interrupted tag/Release publication can be resumed without retagging; +- interrupted annotated-tag/Release publication can be resumed without retagging; +- lightweight release tags are rejected before they can be treated as canonical resume identities; - missing exact-commit SBOM evidence blocks successful publication; - write credentials are kept away from repository-controlled test execution. @@ -144,22 +150,22 @@ Costs and residual risks: - GitHub does not expose an atomic "create tag only if branch still equals SHA" operation, so a small fresh-publish check-then-act window remains after the final `main`-tip check. The mitigation is fail-closed prechecks plus exact remote tag verification; if a concurrent merge wins that window, publish a new patch/minor version and never move the earlier tag. - GitHub Release creation and asset upload are separate mutations. A Release can therefore exist temporarily without the SBOM after an interrupted run; workflow success is withheld until the asset is verified. - The current path establishes SBOM evidence but does not yet establish the broader provenance/attestation and reproducibility evidence required by the fleet's final release-ready definition. Those are follow-up acceptance items; this ADR remains Proposed until they are resolved or explicitly superseded by another owner decision. -- #1030 currently depends on #995's supported-runtime correction and on recovery of hosted runner/control-plane execution. Neither is bypassed here. +- #1030 currently depends on #995's supported-runtime correction and on reconciliation with the current protected-main lineage. Neither is bypassed here. ## Rollback and recovery Published release tags are immutable. Functional rollback is a forward fix in a new patch/minor release. Routine delete/retag is not a recovery mechanism. -For an interrupted publication on a valid existing tag, re-dispatch the same version. The workflow re-verifies that tag target and completes any missing Release/SBOM mutation without moving the tag. +For an interrupted publication on a valid existing annotated tag, re-dispatch the same version. The workflow re-verifies that tag target and completes any missing Release/SBOM mutation without moving the tag. -A tag pointing outside current `main` history, an ambiguous tag object, an unconfirmed lookup failure, a failed exact-commit check, or missing SBOM evidence is a stop condition requiring repair before publication. +A lightweight tag, a tag pointing outside current `main` history, an ambiguous tag object, an unconfirmed lookup failure, a failed exact-commit check, or missing SBOM evidence is a stop condition requiring repair before publication. ## Acceptance before status may become Accepted - #995 merged normally and #1030 non-force-restacked to the resulting protected-main descendant. - all final exact-head required repository and org-central checks terminal GREEN with no valid unresolved review findings. - release docs and product/technical gap baseline agree with the final implementation. -- first canonical version published at an exact tag with verified mandatory SBOM. +- first canonical version published at an exact annotated tag with verified mandatory SBOM. - release provenance/attestation and reproducibility requirements either implemented and tested or governed by a separate Accepted owner ADR with an explicit contract. - consumer bump validated against the released API/client/schema rather than this PR branch or an arbitrary source SHA. From b51009c8b5b6c9e79672e412a87e5b4609f42173 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 10:06:05 +0900 Subject: [PATCH 32/35] docs(release): reject lightweight-tag resume in runbook --- docs/RELEASING.md | 65 +++++++++++++++++++++++++---------------------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index fb39a7f7e..9c293ee7a 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -36,17 +36,20 @@ that separate system. through the normal PR process (review, required checks, no exceptions). 2. `CHANGELOG.md` has a `## [X.Y.Z]` section (an `- Unreleased` or dated suffix is fine) with real, non-empty content describing what changed. -3. Either no exact tag ref `refs/tags/vX.Y.Z` exists yet, or one does and - resolves to a commit that is an ancestor of `main`'s current tip (the - commit you're dispatching, or an earlier one `main` has since advanced - past) — in which case the workflow safely resumes using **the tag's own - target commit**, never a same-named branch and never the commit you happen - to be dispatching against. If that tag has no GitHub Release published - yet, the workflow creates one; if the Release already exists after a - previously interrupted publication, the workflow verifies or restores - the mandatory release assets without moving the tag. A tag that resolves - to a commit that is **not** an ancestor of `main`'s current tip is rejected - outright: bump the version rather than reusing or moving an immutable tag. +3. Either no exact tag ref `refs/tags/vX.Y.Z` exists yet, or an **annotated** + one does and resolves to a commit that is an ancestor of `main`'s current + tip (the commit you're dispatching, or an earlier one `main` has since + advanced past) — in which case the workflow safely resumes using **the + annotated tag's own target commit**, never a same-named branch and never + the commit you happen to be dispatching against. If that tag has no + GitHub Release published yet, the workflow creates one; if the Release + already exists after a previously interrupted publication, the workflow + verifies or restores the mandatory release assets without moving the tag. + A lightweight `vX.Y.Z` tag is **not** a resumable canonical release tag: + the workflow fails closed rather than replacing, promoting, or retagging + it. An annotated tag whose target is **not** an ancestor of `main`'s + current tip is also rejected outright: repair the versioning state or bump + the version rather than reusing or moving an immutable tag. 4. `main` is currently green — its own required checks (Tests, Security, Fuzz, and the org-central Strix/OpenCode/security-scan/OSV/Scorecard checks from `ContextualWisdomLab/.github`) are passing. The release @@ -84,17 +87,18 @@ that separate system. - **`verify`** (read-only): - queries `repos/$REPO/git/ref/tags/vX.Y.Z`, not the generic commits endpoint, so a same-named branch cannot impersonate a release tag. - A lightweight tag resolves directly to its commit; an annotated tag - is peeled through the tag-object API and must resolve to a commit; + A lightweight tag is classified and rejected immediately because the + canonical release identity must be annotated; an annotated tag is + peeled through the tag-object API and must resolve to a commit; - decides `TARGET_SHA`, the exact commit every later gate evaluates against. No tag yet: `TARGET_SHA` is the dispatched commit itself (a - fresh publish). A tag that exists and is an ancestor of `main`'s - current tip: `TARGET_SHA` is the *tag's own target commit* (a resume), - regardless of how far `main` has advanced. A tag that is not an - ancestor of `main` fails closed. A failed tag or Release lookup is - treated as "absent" only on a *confirmed* 404 / "release not found"; - rate-limit, auth, network, or 5xx errors fail closed instead of being - guessed away; + fresh publish). An annotated tag that exists and is an ancestor of + `main`'s current tip: `TARGET_SHA` is the *tag's own target commit* (a + resume), regardless of how far `main` has advanced. A lightweight tag, + a tag that is not an ancestor of `main`, or an unsupported tag object + fails closed. A failed tag or Release lookup is treated as "absent" + only on a *confirmed* 404 / "release not found"; rate-limit, auth, + network, or 5xx errors fail closed instead of being guessed away; - checks out `TARGET_SHA` so every subsequent step reads *that* commit's tree, never a possibly-newer `main` tip; - for a **fresh publish only**, fails closed if `TARGET_SHA` is not @@ -120,12 +124,12 @@ that separate system. while `verify` was testing; for both fresh and resumed publication, re-verifies every check for `TARGET_SHA` immediately before mutation; - verifies the downloaded release notes and SBOM are non-empty; - - creates and pushes annotated tag `vX.Y.Z` only when it does not already - exist; + - creates and pushes annotated tag `vX.Y.Z` only for a fresh publication; + a resume can only arrive here with a previously verified annotated tag; - verifies `refs/tags/vX.Y.Z` really exists on `origin`, its remote Git - object matches the fetched local tag object, and the tag peels to - `TARGET_SHA`. GitHub Release creation never gets a chance to synthesize - an implicit tag from a branch/default branch; + object matches the fetched local annotated tag object, and the tag + peels to `TARGET_SHA`. GitHub Release creation never gets a chance to + synthesize an implicit tag from a branch/default branch; - creates the GitHub Release using the verified notes unless resuming an already-created Release; - verifies `cyclonedx-sbom.json` is attached. If it is missing, uploads @@ -147,11 +151,12 @@ that separate system. ## Known limitations -**This section describes a fresh publish only.** A resume of a tag-only +**This section describes a fresh publish only.** A resume of an annotated-tag interrupted publication evaluates every gate against the tag's own target commit, which is already immutable once pushed — there is no live `main`-tip comparison to race for a resume, so the window below does not -apply to it. +apply to it. A lightweight tag is not a recovery state and is rejected before +these resume semantics apply. **A small, accepted check-then-act window remains before the tag/Release are actually created.** `publish`'s recheck of `main`'s tip and of every check @@ -172,9 +177,9 @@ overwriting the asset; investigate the provenance mismatch before retrying. A Release object can also exist temporarily without its required SBOM when `gh release create` succeeds and a later asset upload fails. That state is **not** a successful canonical release run: the workflow fails closed and a -re-dispatch resumes at the same immutable tag/Release until the mandatory -asset is present and verified. Consumers should use a version as release-ready -only after the release workflow itself has completed successfully. +re-dispatch resumes at the same immutable annotated tag/Release until the +mandatory asset is present and verified. Consumers should use a version as +release-ready only after the release workflow itself has completed successfully. ## Rollback From 1f218cb86a36997279f075a7583ec9c4fadb78cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 01:47:07 +0900 Subject: [PATCH 33/35] test(release): reproduce immutable publication and resume failures --- tests/test_release_immutable_publication.py | 277 ++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 tests/test_release_immutable_publication.py diff --git a/tests/test_release_immutable_publication.py b/tests/test_release_immutable_publication.py new file mode 100644 index 000000000..7da43c3bd --- /dev/null +++ b/tests/test_release_immutable_publication.py @@ -0,0 +1,277 @@ +"""Execute the real publisher against a stateful GitHub immutability boundary. + +Only the remote CLI is replaced. The workflow's own shell, ordering, failure +propagation, uploads and byte comparison execute without publication authority. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +_ROOT = Path(__file__).resolve().parents[1] +_TAG = "v0.2.0" +_SBOM = '{"bomFormat":"CycloneDX","version":1}\n' +_START = "Create the GitHub Release" +_RESUME = "Validate the existing release lifecycle" + +_GH = r'''#!/usr/bin/env python3 +import json +import os +import subprocess +import sys +from pathlib import Path + +state_path = Path(os.environ["FAKE_RELEASE_STATE"]) +state = json.loads(state_path.read_text()) +args = sys.argv[1:] +state["calls"].append(args) + +def save(): + state_path.write_text(json.dumps(state)) + +def fail(message): + save() + print(message, file=sys.stderr) + raise SystemExit(1) + +def output(value): + if "--jq" in args: + result = subprocess.run( + ["jq", "-r", args[args.index("--jq") + 1]], + input=json.dumps(value), text=True, capture_output=True, + ) + if result.returncode: + fail(result.stderr) + print(result.stdout, end="") + else: + print(json.dumps(value)) + +if args[:2] == ["release", "create"]: + if state["release"] is not None: + fail("release already exists") + draft = "--draft" in args + state["release"] = { + "tag_name": args[2], "draft": draft, "prerelease": False, + "immutable": not draft and state["locking"], "assets": [], + } +elif args[:2] == ["release", "view"]: + release = state["release"] + if release is None: + fail("HTTP 404") + output({"assets": release["assets"], "isDraft": release["draft"]}) +elif args[:2] == ["release", "upload"]: + release = state["release"] + if release["immutable"]: + fail("Cannot upload assets to an immutable release") + if state.get("upload_failure"): + fail("upload interrupted") + data = Path(args[3]).read_text() + state["asset_bytes"] = data + release["assets"] = [{"name": "cyclonedx-sbom.json", "size": len(data)}] +elif args[:2] == ["release", "download"]: + if state.get("download_failure"): + fail("download interrupted") + directory = Path(args[args.index("--dir") + 1]) + directory.mkdir(parents=True, exist_ok=True) + (directory / "cyclonedx-sbom.json").write_text(state["asset_bytes"]) +elif args[:2] == ["release", "edit"]: + if not state["release"]["draft"]: + fail("unexpected edit of published release") + if state.get("publish_failure"): + fail("publication interrupted") + state["release"]["draft"] = False + state["release"]["immutable"] = state["locking"] +elif args[:2] in (["release", "verify"], ["release", "verify-asset"]): + if state.get("attestation_failure"): + fail("invalid release attestation") + if not state["release"]["immutable"]: + fail("release not immutable") + if args[1] == "verify-asset" and Path(args[3]).read_text() != state["asset_bytes"]: + fail("attestation asset mismatch") +elif args and args[0] == "api": + if state.get("metadata_failure"): + fail("HTTP 503") + if state["release"] is None: + fail("HTTP 404") + output(state["release"]) +else: + fail("unexpected gh command: " + repr(args)) +save() +''' + + +def _steps() -> list[tuple[str, str, str]]: + """Extract bounded publish steps without adding a YAML dependency.""" + text = (_ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8") + publish = text.split("\n publish:\n", 1)[1] + result = [] + for block in re.split(r"(?m)^ - name: ", publish)[1:]: + name = block.splitlines()[0] + if "\n run: |\n" not in block: + continue + metadata, raw_script = block.split("\n run: |\n", 1) + script = "\n".join( + line[10:] for line in raw_script.splitlines() if line.startswith(" ") + ) + result.append((name, metadata, script)) + return result + + +def _existing(*, draft: bool, immutable: bool, asset: str | None = _SBOM) -> dict: + """Construct a remote lifecycle state, not a successful mock verdict.""" + return { + "tag_name": _TAG, + "draft": draft, + "prerelease": False, + "immutable": immutable, + "assets": [] if asset is None else [ + {"name": "cyclonedx-sbom.json", "size": len(asset)} + ], + } + + +def _run(tmp_path: Path, **changes: object) -> tuple[subprocess.CompletedProcess, dict]: + """Run publication shell steps in order and retain all remote effects.""" + state = {"release": None, "locking": True, "asset_bytes": _SBOM, "calls": []} + state.update(changes) + state_path = tmp_path / "state.json" + state_path.write_text(json.dumps(state)) + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + gh = bin_dir / "gh" + gh.write_text(f"#!{sys.executable} -S\n" + _GH.split("\n", 1)[1]) + gh.chmod(0o700) + (tmp_path / "sbom-download").mkdir() + (tmp_path / "sbom-download/cyclonedx-sbom.json").write_text(_SBOM) + (tmp_path / "release-notes.md").write_text("Release 0.2.0\n") + env = { + **os.environ, + "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + "FAKE_RELEASE_STATE": str(state_path), + "GITHUB_REPOSITORY": "ContextualWisdomLab/contextual-orchestrator", + "RELEASE_VERSION": "0.2.0", + "RELEASE_RESUME": "false" if state["release"] is None else "true", + "TMPDIR": str(tmp_path), + } + active = False + result = subprocess.CompletedProcess([], 0, "", "") + for name, metadata, script in _steps(): + active = active or name in {_START, _RESUME} + if not active: + continue + if "if: env.RELEASE_RESUME != 'true'" in metadata and state["release"] is not None: + continue + if "if: env.RELEASE_RESUME == 'true'" in metadata and state["release"] is None: + continue + result = subprocess.run( + ["bash", "-c", script], cwd=tmp_path, env=env, + text=True, capture_output=True, timeout=20, + ) + if result.returncode: + break + return result, json.loads(state_path.read_text()) + + +def _calls(state: dict, command: str) -> list: + """Select actual remote effects from the subprocess journal.""" + return [call for call in state["calls"] if call[:2] == ["release", command]] + + +def test_fresh_immutable_publication_attaches_before_locking(tmp_path: Path) -> None: + """The original public-create-then-upload sequence fails at immutable upload.""" + result, state = _run(tmp_path) + assert result.returncode == 0, result.stderr + result.stdout + assert state["release"]["immutable"] is True + assert state["release"]["draft"] is False + commands = [call[:2] for call in state["calls"]] + assert commands.index(["release", "upload"]) < commands.index(["release", "edit"]) + assert _calls(state, "verify") and _calls(state, "verify-asset") + + +def test_mutable_publication_cannot_report_success(tmp_path: Path) -> None: + """A release object and tag alone are not immutable evidence.""" + result, _ = _run(tmp_path, locking=False) + assert result.returncode != 0 + + +@pytest.mark.parametrize("asset", [None, _SBOM]) +def test_draft_resume_finishes_without_recreating(tmp_path: Path, asset: str | None) -> None: + """Resume both interrupted upload and interrupted finalization.""" + result, state = _run(tmp_path, release=_existing(draft=True, immutable=False, asset=asset)) + assert result.returncode == 0, result.stderr + result.stdout + assert not _calls(state, "create") + assert state["release"]["immutable"] is True + assert bool(_calls(state, "upload")) is (asset is None) + + +def test_public_immutable_resume_is_read_only(tmp_path: Path) -> None: + """Repeat completion verifies attestation without editing existing artifacts.""" + result, state = _run(tmp_path, release=_existing(draft=False, immutable=True)) + assert result.returncode == 0, result.stderr + result.stdout + assert not any(_calls(state, name) for name in ("create", "upload", "edit")) + assert _calls(state, "verify") and _calls(state, "verify-asset") + + +@pytest.mark.parametrize("immutable", [False, True]) +def test_incomplete_public_release_is_not_repaired_by_upload(tmp_path: Path, immutable: bool) -> None: + """Do not mutate a public release, even if its settings would allow it.""" + result, state = _run( + tmp_path, release=_existing(draft=False, immutable=immutable, asset=None), + ) + assert result.returncode != 0 + assert not any(_calls(state, name) for name in ("create", "upload", "edit")) + + +@pytest.mark.parametrize("draft,immutable", [(True, False), (False, True)]) +def test_different_existing_asset_fails_without_overwrite(tmp_path: Path, draft: bool, immutable: bool) -> None: + """Same asset name must not substitute different bytes.""" + result, state = _run( + tmp_path, release=_existing(draft=draft, immutable=immutable), asset_bytes="different", + ) + assert result.returncode != 0 + assert not _calls(state, "upload") and not _calls(state, "edit") + + +@pytest.mark.parametrize("failure", ["upload_failure", "download_failure", "publish_failure"]) +def test_failure_leaves_recoverable_draft(tmp_path: Path, failure: str) -> None: + """An incomplete publisher must not expose an asset-incomplete public release.""" + result, state = _run(tmp_path, **{failure: True}) + assert result.returncode != 0 + assert state["release"]["draft"] is True + + +@pytest.mark.parametrize("field,value", [ + ("tag_name", "v9.9.9"), ("prerelease", True), ("draft", "true"), ("immutable", None), +]) +def test_ambiguous_resume_metadata_fails_without_mutation(tmp_path: Path, field: str, value: object) -> None: + """Wrong tags and untyped lifecycle fields cannot authorize release writes.""" + release = _existing(draft=True, immutable=False) + release[field] = value + result, state = _run(tmp_path, release=release) + assert result.returncode != 0 + assert not any(_calls(state, name) for name in ("create", "upload", "edit")) + + +def test_metadata_outage_is_not_absence(tmp_path: Path) -> None: + """A release metadata outage cannot authorize a new create or upload.""" + result, state = _run( + tmp_path, release=_existing(draft=True, immutable=False, asset=None), metadata_failure=True, + ) + assert result.returncode != 0 + assert not any(_calls(state, name) for name in ("create", "upload", "edit")) + + +def test_bad_attestation_cannot_report_success(tmp_path: Path) -> None: + """Lock metadata alone is not cryptographic verification.""" + result, state = _run( + tmp_path, release=_existing(draft=False, immutable=True), attestation_failure=True, + ) + assert result.returncode != 0 + assert not any(_calls(state, name) for name in ("create", "upload", "edit")) From fdc6d4a25e061299fca5261f0f000bfb4620191c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 01:48:44 +0900 Subject: [PATCH 34/35] fix(release): verify draft assets before immutable publication --- .github/workflows/release.yml | 59 +++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f60b26dd..5a95aed1d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -185,14 +185,9 @@ jobs: } >> "${GITHUB_OUTPUT}" echo "TARGET_SHA=${target_sha}" >> "${GITHUB_ENV}" - # A published Release already existing does not mean nothing is - # left to resume: creating a Release can publish the Release - # object and then fail partway through uploading its assets, in - # which case the tag and the Release both already exist but a - # release asset can still be missing. A Release already existing - # for TARGET_SHA's tag remains a safe resume, but the publish job - # must restore and verify every mandatory release asset before it - # can report success. + # An interrupted draft can resume asset attachment. A published + # immutable release is verify-only: its assets cannot be changed. + # The publish job validates that lifecycle before any mutation. # # As with the tag lookup above, a failed `gh release view` is NOT # automatically "Release absent": only a confirmed 404 means that. @@ -205,7 +200,7 @@ jobs: exit 1 fi else - echo "::notice::Tag v${RELEASE_VERSION} already points at ${target_sha} and its GitHub Release already exists; resuming to verify or attach any still-missing mandatory release assets." + echo "::notice::Tag v${RELEASE_VERSION} already points at ${target_sha} and its GitHub Release already exists; resuming lifecycle validation before asset verification." echo "release_resume=true" >> "${GITHUB_OUTPUT}" fi @@ -375,6 +370,21 @@ jobs: exit 1 fi + - name: Validate the existing release lifecycle + if: env.RELEASE_RESUME == 'true' + run: | + set -euo pipefail + release="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/v${RELEASE_VERSION}")" + if ! jq -e --arg tag "v${RELEASE_VERSION}" ' + .tag_name == $tag and .prerelease == false and + ((.draft == true and .immutable == false) or + (.draft == false and .immutable == true and + ([.assets[] | select(.name == "cyclonedx-sbom.json" 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 + exit 1 + fi + - name: Create the GitHub Release if: env.RELEASE_RESUME != 'true' run: | @@ -382,6 +392,8 @@ jobs: gh release create "v${RELEASE_VERSION}" \ --repo "${GITHUB_REPOSITORY}" \ --title "v${RELEASE_VERSION}" \ + --verify-tag \ + --draft \ --notes-file release-notes.md - name: Attach required release SBOM @@ -413,3 +425,32 @@ jobs: echo "::error::Published SBOM differs from verified evidence; refusing to overwrite it." >&2 exit 1 fi + + - name: Publish the verified draft and require immutable attestation + run: | + set -euo pipefail + release_endpoint="repos/${GITHUB_REPOSITORY}/releases/tags/v${RELEASE_VERSION}" + release="$(gh api "${release_endpoint}")" + if ! jq -e --arg tag "v${RELEASE_VERSION}" ' + .tag_name == $tag and .prerelease == false and + ([.assets[] | select(.name == "cyclonedx-sbom.json" and (.size | type) == "number" and .size > 0)] | length) == 1 and + ((.draft == true and .immutable == false) or + (.draft == false and .immutable == true)) + ' <<<"${release}" >/dev/null; then + echo "::error::Release lifecycle or mandatory asset changed before publication." >&2 + exit 1 + fi + if [ "$(jq -r '.draft' <<<"${release}")" = "true" ]; then + gh release edit "v${RELEASE_VERSION}" --repo "${GITHUB_REPOSITORY}" --draft=false + fi + release="$(gh api "${release_endpoint}")" + if ! jq -e --arg tag "v${RELEASE_VERSION}" ' + .tag_name == $tag and .draft == false and + .prerelease == false and .immutable == true + ' <<<"${release}" >/dev/null; then + echo "::error::Published release is not immutable; it must not enter the released-owner inventory. Enable repository release immutability before publishing." >&2 + exit 1 + fi + gh release verify "v${RELEASE_VERSION}" --repo "${GITHUB_REPOSITORY}" + gh release verify-asset "v${RELEASE_VERSION}" sbom-download/cyclonedx-sbom.json \ + --repo "${GITHUB_REPOSITORY}" From c525ae41a7bb6b6816551094742ea0ef96b48544 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 01:49:36 +0900 Subject: [PATCH 35/35] docs(release): distinguish verified immutable inventory from release existence --- docs/RELEASING.md | 306 +++++++++++++++++++++------------------------- 1 file changed, 139 insertions(+), 167 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 9c293ee7a..2c3f6bcb3 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -7,186 +7,158 @@ for the full design and its explicit non-goals. ## What a release is, and is not -A release is a git tag `vX.Y.Z` and a GitHub Release built from it. It gives -downstream consumers (Keyverse, BandScope, Wardnet, and others) an immutable, -citable pin target they never again need to vendor a mutable source SHA off -`main` for. Those are two different URLs with two different guarantees: - -- `.../releases/tag/vX.Y.Z` is the actual immutable pin — always the same - commit, forever. **Consumers should pin this one.** -- `.../releases/latest` is a **mutable discovery alias** that repoints to - whatever the newest release is; it is useful for finding "what's current" - in a human workflow, but a consumer pinning to it is *not* protected from - behavior changes across future releases and should not use it as a pin - target. +A canonical release is an annotated git tag `vX.Y.Z`, a published GitHub +Release with `immutable: true`, and its verified 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 + must verify its immutable state, exact commit, and asset attestation before + admitting that version to a released-owner inventory. +- `.../releases/latest` is a **mutable discovery alias**, not a consumer pin. A release is **not** the same thing as [`/api/v1/commercial_release_candidates/latest`](commercial_release_candidate.md) -or `contextual_orchestrator/release_authorization.py`. Those answer "is this -pull request commercially/buyer-sale-ready" for a human procurement audience, -gated behind admin auth inside the running gateway. This document's release -mechanism answers a narrower question — "does an immutable, citable artifact -exist for this exact commit" — and is unaffected by, and does not affect, -that separate system. +or `contextual_orchestrator/release_authorization.py`. Those evaluate +commercial/buyer readiness inside the running gateway. This release mechanism +only establishes a citable, verified release for an exact protected commit. +It does not establish that every consumer-required API, authentication, +deployment-identity or transport contract is implemented by that release. +LifeOS and other consumers must verify their specific owner contracts too. ## Preconditions 1. The version to release is already merged to `main`: `pyproject.toml`'s - `version` field carries the exact `X.Y.Z` you intend to release, landed - through the normal PR process (review, required checks, no exceptions). -2. `CHANGELOG.md` has a `## [X.Y.Z]` section (an `- Unreleased` or dated - suffix is fine) with real, non-empty content describing what changed. -3. Either no exact tag ref `refs/tags/vX.Y.Z` exists yet, or an **annotated** - one does and resolves to a commit that is an ancestor of `main`'s current - tip (the commit you're dispatching, or an earlier one `main` has since - advanced past) — in which case the workflow safely resumes using **the - annotated tag's own target commit**, never a same-named branch and never - the commit you happen to be dispatching against. If that tag has no - GitHub Release published yet, the workflow creates one; if the Release - already exists after a previously interrupted publication, the workflow - verifies or restores the mandatory release assets without moving the tag. - A lightweight `vX.Y.Z` tag is **not** a resumable canonical release tag: - the workflow fails closed rather than replacing, promoting, or retagging - it. An annotated tag whose target is **not** an ancestor of `main`'s - current tip is also rejected outright: repair the versioning state or bump - the version rather than reusing or moving an immutable tag. -4. `main` is currently green — its own required checks (Tests, Security, - Fuzz, and the org-central Strix/OpenCode/security-scan/OSV/Scorecard - checks from `ContextualWisdomLab/.github`) are passing. The release - workflow re-verifies both automatically: the commit is genuinely `main`'s - untampered tip, and every check GitHub reports for that exact commit - (excluding the release run's own) is complete with a successful, - skipped, or neutral conclusion — it fails closed otherwise. It also - re-runs the full test suite fresh, but it does not re-run CodeQL, Trivy, - OSV, Scorecard, or the review bots — those already had to pass before this - commit could exist on protected `main` at all. - - **If you dispatch moments after a merge lands**, the gate can fail - with "expected push-triggered check(s) ... have not registered yet" — - GitHub has not finished creating this new tip's Tests/Security/Fuzz - check-run entries yet. This is expected and safe: wait for those - workflows to actually start, then re-dispatch. It is distinct from a - genuine pending/failed check, which the same gate reports as "not both - complete and green" instead. -5. The exact commit has a successful `security.yml` run exposing the - `cyclonedx-sbom` artifact and a non-empty `cyclonedx-sbom.json`. The - canonical release path treats this SBOM as required supply-chain evidence, - not optional decoration; lookup, download, empty-file, upload, or - post-upload verification failure fails the run closed. + `[project].version` is the exact `X.Y.Z`, integrated through normal review + and required checks without exceptions. +2. `CHANGELOG.md` has a non-empty `## [X.Y.Z]` release section. An + `- Unreleased` or dated suffix is supported. +3. Either `refs/tags/vX.Y.Z` does not exist, or it is an **annotated** tag + that resolves directly to a commit in protected `main` history. A resume + uses that tag's exact target, not the current dispatch commit. Lightweight + tags, unsupported tag objects and unrelated histories fail closed. Never + replace, promote, move or reuse a tag to make publication succeed. +4. The exact commit has passing required checks. The workflow rechecks + registered push-triggered jobs and the reported check rollup through + `scripts/ci/release_checks_gate.sh`, and runs the full test suite fresh. + Its existing check policy accepts terminal success, skipped or neutral + conclusions; this is not permission to treat a skipped required semantic + security action as actual review evidence. Protected integration still + requires all applicable organization gates and reviews. + A newly merged commit whose expected push checks have not yet registered + is not ready. Re-dispatch after the genuine required evidence exists; + do not weaken the expected check inventory. +5. A successful `security.yml` run for that exact commit exposes a non-empty + `cyclonedx-sbom/cyclonedx-sbom.json` artifact. Lookup, download, upload, + empty-file or content-verification failure is fatal, not best effort. +6. A repository administrator has enabled GitHub release immutability before + publication. The normal workflow token has no Administration permission; + do not add an administrative secret or expand the publisher's authority + merely to read or change this setting. The publisher validates the actual + public `immutable: true` result and release/asset attestations before + reporting success. A setting that was disabled or changed during publication + can leave a complete but mutable public release; that is a **failed** run + and is ineligible for consumption, not an automatic deletion/retagging case. +7. The runner's GitHub CLI supports `gh release verify` and + `gh release verify-asset`. Missing verification capability fails closed; + do not replace it with a filename or hash-only success claim. ## Cutting a release -1. Go to **Actions → Release → Run workflow** in the GitHub UI (or - `gh workflow run release.yml -f version=X.Y.Z`). -2. Select branch `main` (the workflow refuses to run against anything else). -3. Enter the exact version, e.g. `0.2.0` — no leading `v`, must match - `pyproject.toml` byte-for-byte. -4. Dispatch. The workflow is two jobs, least-privilege: `verify` runs with no - write permission and no persisted git credential while it executes any - repository-controlled code; `publish` holds the write token and does - nothing but verify final publication inputs, tag, and publish. In order: - - **`verify`** (read-only): - - queries `repos/$REPO/git/ref/tags/vX.Y.Z`, not the generic commits - endpoint, so a same-named branch cannot impersonate a release tag. - A lightweight tag is classified and rejected immediately because the - canonical release identity must be annotated; an annotated tag is - peeled through the tag-object API and must resolve to a commit; - - decides `TARGET_SHA`, the exact commit every later gate evaluates - against. No tag yet: `TARGET_SHA` is the dispatched commit itself (a - fresh publish). An annotated tag that exists and is an ancestor of - `main`'s current tip: `TARGET_SHA` is the *tag's own target commit* (a - resume), regardless of how far `main` has advanced. A lightweight tag, - a tag that is not an ancestor of `main`, or an unsupported tag object - fails closed. A failed tag or Release lookup is treated as "absent" - only on a *confirmed* 404 / "release not found"; rate-limit, auth, - network, or 5xx errors fail closed instead of being guessed away; - - checks out `TARGET_SHA` so every subsequent step reads *that* commit's - tree, never a possibly-newer `main` tip; - - for a **fresh publish only**, fails closed if `TARGET_SHA` is not - `main`'s current tip. A resume skips this comparison because `main` - having advanced past the tag is precisely the recoverable case; - - fails closed (via `scripts/ci/release_checks_gate.sh`) unless all - repository-owned expected push checks have registered for - `TARGET_SHA` and every check GitHub reports for it is terminal-green, - skipped, or neutral; - - fails closed if the input version does not match `TARGET_SHA`'s - `pyproject.toml` `[project]` table; - - runs the full test suite fresh on `TARGET_SHA` (`uv run --locked - --extra api --extra db --extra queue --group dev python -m pytest -q`); - - renders release notes from `TARGET_SHA`'s `CHANGELOG.md` matching - section (`scripts/ci/release_notes.py`); - - requires a successful exact-commit `security.yml` run, downloads its - `cyclonedx-sbom` artifact, and rejects a missing or empty - `sbom-download/cyclonedx-sbom.json`; - - uploads both the rendered notes and SBOM for `publish`; a missing file - is an error, not an ignored artifact condition. - - **`publish`** (write-scoped, only after `verify` succeeds): - - for a **fresh publish only**, re-verifies `main`'s tip has not advanced - while `verify` was testing; for both fresh and resumed publication, - re-verifies every check for `TARGET_SHA` immediately before mutation; - - verifies the downloaded release notes and SBOM are non-empty; - - creates and pushes annotated tag `vX.Y.Z` only for a fresh publication; - a resume can only arrive here with a previously verified annotated tag; - - verifies `refs/tags/vX.Y.Z` really exists on `origin`, its remote Git - object matches the fetched local annotated tag object, and the tag - peels to `TARGET_SHA`. GitHub Release creation never gets a chance to - synthesize an implicit tag from a branch/default branch; - - creates the GitHub Release using the verified notes unless resuming an - already-created Release; - - verifies `cyclonedx-sbom.json` is attached. If it is missing, uploads - it and then re-reads the Release assets. Upload or verification failure - fails the workflow closed; re-dispatch resumes without moving the tag. -5. Confirm both the versioned Release and its `cyclonedx-sbom.json` asset at - `.../releases/tag/vX.Y.Z`. Use `/releases/latest` only to discover the - newest version, never as an immutable consumer pin. +1. Use **Actions → Release → Run workflow**, or + `gh workflow run release.yml -f version=X.Y.Z`. +2. Select branch `main`; other refs cannot publish. +3. Enter `X.Y.Z` without a leading `v`, matching `[project].version` exactly. +4. The read-only `verify` job: + - classifies the exact `refs/tags/vX.Y.Z`, rejecting lightweight tags; + - peels an annotated tag to its commit and verifies protected-main ancestry; + - treats only confirmed 404 / release-not-found lookups as absence, not + rate-limit, authentication, network or server errors; + - checks out `TARGET_SHA`; a fresh publication must match the current + protected-main tip, while a resume may use its verified ancestor; + - checks the exact-head rollup and project version; + - runs `uv run --locked --extra api --extra db --extra queue --group dev + python -m pytest -q`; + - renders notes from the exact commit's CHANGELOG section; + - downloads the exact-commit mandatory CycloneDX SBOM and passes both + notes and SBOM 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 and SBOM inputs; + - 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 **only while the release is a Draft**, then + downloads it and compares its bytes with the verified input; + - publishes the verified Draft. An already-public immutable release is + verify-only and is neither recreated, edited nor uploaded to; + - requires the resulting release to be non-Draft, non-prerelease, + matching-tag and `immutable: true`, then runs `gh release verify` and + `gh release verify-asset` for the exact SBOM. +6. Confirm the version-specific release, successful publication run, exact + tag/commit, immutable state, SBOM and its signed attestation. Do not admit + a release merely because it appears in the GitHub Releases list. + +## Recovery and known limitations + +An interrupted Draft is recoverable without deleting it or moving its tag. +The same version can resume missing-asset upload or final publication after +all current gates pass. Existing assets are compared byte-for-byte and are +never clobbered. A public release missing its mandatory asset, a mutable +public release, mismatching tag, prerelease, untyped lifecycle field or +unavailable metadata is rejected before resume-side mutation. + +A completed immutable release can be checked again without mutation. An +attestation failure, including a temporarily unavailable attestation, is +non-passing evidence; a later verification retry does not require deleting +or republishing that release. + +The final main-tip check and tag creation are not an atomic compare-and-set. +A concurrent merge can land in that interval. The publisher checks exact tag +identity, but a verified fresh tip might no longer be the newest tip by the +time publication completes. Correct the version through another normal +release; never move the previous tag. For an annotated-tag resume, all gates +bind to that tag's target rather than the newer main tip. + +Draft assets remain mutable until publication, so another authorized publisher +can race the draft content comparison. The post-publication signed asset +verification detects a mismatch and fails the run; it does not roll back +publication or authorize a mismatching asset for consumers. Repository-wide +publisher serialization and maintainer access control remain necessary. + +Release immutability must be enabled by the repository owner. This workflow +neither changes that administrative setting nor treats a successfully created +but mutable public release as safe. Enabling the setting afterward does not +retroactively validate a failed release. Investigate it and use a new reviewed +version rather than automatically deleting or recycling its tag. ## After a release -- Bump `pyproject.toml`'s `version` and open a new `## [next-version] - - Unreleased` `CHANGELOG.md` section in an ordinary PR, so the repository is - never left claiming to already be the version it just released. -- Downstream consumers with an open handoff on this gap - (`ContextualWisdomLab/keyverse#132`, `ContextualWisdomLab/bandscope#881`, - the Wardnet consumer-owner handoff on `contextual-orchestrator#971`) can - bump to the published tag instead of a vendored source SHA. - -## Known limitations - -**This section describes a fresh publish only.** A resume of an annotated-tag -interrupted publication evaluates every gate against the tag's own target -commit, which is already immutable once pushed — there is no live -`main`-tip comparison to race for a resume, so the window below does not -apply to it. A lightweight tag is not a recovery state and is rejected before -these resume semantics apply. - -**A small, accepted check-then-act window remains before the tag/Release are -actually created.** `publish`'s recheck of `main`'s tip and of every check -for that commit runs before publication, but GitHub exposes no atomic -"create this tag only if branch X is still at commit Y" API. The workflow -therefore minimizes the window, rejects a stale fresh publish, and verifies -the exact remote tag identity before creating the GitHub Release. If a -concurrent merge lands after the final tip check but before the tag push, the -released commit can have been `main`'s verified tip moments earlier rather -than the newest tip. Cut a new patch/minor release from the intended current -tip; never move or overwrite the earlier immutable tag. - -An existing SBOM asset is downloaded and compared byte-for-byte with the -verified artifact before publication succeeds. Matching filenames alone are -insufficient. A download failure or different content fails the run without -overwriting the asset; investigate the provenance mismatch before retrying. - -A Release object can also exist temporarily without its required SBOM when -`gh release create` succeeds and a later asset upload fails. That state is -**not** a successful canonical release run: the workflow fails closed and a -re-dispatch resumes at the same immutable annotated tag/Release until the -mandatory asset is present and verified. Consumers should use a version as -release-ready only after the release workflow itself has completed successfully. +Bump `[project].version` and open the next non-empty CHANGELOG section in an +ordinary PR. Consumers with owner handoffs, including `keyverse#132`, +`bandscope#881`, `contextual-orchestrator#971` and LifeOS's `#1023` dependency, +can propose a version bump only after their required released API and runtime +contracts have been verified. A tag-only, source-checkout or unreleased branch +substitution is not a consumer repair. ## Rollback -Releases are immutable — never delete or retag a published release to "fix" -it. If a released commit turns out to be broken, release a new patch/minor -version with the fix through the same process above. `gh release delete -vX.Y.Z` (and its tag) is reserved for a genuine publishing mistake caught -immediately after dispatch, before any consumer could plausibly have pinned -it, and should still be treated as an exceptional, logged action, not routine -practice. +Do not delete, retag or overwrite a published release to repair a defect. +Publish a new patch/minor version through the same reviewed process. Deleting +an immutable release is not a way to reuse its tag name. Incomplete Drafts +are resumed rather than deleted by this workflow. + +## Normative references + +- [GitHub immutable releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases) +- [GitHub CLI release creation](https://cli.github.com/manual/gh_release_create) +- [Release attestation verification](https://cli.github.com/manual/gh_release_verify) +- [Release asset verification](https://cli.github.com/manual/gh_release_verify-asset) + +The executable local regression is +`python -m pytest -q tests/test_release_immutable_publication.py`. It executes +the publisher's real shell against a stateful CLI boundary with no network or +publication authority. This is scoped regression evidence, not a substitute +for hosted exact-head checks, independent review, or real release verification.