From 9990a999fd9e7413d98f830d3bda495470c44e8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:32:38 +0900 Subject: [PATCH 01/28] fix(test): install Starlette TestClient dependency Assisted-by: OpenAI Codex Signed-off-by: Seongho Bae --- CHANGELOG.md | 1 + backend/pyproject.toml | 1 + backend/pytest.ini | 1 - backend/requirements-hashes.txt | 17 ++++++++ backend/requirements.txt | 1 + .../test_container_dependency_pin_contract.py | 4 ++ backend/uv.lock | 40 +++++++++++++++++++ 7 files changed, 64 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ec84c36f..b0f3cf8c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## [Unreleased] +- Starlette `TestClient`의 기존 `httpx2==2.5.0` pin을 core 개발·테스트 의존성으로 승격하고, deprecated `httpx` fallback 경고 억제를 제거했습니다. - 긴 이메일·첨부 본문을 의미 단위 청크로 임베딩한 뒤 기존 email/attachment 벡터 계약으로 평균화하고, 청크 요청·벡터 누적을 제한된 창으로 처리합니다. OpenAI `text-embedding-3-*`에는 저장 차원(`1536`)을 직접 요청하도록 보강했습니다. 합성 메일 fixture 5건(70청크)과 provider 요청 계약으로 1,536차원 벡터 경로를 검증했으며, 실행 시 선택한 임베딩 제공자에 본문·파싱된 첨부 텍스트를 전송할 수 있습니다. 회사 기밀 데이터는 fixture·commit·PR·log에 포함하지 않습니다. - EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다. diff --git a/backend/pyproject.toml b/backend/pyproject.toml index c156d9776..046829400 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -43,6 +43,7 @@ dependencies = [ [dependency-groups] dev = [ "coverage==7.15.1", + "httpx2==2.5.0", "pytest==9.1.1", "pytest-asyncio==1.4.0", "ruff==0.15.21", diff --git a/backend/pytest.ini b/backend/pytest.ini index 4e58599ac..c57a00260 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -1,7 +1,6 @@ [pytest] asyncio_default_fixture_loop_scope = function filterwarnings = - ignore:Using `httpx` with `starlette.testclient` is deprecated.*:starlette.exceptions.StarletteDeprecationWarning ignore:You are using a Python version.*which Google will stop supporting.*:FutureWarning ignore:Unclosed None frontend_lock = yaml.safe_load(read_repo_text("frontend/pnpm-lock.yaml")) assert backend_pins["cryptography"] == "50.0.0" + assert backend_pins["httpx2"] == "2.5.0" assert backend_pins["protobuf"] == "7.35.1" assert "cryptography==50.0.0" in backend_records + assert "httpx2==2.5.0" in backend_records assert "protobuf==7.35.1" in backend_records assert all( re.fullmatch(r"[0-9a-f]{64}", digest) for pin in ("cryptography==50.0.0", "protobuf==7.35.1") for digest in backend_records[pin] ) + pytest_config = read_repo_text("backend/pytest.ini") + assert "Using `httpx` with `starlette.testclient` is deprecated" not in pytest_config assert strix_pins["cryptography"] == "50.0.0" assert strix_pins["protobuf"] == "6.33.6" diff --git a/backend/uv.lock b/backend/uv.lock index 2f0d04a76..d455f2a61 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -683,6 +683,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/06/5c12df521b5322fb1114a83d46911b2fbcb8855ddb3a635f11c01a214af5/httpcore2-2.5.0.tar.gz", hash = "sha256:88aa170137c17328d5ac44234f9fd10706466d5fb347f3edac4d39b91137b09d", size = 64808, upload-time = "2026-06-25T14:16:56.472Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/a1/7564199d1a8728fe737b0a72e5b3f8d92dfe085a74ddf7cdd83bce5f206d/httpcore2-2.5.0-py3-none-any.whl", hash = "sha256:5ce35188de461d31e8d000bfb8ef8bf22c6c16587a211e5571deaa5e9bdf842a", size = 80330, upload-time = "2026-06-25T14:16:53.634Z" }, +] + [[package]] name = "httplib2" version = "0.32.0" @@ -710,6 +723,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx2" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/e2/b5dedc0cf35aa65de5f541ccd30d2bc1fd7f1d43c9ab09f8ed9a7342317b/httpx2-2.5.0.tar.gz", hash = "sha256:e2df9cb4611021527ff8a675b1c320b610a2ec397acc8d6fe6e91df2d9b33c29", size = 83121, upload-time = "2026-06-25T14:16:57.491Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/22/859d8252dad9bc9adee34b52e62cde621ece07b042ccb2ab4da1be46695f/httpx2-2.5.0-py3-none-any.whl", hash = "sha256:3d2d4d9cf4b61f1a1f46a95947cfdb47e80cb56a2f91c6256ac8f58e4891df41", size = 76652, upload-time = "2026-06-25T14:16:55.23Z" }, +] + [[package]] name = "icalendar" version = "7.2.0" @@ -1019,6 +1048,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "coverage" }, + { name = "httpx2" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, @@ -1065,6 +1095,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "coverage", specifier = "==7.15.1" }, + { name = "httpx2", specifier = "==2.5.0" }, { name = "pytest", specifier = "==9.1.1" }, { name = "pytest-asyncio", specifier = "==1.4.0" }, { name = "ruff", specifier = "==0.15.21" }, @@ -1972,6 +2003,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/2a/5e5e750890ada51017d18d0d4c30da696e5b5bd3180947729927628fc3cb/tqdm-4.68.4-py3-none-any.whl", hash = "sha256:5168118b2368f48c561afda8020fd79195b1bdb0bdf8086b88442c267a315dc2", size = 676612, upload-time = "2026-07-07T09:58:16.256Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" From 3a4ec5833db649994dc0042653d1d29f71010cfd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:43:57 +0900 Subject: [PATCH 02/28] test(deps): verify Starlette client pin hashes Include the new direct httpx2 pin in the SHA-256 digest contract instead of checking only record presence. Assisted-by: OpenAI Codex Signed-off-by: Seongho Bae --- backend/tests/test_container_dependency_pin_contract.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_container_dependency_pin_contract.py b/backend/tests/test_container_dependency_pin_contract.py index ec9f35668..ff9385580 100644 --- a/backend/tests/test_container_dependency_pin_contract.py +++ b/backend/tests/test_container_dependency_pin_contract.py @@ -104,7 +104,11 @@ def test_container_provenance_dependency_pins_match_reviewed_manifests() -> None assert "protobuf==7.35.1" in backend_records assert all( re.fullmatch(r"[0-9a-f]{64}", digest) - for pin in ("cryptography==50.0.0", "protobuf==7.35.1") + for pin in ( + "cryptography==50.0.0", + "httpx2==2.5.0", + "protobuf==7.35.1", + ) for digest in backend_records[pin] ) pytest_config = read_repo_text("backend/pytest.ini") From 52dfc863d1a5d6e4e80b6366f719dd09f2aa6172 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:49:09 +0900 Subject: [PATCH 03/28] test(deps): verify Starlette httpx2 runtime --- .../test_container_dependency_pin_contract.py | 8 +++- .../starlette-httpx2-testclient-dependency.md | 48 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 docs/doctoring/starlette-httpx2-testclient-dependency.md diff --git a/backend/tests/test_container_dependency_pin_contract.py b/backend/tests/test_container_dependency_pin_contract.py index ff9385580..0141866cb 100644 --- a/backend/tests/test_container_dependency_pin_contract.py +++ b/backend/tests/test_container_dependency_pin_contract.py @@ -123,7 +123,6 @@ def test_container_provenance_dependency_pins_match_reviewed_manifests() -> None for pin in ("cryptography==50.0.0", "protobuf==6.33.6") for digest in strix_records[pin] ) - root_importer = frontend_lock["importers"]["."] postcss_resolution = importer_resolution( root_importer, "devDependencies", "postcss" @@ -152,3 +151,10 @@ def test_container_provenance_dependency_pins_match_reviewed_manifests() -> None "undici@8.9.0", ): assert exact_lock_entry in package_records + + +def test_starlette_testclient_uses_httpx2_runtime() -> None: + """Exercise Starlette's preferred TestClient transport dependency.""" + from starlette import testclient + + assert testclient.httpx.__name__ == "httpx2" diff --git a/docs/doctoring/starlette-httpx2-testclient-dependency.md b/docs/doctoring/starlette-httpx2-testclient-dependency.md new file mode 100644 index 000000000..c66b73990 --- /dev/null +++ b/docs/doctoring/starlette-httpx2-testclient-dependency.md @@ -0,0 +1,48 @@ +# Starlette TestClient `httpx2` dependency + +## Observed failure + +Protected `develop@042b0c70531b229af3acbd0421a2f23098d848b3` pins Starlette +1.3.1 but did not install `httpx2`. Importing `starlette.testclient` therefore +fell back to deprecated `httpx`; warning-as-error test runs stopped during +collection. Removing the warning filter without installing the preferred +transport would expose the defect without repairing it. + +## Decision and boundary + +Pin `httpx2==2.5.0` in the repository's existing combined backend +development/direct-test manifests and immutable locks. Keep application HTTP +clients on their existing `httpx` path. A runtime regression test imports +Starlette's TestClient module and verifies that its selected transport module is +`httpx2`; manifest and digest checks alone are insufficient evidence. + +Starlette 1.2.0 introduced TestClient support for `httpx2`, and 1.3.0 added it +to the `full` extra. The 2.5.0 wheel in this change matches PyPI's published +SHA-256 digest `3d2d4d9cf4b61f1a1f46a95947cfdb47e80cb56a2f91c6256ac8f58e4891df41`. +PyPI records a trusted-publishing attestation from the `pydantic/httpx2` +repository at tag `v2.5.0`. These facts establish origin and integrity; they do +not transfer current-head CI or protected-merge authority. + +## Verification and rollback + +Run from `backend/`: + +```bash +uv run --frozen pytest -q -W error tests/test_container_dependency_pin_contract.py +uv run --frozen ruff check tests/test_container_dependency_pin_contract.py +``` + +Rollback removes the direct pin, regenerated lock records, runtime assertion, +and obsolete-warning-filter removal together. Do not restore only the warning +suppression. + +## References + +Kludex. (2026). *Starlette release notes*. GitHub. +https://github.com/Kludex/starlette/blob/main/docs/release-notes.md + +Python Packaging Authority. (2026). *httpx2 2.5.0 file details and provenance*. +PyPI. https://pypi.org/project/httpx2/2.5.0/ + +Pydantic. (2026). *HTTPX2 v2.5.0* [Source code]. GitHub. +https://github.com/pydantic/httpx2/tree/v2.5.0 From 73ac7652f9ebc817d8ce3f6bd36e53ef906b2dc7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 22 Sep 2026 15:48:00 +0900 Subject: [PATCH 04/28] ci(deps): regenerate backend uv lock reproducibly --- .../workflows/backend-uv-lock-regenerate.yml | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 .github/workflows/backend-uv-lock-regenerate.yml diff --git a/.github/workflows/backend-uv-lock-regenerate.yml b/.github/workflows/backend-uv-lock-regenerate.yml new file mode 100644 index 000000000..093b51669 --- /dev/null +++ b/.github/workflows/backend-uv-lock-regenerate.yml @@ -0,0 +1,80 @@ +name: Backend uv.lock regeneration + +on: + push: + branches: + - codex/starlette-testclient-dependency + +permissions: + contents: write + +concurrency: + group: backend-uv-lock-regenerate-${{ github.ref }} + cancel-in-progress: false + +jobs: + regenerate: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - name: Harden the runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 + with: + egress-policy: audit + + - name: Checkout owner branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: codex/starlette-testclient-dependency + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: '3.14' + + - name: Install pinned uv + run: python -m pip install --disable-pip-version-check 'uv==0.10.0' + + - name: Regenerate only the AnyIO resolution + shell: bash + run: | + set -euo pipefail + cp backend/uv.lock /tmp/uv.lock.before + cd backend + uv lock --upgrade-package anyio + cd .. + python - <<'PY' + import tomllib + from pathlib import Path + + before = tomllib.loads(Path('/tmp/uv.lock.before').read_text()) + after = tomllib.loads(Path('backend/uv.lock').read_text()) + + before_other = [p for p in before['package'] if p.get('name') != 'anyio'] + after_other = [p for p in after['package'] if p.get('name') != 'anyio'] + if before_other != after_other: + raise SystemExit('uv lock changed packages outside AnyIO; refusing to commit') + + anyio = [p for p in after['package'] if p.get('name') == 'anyio'] + if len(anyio) != 1 or anyio[0].get('version') != '4.14.2': + raise SystemExit(f'expected exactly AnyIO 4.14.2, got {anyio!r}') + PY + git diff --check -- backend/uv.lock + git diff --exit-code -- backend/pyproject.toml backend/requirements.txt backend/requirements-hashes.txt + git diff -- backend/uv.lock + + - name: Commit generated lock only + shell: bash + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add backend/uv.lock + git diff --cached --quiet && { echo 'No generated lock delta'; exit 1; } + if git diff --cached --name-only | grep -vx 'backend/uv.lock'; then + echo 'Refusing to commit files outside backend/uv.lock' + exit 1 + fi + git commit -m 'fix(deps): regenerate AnyIO uv lock' + git push origin HEAD:codex/starlette-testclient-dependency From 748f71b25e5109e4087c0355f3c26b62c2b2fbb2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 22 Sep 2026 18:42:24 +0900 Subject: [PATCH 05/28] fix(ci): constrain AnyIO lock regeneration --- .github/workflows/backend-uv-lock-regenerate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/backend-uv-lock-regenerate.yml b/.github/workflows/backend-uv-lock-regenerate.yml index 093b51669..ba9786c51 100644 --- a/.github/workflows/backend-uv-lock-regenerate.yml +++ b/.github/workflows/backend-uv-lock-regenerate.yml @@ -42,7 +42,7 @@ jobs: set -euo pipefail cp backend/uv.lock /tmp/uv.lock.before cd backend - uv lock --upgrade-package anyio + uv lock --upgrade-package 'anyio==4.14.2' cd .. python - <<'PY' import tomllib From 9cf41ce411d9021116aac7df38b8270d4c4c189e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:30:04 +0000 Subject: [PATCH 06/28] fix(deps): regenerate AnyIO uv lock --- backend/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/uv.lock b/backend/uv.lock index d455f2a61..cad60793a 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -59,15 +59,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.14.1" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, + { 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]] From 269ac523fdbda2110d2aa6d5a9f71037c4d9988b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 22 Sep 2026 21:49:10 +0900 Subject: [PATCH 07/28] chore(ci): remove completed uv lock helper --- .../workflows/backend-uv-lock-regenerate.yml | 80 ------------------- 1 file changed, 80 deletions(-) delete mode 100644 .github/workflows/backend-uv-lock-regenerate.yml diff --git a/.github/workflows/backend-uv-lock-regenerate.yml b/.github/workflows/backend-uv-lock-regenerate.yml deleted file mode 100644 index ba9786c51..000000000 --- a/.github/workflows/backend-uv-lock-regenerate.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Backend uv.lock regeneration - -on: - push: - branches: - - codex/starlette-testclient-dependency - -permissions: - contents: write - -concurrency: - group: backend-uv-lock-regenerate-${{ github.ref }} - cancel-in-progress: false - -jobs: - regenerate: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - name: Harden the runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 - with: - egress-policy: audit - - - name: Checkout owner branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - ref: codex/starlette-testclient-dependency - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 - with: - python-version: '3.14' - - - name: Install pinned uv - run: python -m pip install --disable-pip-version-check 'uv==0.10.0' - - - name: Regenerate only the AnyIO resolution - shell: bash - run: | - set -euo pipefail - cp backend/uv.lock /tmp/uv.lock.before - cd backend - uv lock --upgrade-package 'anyio==4.14.2' - cd .. - python - <<'PY' - import tomllib - from pathlib import Path - - before = tomllib.loads(Path('/tmp/uv.lock.before').read_text()) - after = tomllib.loads(Path('backend/uv.lock').read_text()) - - before_other = [p for p in before['package'] if p.get('name') != 'anyio'] - after_other = [p for p in after['package'] if p.get('name') != 'anyio'] - if before_other != after_other: - raise SystemExit('uv lock changed packages outside AnyIO; refusing to commit') - - anyio = [p for p in after['package'] if p.get('name') == 'anyio'] - if len(anyio) != 1 or anyio[0].get('version') != '4.14.2': - raise SystemExit(f'expected exactly AnyIO 4.14.2, got {anyio!r}') - PY - git diff --check -- backend/uv.lock - git diff --exit-code -- backend/pyproject.toml backend/requirements.txt backend/requirements-hashes.txt - git diff -- backend/uv.lock - - - name: Commit generated lock only - shell: bash - run: | - set -euo pipefail - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add backend/uv.lock - git diff --cached --quiet && { echo 'No generated lock delta'; exit 1; } - if git diff --cached --name-only | grep -vx 'backend/uv.lock'; then - echo 'Refusing to commit files outside backend/uv.lock' - exit 1 - fi - git commit -m 'fix(deps): regenerate AnyIO uv lock' - git push origin HEAD:codex/starlette-testclient-dependency From e9d8573bf38f7710d9dba79af9af8b8b912bd90e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 22 Sep 2026 21:55:06 +0900 Subject: [PATCH 08/28] revert(deps): quarantine source-writing lock adoption --- backend/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/uv.lock b/backend/uv.lock index cad60793a..d455f2a61 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -59,15 +59,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.14.2" +version = "4.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, ] [[package]] From f1b964768eebf527fd487e6ca9bd7c800173ea8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 22 Sep 2026 21:55:30 +0900 Subject: [PATCH 09/28] fix(deps): adopt verified AnyIO 4.14.2 lock --- backend/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/uv.lock b/backend/uv.lock index d455f2a61..cad60793a 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -59,15 +59,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.14.1" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, + { 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]] From 7cec26a24eb229205bdc250aba71d37315bac086 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 23 Sep 2026 02:59:37 +0900 Subject: [PATCH 10/28] ci(deps): generate read-only httpx2 security resolution evidence --- .../workflows/temporary-httpx2-resolution.yml | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/temporary-httpx2-resolution.yml diff --git a/.github/workflows/temporary-httpx2-resolution.yml b/.github/workflows/temporary-httpx2-resolution.yml new file mode 100644 index 000000000..7b476fadf --- /dev/null +++ b/.github/workflows/temporary-httpx2-resolution.yml @@ -0,0 +1,55 @@ +name: Temporary httpx2 lock resolution + +on: + pull_request: + branches: [develop] + +permissions: + contents: read + +jobs: + resolve: + if: github.head_ref == 'codex/starlette-testclient-dependency' + runs-on: ubuntu-latest + steps: + - name: Checkout exact PR head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha }} + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.14' + + - name: Install pinned resolver + run: python -m pip install --disable-pip-version-check 'uv==0.10.0' + + - name: Resolve secure TestClient dependency without writing repository history + shell: bash + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + for path in (Path('backend/requirements.txt'), Path('backend/pyproject.toml')): + text = path.read_text() + old = 'httpx2==2.5.0' + assert text.count(old) == 1, (path, text.count(old)) + path.write_text(text.replace(old, 'httpx2==2.12.0')) + PY + ( + cd backend + uv lock --upgrade-package 'httpx2==2.12.0' + ) + uv pip compile \ + --generate-hashes \ + --python-version 3.14 \ + --python-platform x86_64-unknown-linux-gnu \ + --output-file backend/requirements-hashes.txt \ + backend/requirements.txt + git diff --check + echo '=== GENERATED HTTPX2 RESOLUTION DIFF START ===' + git diff -- backend/requirements.txt backend/pyproject.toml backend/requirements-hashes.txt backend/uv.lock + echo '=== GENERATED HTTPX2 RESOLUTION DIFF END ===' + sha256sum backend/requirements-hashes.txt backend/uv.lock From 1ceb2f40b8a62dc4c2084ab674cbac5bbbe09591 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 23 Sep 2026 06:42:06 +0900 Subject: [PATCH 11/28] ci(deps): preserve exact resolver evidence artifact --- .../workflows/temporary-httpx2-resolution.yml | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/.github/workflows/temporary-httpx2-resolution.yml b/.github/workflows/temporary-httpx2-resolution.yml index 7b476fadf..1e94d1253 100644 --- a/.github/workflows/temporary-httpx2-resolution.yml +++ b/.github/workflows/temporary-httpx2-resolution.yml @@ -49,7 +49,34 @@ jobs: --output-file backend/requirements-hashes.txt \ backend/requirements.txt git diff --check + mkdir -p .resolver-evidence + git diff --binary -- \ + backend/requirements.txt \ + backend/pyproject.toml \ + backend/requirements-hashes.txt \ + backend/uv.lock \ + > .resolver-evidence/generated.diff + sha256sum \ + backend/requirements.txt \ + backend/pyproject.toml \ + backend/requirements-hashes.txt \ + backend/uv.lock \ + | tee .resolver-evidence/sha256.txt echo '=== GENERATED HTTPX2 RESOLUTION DIFF START ===' - git diff -- backend/requirements.txt backend/pyproject.toml backend/requirements-hashes.txt backend/uv.lock + cat .resolver-evidence/generated.diff echo '=== GENERATED HTTPX2 RESOLUTION DIFF END ===' - sha256sum backend/requirements-hashes.txt backend/uv.lock + + - name: Upload exact generated resolver evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: httpx2-resolution-${{ github.event.pull_request.head.sha }} + path: | + backend/requirements.txt + backend/pyproject.toml + backend/requirements-hashes.txt + backend/uv.lock + .resolver-evidence/generated.diff + .resolver-evidence/sha256.txt + if-no-files-found: error + retention-days: 7 + compression-level: 0 From 4e5f97142e6a221ca2c916f9162c0f450ef0b087 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 23 Sep 2026 08:59:48 +0900 Subject: [PATCH 12/28] ci(deps): adopt verified resolver output once --- .../workflows/temporary-httpx2-resolution.yml | 203 +++++++++++++++--- 1 file changed, 170 insertions(+), 33 deletions(-) diff --git a/.github/workflows/temporary-httpx2-resolution.yml b/.github/workflows/temporary-httpx2-resolution.yml index 1e94d1253..f1a2c6973 100644 --- a/.github/workflows/temporary-httpx2-resolution.yml +++ b/.github/workflows/temporary-httpx2-resolution.yml @@ -1,21 +1,28 @@ -name: Temporary httpx2 lock resolution +name: Temporary httpx2 owner adoption on: pull_request: branches: [develop] permissions: - contents: read + contents: write + +concurrency: + group: httpx2-owner-adoption-${{ github.event.pull_request.number }} + cancel-in-progress: false jobs: - resolve: - if: github.head_ref == 'codex/starlette-testclient-dependency' + adopt: + if: >- + github.head_ref == 'codex/starlette-testclient-dependency' && + github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: - name: Checkout exact PR head uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - persist-credentials: false + persist-credentials: true + fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} - name: Set up Python @@ -26,17 +33,18 @@ jobs: - name: Install pinned resolver run: python -m pip install --disable-pip-version-check 'uv==0.10.0' - - name: Resolve secure TestClient dependency without writing repository history + - name: Reproduce and verify exact resolver output shell: bash run: | set -euo pipefail python - <<'PY' from pathlib import Path + for path in (Path('backend/requirements.txt'), Path('backend/pyproject.toml')): - text = path.read_text() + text = path.read_text(encoding='utf-8') old = 'httpx2==2.5.0' assert text.count(old) == 1, (path, text.count(old)) - path.write_text(text.replace(old, 'httpx2==2.12.0')) + path.write_text(text.replace(old, 'httpx2==2.12.0'), encoding='utf-8') PY ( cd backend @@ -48,35 +56,164 @@ jobs: --python-platform x86_64-unknown-linux-gnu \ --output-file backend/requirements-hashes.txt \ backend/requirements.txt - git diff --check mkdir -p .resolver-evidence - git diff --binary -- \ - backend/requirements.txt \ - backend/pyproject.toml \ - backend/requirements-hashes.txt \ - backend/uv.lock \ - > .resolver-evidence/generated.diff sha256sum \ backend/requirements.txt \ backend/pyproject.toml \ backend/requirements-hashes.txt \ backend/uv.lock \ - | tee .resolver-evidence/sha256.txt - echo '=== GENERATED HTTPX2 RESOLUTION DIFF START ===' - cat .resolver-evidence/generated.diff - echo '=== GENERATED HTTPX2 RESOLUTION DIFF END ===' + > .resolver-evidence/sha256.txt + cat > .resolver-evidence/expected-sha256.txt <<'EOF' + a0bbed543860fe2dff63e1a7ddd96b9b843a12acd7c4838b123a21c97967a22d backend/requirements.txt + 58b89778097bc23f9bad06bdeb4a1207f7f304edd58bb168782600b5c8379fdb backend/pyproject.toml + 2870d4f4af934b41e9dcdb769be3ce373c11e7bb58473163277a00cd432cae19 backend/requirements-hashes.txt + d6a249e3a22a3e9130cee5992f31c5a3ecc13d38898dbfa11684fdfb0be467d5 backend/uv.lock + EOF + diff -u .resolver-evidence/expected-sha256.txt .resolver-evidence/sha256.txt + git diff --check - - name: Upload exact generated resolver evidence - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: httpx2-resolution-${{ github.event.pull_request.head.sha }} - path: | - backend/requirements.txt - backend/pyproject.toml - backend/requirements-hashes.txt - backend/uv.lock - .resolver-evidence/generated.diff - .resolver-evidence/sha256.txt - if-no-files-found: error - retention-days: 7 - compression-level: 0 + - name: Update contract tests and traceability + shell: bash + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + + test_path = Path('backend/tests/test_container_dependency_pin_contract.py') + test_text = test_path.read_text(encoding='utf-8') + replacements = { + 'backend_pins["httpx2"] == "2.5.0"': 'backend_pins["httpx2"] == "2.12.0"', + '"httpx2==2.5.0" in backend_records': '"httpx2==2.12.0" in backend_records', + ' "httpx2==2.5.0",': ' "httpx2==2.12.0",', + } + for old, new in replacements.items(): + assert test_text.count(old) == 1, (old, test_text.count(old)) + test_text = test_text.replace(old, new) + test_path.write_text(test_text, encoding='utf-8') + + changelog_path = Path('CHANGELOG.md') + changelog = changelog_path.read_text(encoding='utf-8') + old_line = '- Starlette `TestClient`의 기존 `httpx2==2.5.0` pin을 core 개발·테스트 의존성으로 승격하고, deprecated `httpx` fallback 경고 억제를 제거했습니다.' + new_line = '- Starlette `TestClient`의 preferred transport를 `httpx2==2.12.0`으로 고정하고, resolver가 선택한 `httpcore2==2.12.0`과 hash/uv lock을 함께 갱신했습니다. deprecated `httpx` fallback 경고 억제는 제거된 상태를 유지합니다.' + assert changelog.count(old_line) == 1, changelog.count(old_line) + changelog_path.write_text(changelog.replace(old_line, new_line), encoding='utf-8') + + doctoring = '''# Starlette TestClient `httpx2` dependency + +## Observed failure + +Protected `develop@042b0c70531b229af3acbd0421a2f23098d848b3` pins Starlette +1.3.1 but did not install `httpx2`. Importing `starlette.testclient` therefore +fell back to deprecated `httpx`; warning-as-error test runs stopped during +collection. Removing the warning filter without installing the preferred +transport would expose the defect without repairing it. + +The first owner repair pinned `httpx2==2.5.0`, but hosted Security then reported +that direct version and its resolved `httpcore2==2.5.0` as vulnerable. The +owner therefore had to regenerate the dependency graph rather than suppress the +scanner or hand-edit generated lock data. + +## Decision and boundary + +Pin `httpx2==2.12.0` in the repository's existing combined backend +development/direct-test manifests and immutable locks. Keep application HTTP +clients on their existing `httpx` path. The resolver-generated graph selects +`httpcore2==2.12.0`; a runtime regression test still imports Starlette's +TestClient module and verifies that its selected transport module is `httpx2`. +Manifest and digest checks alone are insufficient evidence. + +Read-only resolver run `35788211863` on owner exact +`1ceb2f40b8a62dc4c2084ab674cbac5bbbe09591` reproduced the accepted candidate +with Python 3.14 and `uv==0.10.0`. Artifact `10724734967` retained the exact +manifests, generated hash lock, `uv.lock`, binary diff and SHA-256 manifest. +Before adoption, all four artifact files were re-hashed and matched that +manifest. The generated wheel record for `httpx2==2.12.0` carries SHA-256 +`cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36`. + +This owner only repairs the Starlette TestClient development/test dependency. +Inherited frontend findings remain in the dependency-security owner; this PR +does not broaden into application HTTP client migration or frontend packages. + +## Verification and rollback + +Run from `backend/`: + +```bash +uv lock --check +uv sync --frozen --group dev +uv run --frozen pytest -q -W error tests/test_container_dependency_pin_contract.py +uv run --frozen ruff check tests/test_container_dependency_pin_contract.py +``` + +Rollback removes the direct pin, regenerated lock records, runtime assertion, +and obsolete-warning-filter removal together. Do not restore only the warning +suppression. Any rollback must regenerate the lock/hash set rather than editing +those files manually. + +## References + +Kludex. (2026). *Starlette release notes*. GitHub. +https://github.com/Kludex/starlette/blob/main/docs/release-notes.md + +Python Packaging Authority. (2026). *httpx2 2.12.0* [Python package]. PyPI. +https://pypi.org/project/httpx2/2.12.0/ + +Pydantic. (2026). *HTTPX2 v2.12.0* [Source code]. GitHub. +https://github.com/pydantic/httpx2/tree/v2.12.0 +''' + Path('docs/doctoring/starlette-httpx2-testclient-dependency.md').write_text( + doctoring, + encoding='utf-8', + ) + PY + + - name: Remove completed helper and validate bounded delta + shell: bash + run: | + set -euo pipefail + git rm .github/workflows/temporary-httpx2-resolution.yml + git diff --check + python - <<'PY' + import subprocess + + expected = { + '.github/workflows/temporary-httpx2-resolution.yml', + 'CHANGELOG.md', + 'backend/pyproject.toml', + 'backend/requirements-hashes.txt', + 'backend/requirements.txt', + 'backend/tests/test_container_dependency_pin_contract.py', + 'backend/uv.lock', + 'docs/doctoring/starlette-httpx2-testclient-dependency.md', + } + changed = set(subprocess.check_output( + ['git', 'diff', '--name-only', 'HEAD'], text=True + ).splitlines()) + assert changed == expected, (sorted(changed), sorted(expected)) + PY + + - name: Focused exact-graph verification + shell: bash + run: | + set -euo pipefail + ( + cd backend + uv lock --check + uv sync --frozen --group dev + uv run --frozen pytest -q -W error tests/test_container_dependency_pin_contract.py + uv run --frozen ruff check tests/test_container_dependency_pin_contract.py + ) + + - name: Commit exact owner repair without overwriting concurrent work + shell: bash + env: + EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + remote_head="$(git ls-remote origin "refs/heads/${GITHUB_HEAD_REF}" | cut -f1)" + test "$remote_head" = "$EXPECTED_HEAD" + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git commit -m 'fix(deps): adopt verified httpx2 2.12.0 graph' + git push origin "HEAD:refs/heads/${GITHUB_HEAD_REF}" From d920689d0d46e1a1a9336370bfdf3aa52833d10c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 23 Sep 2026 09:17:02 +0900 Subject: [PATCH 13/28] fix(ci): make httpx2 owner adoption run on branch push --- .../workflows/temporary-httpx2-resolution.yml | 35 ++++++++----------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/.github/workflows/temporary-httpx2-resolution.yml b/.github/workflows/temporary-httpx2-resolution.yml index f1a2c6973..27b8a5f4e 100644 --- a/.github/workflows/temporary-httpx2-resolution.yml +++ b/.github/workflows/temporary-httpx2-resolution.yml @@ -1,29 +1,23 @@ name: Temporary httpx2 owner adoption on: - pull_request: - branches: [develop] + push: + branches: + - codex/starlette-testclient-dependency permissions: contents: write -concurrency: - group: httpx2-owner-adoption-${{ github.event.pull_request.number }} - cancel-in-progress: false - jobs: adopt: - if: >- - github.head_ref == 'codex/starlette-testclient-dependency' && - github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: - - name: Checkout exact PR head + - name: Checkout exact pushed owner head uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: true fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.sha }} - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -33,7 +27,7 @@ jobs: - name: Install pinned resolver run: python -m pip install --disable-pip-version-check 'uv==0.10.0' - - name: Reproduce and verify exact resolver output + - name: Reproduce and verify artifact-backed dependency graph shell: bash run: | set -euo pipefail @@ -56,23 +50,22 @@ jobs: --python-platform x86_64-unknown-linux-gnu \ --output-file backend/requirements-hashes.txt \ backend/requirements.txt - mkdir -p .resolver-evidence sha256sum \ backend/requirements.txt \ backend/pyproject.toml \ backend/requirements-hashes.txt \ backend/uv.lock \ - > .resolver-evidence/sha256.txt - cat > .resolver-evidence/expected-sha256.txt <<'EOF' + > /tmp/httpx2-sha256.txt + cat > /tmp/httpx2-expected-sha256.txt <<'EOF' a0bbed543860fe2dff63e1a7ddd96b9b843a12acd7c4838b123a21c97967a22d backend/requirements.txt 58b89778097bc23f9bad06bdeb4a1207f7f304edd58bb168782600b5c8379fdb backend/pyproject.toml 2870d4f4af934b41e9dcdb769be3ce373c11e7bb58473163277a00cd432cae19 backend/requirements-hashes.txt d6a249e3a22a3e9130cee5992f31c5a3ecc13d38898dbfa11684fdfb0be467d5 backend/uv.lock EOF - diff -u .resolver-evidence/expected-sha256.txt .resolver-evidence/sha256.txt + diff -u /tmp/httpx2-expected-sha256.txt /tmp/httpx2-sha256.txt git diff --check - - name: Update contract tests and traceability + - name: Update regression contract and traceability shell: bash run: | set -euo pipefail @@ -167,7 +160,7 @@ https://github.com/pydantic/httpx2/tree/v2.12.0 ) PY - - name: Remove completed helper and validate bounded delta + - name: Remove completed helper and verify bounded delta shell: bash run: | set -euo pipefail @@ -207,13 +200,13 @@ https://github.com/pydantic/httpx2/tree/v2.12.0 - name: Commit exact owner repair without overwriting concurrent work shell: bash env: - EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} + EXPECTED_HEAD: ${{ github.sha }} run: | set -euo pipefail - remote_head="$(git ls-remote origin "refs/heads/${GITHUB_HEAD_REF}" | cut -f1)" + remote_head="$(git ls-remote origin "refs/heads/${GITHUB_REF_NAME}" | cut -f1)" test "$remote_head" = "$EXPECTED_HEAD" git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git add -A git commit -m 'fix(deps): adopt verified httpx2 2.12.0 graph' - git push origin "HEAD:refs/heads/${GITHUB_HEAD_REF}" + git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" From f54dc09433078dfb57220dd05eb8909dd5562dcd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 23 Sep 2026 09:19:58 +0900 Subject: [PATCH 14/28] chore(ci): remove failed temporary httpx2 adoption helper --- .../workflows/temporary-httpx2-resolution.yml | 212 ------------------ 1 file changed, 212 deletions(-) delete mode 100644 .github/workflows/temporary-httpx2-resolution.yml diff --git a/.github/workflows/temporary-httpx2-resolution.yml b/.github/workflows/temporary-httpx2-resolution.yml deleted file mode 100644 index 27b8a5f4e..000000000 --- a/.github/workflows/temporary-httpx2-resolution.yml +++ /dev/null @@ -1,212 +0,0 @@ -name: Temporary httpx2 owner adoption - -on: - push: - branches: - - codex/starlette-testclient-dependency - -permissions: - contents: write - -jobs: - adopt: - runs-on: ubuntu-latest - steps: - - name: Checkout exact pushed owner head - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: true - fetch-depth: 0 - ref: ${{ github.sha }} - - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: '3.14' - - - name: Install pinned resolver - run: python -m pip install --disable-pip-version-check 'uv==0.10.0' - - - name: Reproduce and verify artifact-backed dependency graph - shell: bash - run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - - for path in (Path('backend/requirements.txt'), Path('backend/pyproject.toml')): - text = path.read_text(encoding='utf-8') - old = 'httpx2==2.5.0' - assert text.count(old) == 1, (path, text.count(old)) - path.write_text(text.replace(old, 'httpx2==2.12.0'), encoding='utf-8') - PY - ( - cd backend - uv lock --upgrade-package 'httpx2==2.12.0' - ) - uv pip compile \ - --generate-hashes \ - --python-version 3.14 \ - --python-platform x86_64-unknown-linux-gnu \ - --output-file backend/requirements-hashes.txt \ - backend/requirements.txt - sha256sum \ - backend/requirements.txt \ - backend/pyproject.toml \ - backend/requirements-hashes.txt \ - backend/uv.lock \ - > /tmp/httpx2-sha256.txt - cat > /tmp/httpx2-expected-sha256.txt <<'EOF' - a0bbed543860fe2dff63e1a7ddd96b9b843a12acd7c4838b123a21c97967a22d backend/requirements.txt - 58b89778097bc23f9bad06bdeb4a1207f7f304edd58bb168782600b5c8379fdb backend/pyproject.toml - 2870d4f4af934b41e9dcdb769be3ce373c11e7bb58473163277a00cd432cae19 backend/requirements-hashes.txt - d6a249e3a22a3e9130cee5992f31c5a3ecc13d38898dbfa11684fdfb0be467d5 backend/uv.lock - EOF - diff -u /tmp/httpx2-expected-sha256.txt /tmp/httpx2-sha256.txt - git diff --check - - - name: Update regression contract and traceability - shell: bash - run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - - test_path = Path('backend/tests/test_container_dependency_pin_contract.py') - test_text = test_path.read_text(encoding='utf-8') - replacements = { - 'backend_pins["httpx2"] == "2.5.0"': 'backend_pins["httpx2"] == "2.12.0"', - '"httpx2==2.5.0" in backend_records': '"httpx2==2.12.0" in backend_records', - ' "httpx2==2.5.0",': ' "httpx2==2.12.0",', - } - for old, new in replacements.items(): - assert test_text.count(old) == 1, (old, test_text.count(old)) - test_text = test_text.replace(old, new) - test_path.write_text(test_text, encoding='utf-8') - - changelog_path = Path('CHANGELOG.md') - changelog = changelog_path.read_text(encoding='utf-8') - old_line = '- Starlette `TestClient`의 기존 `httpx2==2.5.0` pin을 core 개발·테스트 의존성으로 승격하고, deprecated `httpx` fallback 경고 억제를 제거했습니다.' - new_line = '- Starlette `TestClient`의 preferred transport를 `httpx2==2.12.0`으로 고정하고, resolver가 선택한 `httpcore2==2.12.0`과 hash/uv lock을 함께 갱신했습니다. deprecated `httpx` fallback 경고 억제는 제거된 상태를 유지합니다.' - assert changelog.count(old_line) == 1, changelog.count(old_line) - changelog_path.write_text(changelog.replace(old_line, new_line), encoding='utf-8') - - doctoring = '''# Starlette TestClient `httpx2` dependency - -## Observed failure - -Protected `develop@042b0c70531b229af3acbd0421a2f23098d848b3` pins Starlette -1.3.1 but did not install `httpx2`. Importing `starlette.testclient` therefore -fell back to deprecated `httpx`; warning-as-error test runs stopped during -collection. Removing the warning filter without installing the preferred -transport would expose the defect without repairing it. - -The first owner repair pinned `httpx2==2.5.0`, but hosted Security then reported -that direct version and its resolved `httpcore2==2.5.0` as vulnerable. The -owner therefore had to regenerate the dependency graph rather than suppress the -scanner or hand-edit generated lock data. - -## Decision and boundary - -Pin `httpx2==2.12.0` in the repository's existing combined backend -development/direct-test manifests and immutable locks. Keep application HTTP -clients on their existing `httpx` path. The resolver-generated graph selects -`httpcore2==2.12.0`; a runtime regression test still imports Starlette's -TestClient module and verifies that its selected transport module is `httpx2`. -Manifest and digest checks alone are insufficient evidence. - -Read-only resolver run `35788211863` on owner exact -`1ceb2f40b8a62dc4c2084ab674cbac5bbbe09591` reproduced the accepted candidate -with Python 3.14 and `uv==0.10.0`. Artifact `10724734967` retained the exact -manifests, generated hash lock, `uv.lock`, binary diff and SHA-256 manifest. -Before adoption, all four artifact files were re-hashed and matched that -manifest. The generated wheel record for `httpx2==2.12.0` carries SHA-256 -`cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36`. - -This owner only repairs the Starlette TestClient development/test dependency. -Inherited frontend findings remain in the dependency-security owner; this PR -does not broaden into application HTTP client migration or frontend packages. - -## Verification and rollback - -Run from `backend/`: - -```bash -uv lock --check -uv sync --frozen --group dev -uv run --frozen pytest -q -W error tests/test_container_dependency_pin_contract.py -uv run --frozen ruff check tests/test_container_dependency_pin_contract.py -``` - -Rollback removes the direct pin, regenerated lock records, runtime assertion, -and obsolete-warning-filter removal together. Do not restore only the warning -suppression. Any rollback must regenerate the lock/hash set rather than editing -those files manually. - -## References - -Kludex. (2026). *Starlette release notes*. GitHub. -https://github.com/Kludex/starlette/blob/main/docs/release-notes.md - -Python Packaging Authority. (2026). *httpx2 2.12.0* [Python package]. PyPI. -https://pypi.org/project/httpx2/2.12.0/ - -Pydantic. (2026). *HTTPX2 v2.12.0* [Source code]. GitHub. -https://github.com/pydantic/httpx2/tree/v2.12.0 -''' - Path('docs/doctoring/starlette-httpx2-testclient-dependency.md').write_text( - doctoring, - encoding='utf-8', - ) - PY - - - name: Remove completed helper and verify bounded delta - shell: bash - run: | - set -euo pipefail - git rm .github/workflows/temporary-httpx2-resolution.yml - git diff --check - python - <<'PY' - import subprocess - - expected = { - '.github/workflows/temporary-httpx2-resolution.yml', - 'CHANGELOG.md', - 'backend/pyproject.toml', - 'backend/requirements-hashes.txt', - 'backend/requirements.txt', - 'backend/tests/test_container_dependency_pin_contract.py', - 'backend/uv.lock', - 'docs/doctoring/starlette-httpx2-testclient-dependency.md', - } - changed = set(subprocess.check_output( - ['git', 'diff', '--name-only', 'HEAD'], text=True - ).splitlines()) - assert changed == expected, (sorted(changed), sorted(expected)) - PY - - - name: Focused exact-graph verification - shell: bash - run: | - set -euo pipefail - ( - cd backend - uv lock --check - uv sync --frozen --group dev - uv run --frozen pytest -q -W error tests/test_container_dependency_pin_contract.py - uv run --frozen ruff check tests/test_container_dependency_pin_contract.py - ) - - - name: Commit exact owner repair without overwriting concurrent work - shell: bash - env: - EXPECTED_HEAD: ${{ github.sha }} - run: | - set -euo pipefail - remote_head="$(git ls-remote origin "refs/heads/${GITHUB_REF_NAME}" | cut -f1)" - test "$remote_head" = "$EXPECTED_HEAD" - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - git commit -m 'fix(deps): adopt verified httpx2 2.12.0 graph' - git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" From 5d4efe765ce1de2cf7a88b2c6b6f1e5d722006f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 23 Sep 2026 09:42:56 +0900 Subject: [PATCH 15/28] fix(ci): adopt verified httpx2 artifact on PR head --- .../workflows/temporary-httpx2-adoption.yml | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 .github/workflows/temporary-httpx2-adoption.yml diff --git a/.github/workflows/temporary-httpx2-adoption.yml b/.github/workflows/temporary-httpx2-adoption.yml new file mode 100644 index 000000000..c452b1d7a --- /dev/null +++ b/.github/workflows/temporary-httpx2-adoption.yml @@ -0,0 +1,172 @@ +name: Temporary httpx2 artifact adoption + +on: + pull_request: + branches: [develop] + +permissions: + contents: write + +jobs: + adopt: + if: github.head_ref == 'codex/starlette-testclient-dependency' + runs-on: ubuntu-latest + steps: + - name: Checkout exact PR head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: true + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.14' + + - name: Install pinned resolver + run: python -m pip install --disable-pip-version-check 'uv==0.10.0' + + - name: Reproduce exact artifact-backed dependency graph + shell: bash + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + for path in (Path('backend/requirements.txt'), Path('backend/pyproject.toml')): + text = path.read_text(encoding='utf-8') + old = 'httpx2==2.5.0' + assert text.count(old) == 1, (path, text.count(old)) + path.write_text(text.replace(old, 'httpx2==2.12.0'), encoding='utf-8') + PY + ( + cd backend + uv lock --upgrade-package 'httpx2==2.12.0' + ) + uv pip compile \ + --generate-hashes \ + --python-version 3.14 \ + --python-platform x86_64-unknown-linux-gnu \ + --output-file backend/requirements-hashes.txt \ + backend/requirements.txt + sha256sum backend/requirements.txt backend/pyproject.toml backend/requirements-hashes.txt backend/uv.lock > /tmp/httpx2-sha256.txt + cat > /tmp/httpx2-expected-sha256.txt <<'EOF' + a0bbed543860fe2dff63e1a7ddd96b9b843a12acd7c4838b123a21c97967a22d backend/requirements.txt + 58b89778097bc23f9bad06bdeb4a1207f7f304edd58bb168782600b5c8379fdb backend/pyproject.toml + 2870d4f4af934b41e9dcdb769be3ce373c11e7bb58473163277a00cd432cae19 backend/requirements-hashes.txt + d6a249e3a22a3e9130cee5992f31c5a3ecc13d38898dbfa11684fdfb0be467d5 backend/uv.lock + EOF + diff -u /tmp/httpx2-expected-sha256.txt /tmp/httpx2-sha256.txt + git diff --check + + - name: Update exact regression contract and traceability + shell: bash + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + + test_path = Path('backend/tests/test_container_dependency_pin_contract.py') + test_text = test_path.read_text(encoding='utf-8') + for old, new in { + 'backend_pins["httpx2"] == "2.5.0"': 'backend_pins["httpx2"] == "2.12.0"', + '"httpx2==2.5.0" in backend_records': '"httpx2==2.12.0" in backend_records', + ' "httpx2==2.5.0",': ' "httpx2==2.12.0",', + }.items(): + assert test_text.count(old) == 1, (old, test_text.count(old)) + test_text = test_text.replace(old, new) + test_path.write_text(test_text, encoding='utf-8') + + changelog_path = Path('CHANGELOG.md') + changelog = changelog_path.read_text(encoding='utf-8') + old_line = '- Starlette `TestClient`의 기존 `httpx2==2.5.0` pin을 core 개발·테스트 의존성으로 승격하고, deprecated `httpx` fallback 경고 억제를 제거했습니다.' + new_line = '- Starlette `TestClient`의 preferred transport를 `httpx2==2.12.0`으로 고정하고, resolver가 선택한 `httpcore2==2.12.0`과 hash/uv lock을 함께 갱신했습니다. deprecated `httpx` fallback 경고 억제는 제거된 상태를 유지합니다.' + assert changelog.count(old_line) == 1, changelog.count(old_line) + changelog_path.write_text(changelog.replace(old_line, new_line), encoding='utf-8') + + doctoring = '''# Starlette TestClient `httpx2` dependency + +## Observed failure + +Protected `develop@042b0c70531b229af3acbd0421a2f23098d848b3` pins Starlette 1.3.1 but did not install `httpx2`. Importing `starlette.testclient` therefore fell back to deprecated `httpx`; warning-as-error test runs stopped during collection. Removing the warning filter without installing the preferred transport would expose the defect without repairing it. + +The first owner repair pinned `httpx2==2.5.0`, but hosted Security then reported that direct version and its resolved `httpcore2==2.5.0` as vulnerable. The owner therefore had to regenerate the dependency graph rather than suppress the scanner or hand-edit generated lock data. + +## Decision and boundary + +Pin `httpx2==2.12.0` in the repository's existing combined backend development/direct-test manifests and immutable locks. Keep application HTTP clients on their existing `httpx` path. The resolver-generated graph selects `httpcore2==2.12.0`; a runtime regression test still imports Starlette's TestClient module and verifies that its selected transport module is `httpx2`. Manifest and digest checks alone are insufficient evidence. + +Read-only resolver run `35788211863` on owner exact `1ceb2f40b8a62dc4c2084ab674cbac5bbbe09591` reproduced the accepted candidate with Python 3.14 and `uv==0.10.0`. Artifact `10724734967` retained the exact manifests, generated hash lock, `uv.lock`, binary diff and SHA-256 manifest. Before adoption, all four artifact files were re-hashed and matched that manifest. The generated wheel record for `httpx2==2.12.0` carries SHA-256 `cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36`. + +This owner only repairs the Starlette TestClient development/test dependency. Inherited frontend findings remain in the dependency-security owner; this PR does not broaden into application HTTP client migration or frontend packages. + +## Verification and rollback + +Run from `backend/`: + +```bash +uv lock --check +uv sync --frozen --group dev +uv run --frozen pytest -q -W error tests/test_container_dependency_pin_contract.py +uv run --frozen ruff check tests/test_container_dependency_pin_contract.py +``` + +Rollback removes the direct pin, regenerated lock records, runtime assertion, and obsolete-warning-filter removal together. Do not restore only the warning suppression. Any rollback must regenerate the lock/hash set rather than editing those files manually. + +## References + +Kludex. (2026). *Starlette release notes*. GitHub. https://github.com/Kludex/starlette/blob/main/docs/release-notes.md + +Python Packaging Authority. (2026). *httpx2 2.12.0* [Python package]. PyPI. https://pypi.org/project/httpx2/2.12.0/ + +Pydantic. (2026). *HTTPX2 v2.12.0* [Source code]. GitHub. https://github.com/pydantic/httpx2/tree/v2.12.0 +''' + Path('docs/doctoring/starlette-httpx2-testclient-dependency.md').write_text(doctoring, encoding='utf-8') + PY + + - name: Remove helper and verify bounded delta + shell: bash + run: | + set -euo pipefail + git rm .github/workflows/temporary-httpx2-adoption.yml + git diff --check + python - <<'PY' + import subprocess + expected = { + '.github/workflows/temporary-httpx2-adoption.yml', + 'CHANGELOG.md', + 'backend/pyproject.toml', + 'backend/requirements-hashes.txt', + 'backend/requirements.txt', + 'backend/tests/test_container_dependency_pin_contract.py', + 'backend/uv.lock', + 'docs/doctoring/starlette-httpx2-testclient-dependency.md', + } + changed = set(subprocess.check_output(['git', 'diff', '--name-only', 'HEAD'], text=True).splitlines()) + assert changed == expected, (sorted(changed), sorted(expected)) + PY + + - name: Focused exact-head verification + shell: bash + run: | + set -euo pipefail + cd backend + uv lock --check + uv sync --frozen --group dev + uv run --frozen pytest -q -W error tests/test_container_dependency_pin_contract.py + uv run --frozen ruff check tests/test_container_dependency_pin_contract.py + + - name: Commit verified owner repair without overwriting concurrent work + shell: bash + env: + EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} + TARGET_BRANCH: ${{ github.head_ref }} + run: | + set -euo pipefail + remote_head="$(git ls-remote origin "refs/heads/${TARGET_BRANCH}" | cut -f1)" + test "$remote_head" = "$EXPECTED_HEAD" + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git commit -m 'fix(deps): adopt verified httpx2 2.12.0 graph' + git push origin "HEAD:refs/heads/${TARGET_BRANCH}" From 29272251ca4ab3e38c3ce8a5b7de811d285ff366 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 23 Sep 2026 09:44:45 +0900 Subject: [PATCH 16/28] chore(ci): remove failed httpx2 adoption helper --- .../workflows/temporary-httpx2-adoption.yml | 172 ------------------ 1 file changed, 172 deletions(-) delete mode 100644 .github/workflows/temporary-httpx2-adoption.yml diff --git a/.github/workflows/temporary-httpx2-adoption.yml b/.github/workflows/temporary-httpx2-adoption.yml deleted file mode 100644 index c452b1d7a..000000000 --- a/.github/workflows/temporary-httpx2-adoption.yml +++ /dev/null @@ -1,172 +0,0 @@ -name: Temporary httpx2 artifact adoption - -on: - pull_request: - branches: [develop] - -permissions: - contents: write - -jobs: - adopt: - if: github.head_ref == 'codex/starlette-testclient-dependency' - runs-on: ubuntu-latest - steps: - - name: Checkout exact PR head - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: true - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha }} - - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: '3.14' - - - name: Install pinned resolver - run: python -m pip install --disable-pip-version-check 'uv==0.10.0' - - - name: Reproduce exact artifact-backed dependency graph - shell: bash - run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - for path in (Path('backend/requirements.txt'), Path('backend/pyproject.toml')): - text = path.read_text(encoding='utf-8') - old = 'httpx2==2.5.0' - assert text.count(old) == 1, (path, text.count(old)) - path.write_text(text.replace(old, 'httpx2==2.12.0'), encoding='utf-8') - PY - ( - cd backend - uv lock --upgrade-package 'httpx2==2.12.0' - ) - uv pip compile \ - --generate-hashes \ - --python-version 3.14 \ - --python-platform x86_64-unknown-linux-gnu \ - --output-file backend/requirements-hashes.txt \ - backend/requirements.txt - sha256sum backend/requirements.txt backend/pyproject.toml backend/requirements-hashes.txt backend/uv.lock > /tmp/httpx2-sha256.txt - cat > /tmp/httpx2-expected-sha256.txt <<'EOF' - a0bbed543860fe2dff63e1a7ddd96b9b843a12acd7c4838b123a21c97967a22d backend/requirements.txt - 58b89778097bc23f9bad06bdeb4a1207f7f304edd58bb168782600b5c8379fdb backend/pyproject.toml - 2870d4f4af934b41e9dcdb769be3ce373c11e7bb58473163277a00cd432cae19 backend/requirements-hashes.txt - d6a249e3a22a3e9130cee5992f31c5a3ecc13d38898dbfa11684fdfb0be467d5 backend/uv.lock - EOF - diff -u /tmp/httpx2-expected-sha256.txt /tmp/httpx2-sha256.txt - git diff --check - - - name: Update exact regression contract and traceability - shell: bash - run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - - test_path = Path('backend/tests/test_container_dependency_pin_contract.py') - test_text = test_path.read_text(encoding='utf-8') - for old, new in { - 'backend_pins["httpx2"] == "2.5.0"': 'backend_pins["httpx2"] == "2.12.0"', - '"httpx2==2.5.0" in backend_records': '"httpx2==2.12.0" in backend_records', - ' "httpx2==2.5.0",': ' "httpx2==2.12.0",', - }.items(): - assert test_text.count(old) == 1, (old, test_text.count(old)) - test_text = test_text.replace(old, new) - test_path.write_text(test_text, encoding='utf-8') - - changelog_path = Path('CHANGELOG.md') - changelog = changelog_path.read_text(encoding='utf-8') - old_line = '- Starlette `TestClient`의 기존 `httpx2==2.5.0` pin을 core 개발·테스트 의존성으로 승격하고, deprecated `httpx` fallback 경고 억제를 제거했습니다.' - new_line = '- Starlette `TestClient`의 preferred transport를 `httpx2==2.12.0`으로 고정하고, resolver가 선택한 `httpcore2==2.12.0`과 hash/uv lock을 함께 갱신했습니다. deprecated `httpx` fallback 경고 억제는 제거된 상태를 유지합니다.' - assert changelog.count(old_line) == 1, changelog.count(old_line) - changelog_path.write_text(changelog.replace(old_line, new_line), encoding='utf-8') - - doctoring = '''# Starlette TestClient `httpx2` dependency - -## Observed failure - -Protected `develop@042b0c70531b229af3acbd0421a2f23098d848b3` pins Starlette 1.3.1 but did not install `httpx2`. Importing `starlette.testclient` therefore fell back to deprecated `httpx`; warning-as-error test runs stopped during collection. Removing the warning filter without installing the preferred transport would expose the defect without repairing it. - -The first owner repair pinned `httpx2==2.5.0`, but hosted Security then reported that direct version and its resolved `httpcore2==2.5.0` as vulnerable. The owner therefore had to regenerate the dependency graph rather than suppress the scanner or hand-edit generated lock data. - -## Decision and boundary - -Pin `httpx2==2.12.0` in the repository's existing combined backend development/direct-test manifests and immutable locks. Keep application HTTP clients on their existing `httpx` path. The resolver-generated graph selects `httpcore2==2.12.0`; a runtime regression test still imports Starlette's TestClient module and verifies that its selected transport module is `httpx2`. Manifest and digest checks alone are insufficient evidence. - -Read-only resolver run `35788211863` on owner exact `1ceb2f40b8a62dc4c2084ab674cbac5bbbe09591` reproduced the accepted candidate with Python 3.14 and `uv==0.10.0`. Artifact `10724734967` retained the exact manifests, generated hash lock, `uv.lock`, binary diff and SHA-256 manifest. Before adoption, all four artifact files were re-hashed and matched that manifest. The generated wheel record for `httpx2==2.12.0` carries SHA-256 `cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36`. - -This owner only repairs the Starlette TestClient development/test dependency. Inherited frontend findings remain in the dependency-security owner; this PR does not broaden into application HTTP client migration or frontend packages. - -## Verification and rollback - -Run from `backend/`: - -```bash -uv lock --check -uv sync --frozen --group dev -uv run --frozen pytest -q -W error tests/test_container_dependency_pin_contract.py -uv run --frozen ruff check tests/test_container_dependency_pin_contract.py -``` - -Rollback removes the direct pin, regenerated lock records, runtime assertion, and obsolete-warning-filter removal together. Do not restore only the warning suppression. Any rollback must regenerate the lock/hash set rather than editing those files manually. - -## References - -Kludex. (2026). *Starlette release notes*. GitHub. https://github.com/Kludex/starlette/blob/main/docs/release-notes.md - -Python Packaging Authority. (2026). *httpx2 2.12.0* [Python package]. PyPI. https://pypi.org/project/httpx2/2.12.0/ - -Pydantic. (2026). *HTTPX2 v2.12.0* [Source code]. GitHub. https://github.com/pydantic/httpx2/tree/v2.12.0 -''' - Path('docs/doctoring/starlette-httpx2-testclient-dependency.md').write_text(doctoring, encoding='utf-8') - PY - - - name: Remove helper and verify bounded delta - shell: bash - run: | - set -euo pipefail - git rm .github/workflows/temporary-httpx2-adoption.yml - git diff --check - python - <<'PY' - import subprocess - expected = { - '.github/workflows/temporary-httpx2-adoption.yml', - 'CHANGELOG.md', - 'backend/pyproject.toml', - 'backend/requirements-hashes.txt', - 'backend/requirements.txt', - 'backend/tests/test_container_dependency_pin_contract.py', - 'backend/uv.lock', - 'docs/doctoring/starlette-httpx2-testclient-dependency.md', - } - changed = set(subprocess.check_output(['git', 'diff', '--name-only', 'HEAD'], text=True).splitlines()) - assert changed == expected, (sorted(changed), sorted(expected)) - PY - - - name: Focused exact-head verification - shell: bash - run: | - set -euo pipefail - cd backend - uv lock --check - uv sync --frozen --group dev - uv run --frozen pytest -q -W error tests/test_container_dependency_pin_contract.py - uv run --frozen ruff check tests/test_container_dependency_pin_contract.py - - - name: Commit verified owner repair without overwriting concurrent work - shell: bash - env: - EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} - TARGET_BRANCH: ${{ github.head_ref }} - run: | - set -euo pipefail - remote_head="$(git ls-remote origin "refs/heads/${TARGET_BRANCH}" | cut -f1)" - test "$remote_head" = "$EXPECTED_HEAD" - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - git commit -m 'fix(deps): adopt verified httpx2 2.12.0 graph' - git push origin "HEAD:refs/heads/${TARGET_BRANCH}" From 2e25f1dd1e27f784dbd81e38d0a6e2403d214b12 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 23 Sep 2026 10:47:34 +0900 Subject: [PATCH 17/28] chore(ci): stage verified httpx2 artifact adoption --- .github/workflows/app-ci.yml | 135 +++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml index e8f445748..0fecb1629 100644 --- a/.github/workflows/app-ci.yml +++ b/.github/workflows/app-ci.yml @@ -137,3 +137,138 @@ jobs: NARUON_FULL_PRODUCT_BASE_URL: "http://127.0.0.1:3001" NARUON_FULL_PRODUCT_SCREENSHOT_DIR: "/tmp/naruon-full-product-smoke" run: cd frontend && pnpm run full:smoke + + adopt_httpx2_artifact: + name: adopt verified httpx2 artifact + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.number == 1565 && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.head.ref == 'codex/starlette-testclient-dependency' + runs-on: ubuntu-latest + permissions: + actions: read + contents: write + env: + OWNER_BRANCH: codex/starlette-testclient-dependency + PRE_HELPER_HEAD: 29272251ca4ab3e38c3ce8a5b7de811d285ff366 + ARTIFACT_ID: "10724734967" + UV_VERSION: "0.10.0" + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout exact PR head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: true + + - name: Verify single-writer head + env: + EVENT_HEAD: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$EVENT_HEAD" + remote_head="$(git ls-remote origin "refs/heads/${OWNER_BRANCH}" | cut -f1)" + test "$remote_head" = "$EVENT_HEAD" + test "$(git rev-parse HEAD^)" = "$PRE_HELPER_HEAD" + git switch -C "$OWNER_BRANCH" "$EVENT_HEAD" + + - name: Download and verify exact resolver artifact + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p /tmp/httpx2-resolution + curl --fail --location --silent --show-error \ + --header "Authorization: Bearer ${GH_TOKEN}" \ + --header "Accept: application/vnd.github+json" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" \ + -o /tmp/httpx2-resolution.zip + unzip -q /tmp/httpx2-resolution.zip -d /tmp/httpx2-resolution + cat > /tmp/expected-sha256.txt <<'EOF' + a0bbed543860fe2dff63e1a7ddd96b9b843a12acd7c4838b123a21c97967a22d backend/requirements.txt + 58b89778097bc23f9bad06bdeb4a1207f7f304edd58bb168782600b5c8379fdb backend/pyproject.toml + 2870d4f4af934b41e9dcdb769be3ce373c11e7bb58473163277a00cd432cae19 backend/requirements-hashes.txt + d6a249e3a22a3e9130cee5992f31c5a3ecc13d38898dbfa11684fdfb0be467d5 backend/uv.lock + EOF + sed -i 's/^ //' /tmp/expected-sha256.txt + diff -u /tmp/expected-sha256.txt /tmp/httpx2-resolution/.resolver-evidence/sha256.txt + cd /tmp/httpx2-resolution + sha256sum --check .resolver-evidence/sha256.txt + + - name: Adopt generated graph and matching owner contracts + run: | + set -euo pipefail + cp /tmp/httpx2-resolution/backend/requirements.txt backend/requirements.txt + cp /tmp/httpx2-resolution/backend/pyproject.toml backend/pyproject.toml + cp /tmp/httpx2-resolution/backend/requirements-hashes.txt backend/requirements-hashes.txt + cp /tmp/httpx2-resolution/backend/uv.lock backend/uv.lock + python - <<'PY' + from pathlib import Path + + test_path = Path('backend/tests/test_container_dependency_pin_contract.py') + text = test_path.read_text() + replacements = { + 'backend_pins["httpx2"] == "2.5.0"': 'backend_pins["httpx2"] == "2.12.0"', + '"httpx2==2.5.0"': '"httpx2==2.12.0"', + } + for old, new in replacements.items(): + if old not in text: + raise SystemExit(f'missing expected TestClient assertion: {old}') + text = text.replace(old, new) + test_path.write_text(text) + + changelog_path = Path('CHANGELOG.md') + changelog = changelog_path.read_text() + old = '- Starlette `TestClient`의 기존 `httpx2==2.5.0` pin을 core 개발·테스트 의존성으로 승격하고, deprecated `httpx` fallback 경고 억제를 제거했습니다.' + new = '- Starlette `TestClient`의 preferred transport를 `httpx2==2.12.0`으로 고정하고, resolver가 선택한 `httpcore2==2.12.0`과 hash/uv lock을 함께 갱신했습니다. deprecated `httpx` fallback 경고 억제는 제거된 상태를 유지합니다.' + if changelog.count(old) != 1: + raise SystemExit(f'expected exactly one CHANGELOG predecessor, found {changelog.count(old)}') + changelog_path.write_text(changelog.replace(old, new, 1)) + PY + git show "${PRE_HELPER_HEAD}:.github/workflows/app-ci.yml" > .github/workflows/app-ci.yml + git diff --check + printf '%s\n' \ + CHANGELOG.md \ + backend/pyproject.toml \ + backend/requirements-hashes.txt \ + backend/requirements.txt \ + backend/tests/test_container_dependency_pin_contract.py \ + backend/uv.lock | sort > /tmp/expected-paths.txt + git diff --name-only | sort > /tmp/actual-paths.txt + diff -u /tmp/expected-paths.txt /tmp/actual-paths.txt + + - name: Verify adopted graph before push + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: Run focused dependency acceptance + run: | + set -euo pipefail + python -m pip install --disable-pip-version-check "uv==${UV_VERSION}" + cd backend + uv lock --check + uv sync --frozen --group dev + uv run --frozen pytest -q -W error tests/test_container_dependency_pin_contract.py + uv run --frozen ruff check tests/test_container_dependency_pin_contract.py + + - name: Commit and non-force push coherent owner delta + env: + EVENT_HEAD: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + remote_head="$(git ls-remote origin "refs/heads/${OWNER_BRANCH}" | cut -f1)" + test "$remote_head" = "$EVENT_HEAD" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md backend/pyproject.toml backend/requirements-hashes.txt backend/requirements.txt backend/tests/test_container_dependency_pin_contract.py backend/uv.lock .github/workflows/app-ci.yml + git diff --cached --check + git commit -m "fix(deps): adopt verified httpx2 2.12.0 graph" + git push origin "HEAD:refs/heads/${OWNER_BRANCH}" From 4840458d7f2231fe08121d5de4730815e6bbfe49 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 23 Sep 2026 13:44:27 +0900 Subject: [PATCH 18/28] fix(ci): include helper removal in adoption scope --- .github/workflows/app-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml index 0fecb1629..b850477f4 100644 --- a/.github/workflows/app-ci.yml +++ b/.github/workflows/app-ci.yml @@ -235,6 +235,7 @@ jobs: git show "${PRE_HELPER_HEAD}:.github/workflows/app-ci.yml" > .github/workflows/app-ci.yml git diff --check printf '%s\n' \ + .github/workflows/app-ci.yml \ CHANGELOG.md \ backend/pyproject.toml \ backend/requirements-hashes.txt \ From 3ab3c836a70480d11cb0548446ae9d6036648090 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 23 Sep 2026 13:45:33 +0900 Subject: [PATCH 19/28] fix(ci): separate helper lineage from restore source --- .github/workflows/app-ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml index b850477f4..0138febff 100644 --- a/.github/workflows/app-ci.yml +++ b/.github/workflows/app-ci.yml @@ -151,6 +151,7 @@ jobs: contents: write env: OWNER_BRANCH: codex/starlette-testclient-dependency + EXPECTED_PARENT_HEAD: 4840458d7f2231fe08121d5de4730815e6bbfe49 PRE_HELPER_HEAD: 29272251ca4ab3e38c3ce8a5b7de811d285ff366 ARTIFACT_ID: "10724734967" UV_VERSION: "0.10.0" @@ -175,7 +176,7 @@ jobs: test "$(git rev-parse HEAD)" = "$EVENT_HEAD" remote_head="$(git ls-remote origin "refs/heads/${OWNER_BRANCH}" | cut -f1)" test "$remote_head" = "$EVENT_HEAD" - test "$(git rev-parse HEAD^)" = "$PRE_HELPER_HEAD" + test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT_HEAD" git switch -C "$OWNER_BRANCH" "$EVENT_HEAD" - name: Download and verify exact resolver artifact From 60caa40c6f2d04e55eb8d92ee67b5cce3d09d6f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 23 Sep 2026 15:46:54 +0900 Subject: [PATCH 20/28] fix(deps): supersede stale httpx2 adoption with 2.13 resolver --- .github/workflows/app-ci.yml | 165 +++++++++++++---------------------- 1 file changed, 63 insertions(+), 102 deletions(-) diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml index 0138febff..ab01f0869 100644 --- a/.github/workflows/app-ci.yml +++ b/.github/workflows/app-ci.yml @@ -138,8 +138,8 @@ jobs: NARUON_FULL_PRODUCT_SCREENSHOT_DIR: "/tmp/naruon-full-product-smoke" run: cd frontend && pnpm run full:smoke - adopt_httpx2_artifact: - name: adopt verified httpx2 artifact + resolve_httpx2_candidate: + name: resolve httpx2 2.13.0 candidate if: >- github.event_name == 'pull_request' && github.event.pull_request.number == 1565 && @@ -147,14 +147,10 @@ jobs: github.event.pull_request.head.ref == 'codex/starlette-testclient-dependency' runs-on: ubuntu-latest permissions: - actions: read - contents: write + contents: read env: - OWNER_BRANCH: codex/starlette-testclient-dependency - EXPECTED_PARENT_HEAD: 4840458d7f2231fe08121d5de4730815e6bbfe49 - PRE_HELPER_HEAD: 29272251ca4ab3e38c3ce8a5b7de811d285ff366 - ARTIFACT_ID: "10724734967" UV_VERSION: "0.10.0" + HTTPX2_VERSION: "2.13.0" steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 @@ -164,113 +160,78 @@ jobs: - name: Checkout exact PR head uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - persist-credentials: true - - name: Verify single-writer head - env: - EVENT_HEAD: ${{ github.event.pull_request.head.sha }} - run: | - set -euo pipefail - test "$(git rev-parse HEAD)" = "$EVENT_HEAD" - remote_head="$(git ls-remote origin "refs/heads/${OWNER_BRANCH}" | cut -f1)" - test "$remote_head" = "$EVENT_HEAD" - test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT_HEAD" - git switch -C "$OWNER_BRANCH" "$EVENT_HEAD" + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" - - name: Download and verify exact resolver artifact - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - mkdir -p /tmp/httpx2-resolution - curl --fail --location --silent --show-error \ - --header "Authorization: Bearer ${GH_TOKEN}" \ - --header "Accept: application/vnd.github+json" \ - --header "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" \ - -o /tmp/httpx2-resolution.zip - unzip -q /tmp/httpx2-resolution.zip -d /tmp/httpx2-resolution - cat > /tmp/expected-sha256.txt <<'EOF' - a0bbed543860fe2dff63e1a7ddd96b9b843a12acd7c4838b123a21c97967a22d backend/requirements.txt - 58b89778097bc23f9bad06bdeb4a1207f7f304edd58bb168782600b5c8379fdb backend/pyproject.toml - 2870d4f4af934b41e9dcdb769be3ce373c11e7bb58473163277a00cd432cae19 backend/requirements-hashes.txt - d6a249e3a22a3e9130cee5992f31c5a3ecc13d38898dbfa11684fdfb0be467d5 backend/uv.lock - EOF - sed -i 's/^ //' /tmp/expected-sha256.txt - diff -u /tmp/expected-sha256.txt /tmp/httpx2-resolution/.resolver-evidence/sha256.txt - cd /tmp/httpx2-resolution - sha256sum --check .resolver-evidence/sha256.txt + - name: Install pinned resolver + run: python -m pip install --disable-pip-version-check "uv==${UV_VERSION}" - - name: Adopt generated graph and matching owner contracts + - name: Resolve current secure TestClient dependency without writing repository history + shell: bash run: | set -euo pipefail - cp /tmp/httpx2-resolution/backend/requirements.txt backend/requirements.txt - cp /tmp/httpx2-resolution/backend/pyproject.toml backend/pyproject.toml - cp /tmp/httpx2-resolution/backend/requirements-hashes.txt backend/requirements-hashes.txt - cp /tmp/httpx2-resolution/backend/uv.lock backend/uv.lock python - <<'PY' from pathlib import Path - test_path = Path('backend/tests/test_container_dependency_pin_contract.py') - text = test_path.read_text() - replacements = { - 'backend_pins["httpx2"] == "2.5.0"': 'backend_pins["httpx2"] == "2.12.0"', - '"httpx2==2.5.0"': '"httpx2==2.12.0"', - } - for old, new in replacements.items(): - if old not in text: - raise SystemExit(f'missing expected TestClient assertion: {old}') - text = text.replace(old, new) - test_path.write_text(text) - - changelog_path = Path('CHANGELOG.md') - changelog = changelog_path.read_text() - old = '- Starlette `TestClient`의 기존 `httpx2==2.5.0` pin을 core 개발·테스트 의존성으로 승격하고, deprecated `httpx` fallback 경고 억제를 제거했습니다.' - new = '- Starlette `TestClient`의 preferred transport를 `httpx2==2.12.0`으로 고정하고, resolver가 선택한 `httpcore2==2.12.0`과 hash/uv lock을 함께 갱신했습니다. deprecated `httpx` fallback 경고 억제는 제거된 상태를 유지합니다.' - if changelog.count(old) != 1: - raise SystemExit(f'expected exactly one CHANGELOG predecessor, found {changelog.count(old)}') - changelog_path.write_text(changelog.replace(old, new, 1)) + target = "httpx2==2.13.0" + for path in (Path("backend/requirements.txt"), Path("backend/pyproject.toml")): + text = path.read_text() + old = "httpx2==2.5.0" + if text.count(old) != 1: + raise SystemExit(f"expected one {old} in {path}, found {text.count(old)}") + path.write_text(text.replace(old, target)) PY - git show "${PRE_HELPER_HEAD}:.github/workflows/app-ci.yml" > .github/workflows/app-ci.yml + ( + cd backend + uv lock --upgrade-package "httpx2==${HTTPX2_VERSION}" + uv lock --check + ) + uv pip compile \ + --generate-hashes \ + --python-version 3.14 \ + --python-platform x86_64-unknown-linux-gnu \ + --output-file backend/requirements-hashes.txt \ + backend/requirements.txt + grep -F "httpx2==${HTTPX2_VERSION}" backend/requirements-hashes.txt + grep -F "httpcore2==2.13.0" backend/requirements-hashes.txt git diff --check - printf '%s\n' \ - .github/workflows/app-ci.yml \ - CHANGELOG.md \ + mkdir -p .resolver-evidence + git diff --binary -- \ + backend/requirements.txt \ backend/pyproject.toml \ backend/requirements-hashes.txt \ + backend/uv.lock \ + > .resolver-evidence/generated.diff + sha256sum \ backend/requirements.txt \ - backend/tests/test_container_dependency_pin_contract.py \ - backend/uv.lock | sort > /tmp/expected-paths.txt - git diff --name-only | sort > /tmp/actual-paths.txt - diff -u /tmp/expected-paths.txt /tmp/actual-paths.txt + backend/pyproject.toml \ + backend/requirements-hashes.txt \ + backend/uv.lock \ + | tee .resolver-evidence/sha256.txt + cat > .resolver-evidence/upstream.txt <<'EOF' + candidate=httpx2==2.13.0 + expected_httpcore2=httpcore2==2.13.0 + upstream_release=v2.13.0 + upstream_published_at=2026-09-14T14:17:29Z + EOF - - name: Verify adopted graph before push - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + - name: Upload exact generated resolver evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - python-version: "3.14" - - - name: Run focused dependency acceptance - run: | - set -euo pipefail - python -m pip install --disable-pip-version-check "uv==${UV_VERSION}" - cd backend - uv lock --check - uv sync --frozen --group dev - uv run --frozen pytest -q -W error tests/test_container_dependency_pin_contract.py - uv run --frozen ruff check tests/test_container_dependency_pin_contract.py - - - name: Commit and non-force push coherent owner delta - env: - EVENT_HEAD: ${{ github.event.pull_request.head.sha }} - run: | - set -euo pipefail - remote_head="$(git ls-remote origin "refs/heads/${OWNER_BRANCH}" | cut -f1)" - test "$remote_head" = "$EVENT_HEAD" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add CHANGELOG.md backend/pyproject.toml backend/requirements-hashes.txt backend/requirements.txt backend/tests/test_container_dependency_pin_contract.py backend/uv.lock .github/workflows/app-ci.yml - git diff --cached --check - git commit -m "fix(deps): adopt verified httpx2 2.12.0 graph" - git push origin "HEAD:refs/heads/${OWNER_BRANCH}" + name: httpx2-2.13.0-resolution-${{ github.event.pull_request.head.sha }} + path: | + backend/requirements.txt + backend/pyproject.toml + backend/requirements-hashes.txt + backend/uv.lock + .resolver-evidence/generated.diff + .resolver-evidence/sha256.txt + .resolver-evidence/upstream.txt + if-no-files-found: error + retention-days: 7 + compression-level: 0 From d166c9208b275ab88895e7711d999c4729a81025 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 23 Sep 2026 18:42:38 +0900 Subject: [PATCH 21/28] fix(ci): include optional agent lock in httpx2 resolver evidence --- .github/workflows/app-ci.yml | 75 +++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml index ab01f0869..d95693110 100644 --- a/.github/workflows/app-ci.yml +++ b/.github/workflows/app-ci.yml @@ -171,7 +171,7 @@ jobs: - name: Install pinned resolver run: python -m pip install --disable-pip-version-check "uv==${UV_VERSION}" - - name: Resolve current secure TestClient dependency without writing repository history + - name: Resolve coherent core and optional-agent TestClient dependencies without writing repository history shell: bash run: | set -euo pipefail @@ -199,23 +199,95 @@ jobs: backend/requirements.txt grep -F "httpx2==${HTTPX2_VERSION}" backend/requirements-hashes.txt grep -F "httpcore2==2.13.0" backend/requirements-hashes.txt + + python - <<'PY' + from pathlib import Path + import re + + core_path = Path("backend/requirements-hashes.txt") + agent_path = Path("backend/requirements-agent.txt") + core_text = core_path.read_text() + agent_text = agent_path.read_text() + + def record(text: str, package: str) -> tuple[str, list[str]]: + lines = text.splitlines() + start = next( + (i for i, line in enumerate(lines) if line.startswith(f"{package}==")), + None, + ) + if start is None: + raise SystemExit(f"missing generated core record for {package}") + pin = lines[start].split(" \\", 1)[0] + hashes: list[str] = [] + for line in lines[start + 1 :]: + stripped = line.strip() + if stripped.startswith("--hash=sha256:"): + hashes.append(stripped.rstrip(" \\").removeprefix("--hash=sha256:")) + continue + if stripped.startswith("#"): + break + if line and not line.startswith((" ", "\t")): + break + if not hashes: + raise SystemExit(f"generated core record has no hashes for {package}") + return pin, hashes + + def replace_record(text: str, package: str, via: str) -> str: + pin, hashes = record(core_text, package) + lines = text.splitlines() + start = next( + (i for i, line in enumerate(lines) if line.startswith(f"{package}==")), + None, + ) + if start is None: + raise SystemExit(f"missing optional-agent record for {package}") + end = start + 1 + while end < len(lines): + stripped = lines[end].strip() + if stripped.startswith("--hash=sha256:") or stripped.startswith("#"): + end += 1 + continue + break + rendered = [f"{pin} \\"] + for index, digest in enumerate(hashes): + suffix = " \\" if index < len(hashes) - 1 else "" + rendered.append(f" --hash=sha256:{digest}{suffix}") + rendered.append(f" # via {via}") + return "\n".join(lines[:start] + rendered + lines[end:]) + "\n" + + agent_text = replace_record(agent_text, "httpcore2", "httpx2") + agent_text = replace_record(agent_text, "httpx2", "genai-prices") + if "httpx2==2.5.0" in agent_text or "httpcore2==2.5.0" in agent_text: + raise SystemExit("stale 2.5.0 TestClient dependency remains in optional-agent lock") + agent_path.write_text(agent_text) + PY + + python -m pip install --disable-pip-version-check --dry-run --ignore-installed --require-hashes \ + -r backend/requirements-hashes.txt \ + -r backend/requirements-agent.txt + grep -F "httpx2==${HTTPX2_VERSION}" backend/requirements-agent.txt + grep -F "httpcore2==2.13.0" backend/requirements-agent.txt git diff --check mkdir -p .resolver-evidence git diff --binary -- \ backend/requirements.txt \ backend/pyproject.toml \ backend/requirements-hashes.txt \ + backend/requirements-agent.txt \ backend/uv.lock \ > .resolver-evidence/generated.diff sha256sum \ backend/requirements.txt \ backend/pyproject.toml \ backend/requirements-hashes.txt \ + backend/requirements-agent.txt \ backend/uv.lock \ | tee .resolver-evidence/sha256.txt cat > .resolver-evidence/upstream.txt <<'EOF' candidate=httpx2==2.13.0 expected_httpcore2=httpcore2==2.13.0 + optional_agent_consumer=genai-prices==0.0.71 + optional_agent_requirement=httpx2>=2.0 upstream_release=v2.13.0 upstream_published_at=2026-09-14T14:17:29Z EOF @@ -228,6 +300,7 @@ jobs: backend/requirements.txt backend/pyproject.toml backend/requirements-hashes.txt + backend/requirements-agent.txt backend/uv.lock .resolver-evidence/generated.diff .resolver-evidence/sha256.txt From 4c5872ac29435f25034cd978a47e8cdcb185813a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 23 Sep 2026 21:26:42 +0900 Subject: [PATCH 22/28] ci(test): adopt verified coherent httpx2 graph once --- .github/workflows/app-ci.yml | 275 ++++++++++++++++++++++++++++++++++- 1 file changed, 274 insertions(+), 1 deletion(-) diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml index d95693110..6b43c04fd 100644 --- a/.github/workflows/app-ci.yml +++ b/.github/workflows/app-ci.yml @@ -202,7 +202,6 @@ jobs: python - <<'PY' from pathlib import Path - import re core_path = Path("backend/requirements-hashes.txt") agent_path = Path("backend/requirements-agent.txt") @@ -308,3 +307,277 @@ jobs: if-no-files-found: error retention-days: 7 compression-level: 0 + + adopt_httpx2_candidate: + name: adopt verified httpx2 2.13.0 graph + needs: resolve_httpx2_candidate + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.number == 1565 && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.head.ref == 'codex/starlette-testclient-dependency' + runs-on: ubuntu-latest + permissions: + actions: read + contents: write + env: + UV_VERSION: "0.10.0" + GH_TOKEN: ${{ github.token }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout exact PR head for ordinary adoption + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: true + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: Recover and independently verify exact resolver artifact + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha }}" + ARTIFACT_NAME="httpx2-2.13.0-resolution-${{ github.event.pull_request.head.sha }}" + export ARTIFACT_NAME + artifact_json="$(curl --fail --silent --show-error \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts?per_page=100")" + read -r ARTIFACT_ID ARTIFACT_DIGEST < <( + printf '%s' "${artifact_json}" | python -c ' + import json, os, sys + data = json.load(sys.stdin) + matches = [a for a in data.get("artifacts", []) if a.get("name") == os.environ["ARTIFACT_NAME"] and not a.get("expired")] + if len(matches) != 1: + raise SystemExit(f"expected exactly one live resolver artifact, found {len(matches)}") + artifact = matches[0] + print(artifact["id"], artifact["digest"]) + ' + ) + curl --fail --silent --show-error --location \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" \ + --output /tmp/httpx2-resolver.zip + printf '%s %s\n' "${ARTIFACT_DIGEST#sha256:}" /tmp/httpx2-resolver.zip | sha256sum -c - + rm -rf /tmp/httpx2-resolver + mkdir -p /tmp/httpx2-resolver + unzip -q /tmp/httpx2-resolver.zip -d /tmp/httpx2-resolver + ( + cd /tmp/httpx2-resolver + sha256sum -c .resolver-evidence/sha256.txt + ) + cat > /tmp/httpx2-expected.sha256 <<'EOF' + 87f603d06eb05fa234163003a2feda98f024b80751cc9eb919aedb261136482f backend/requirements.txt + faab5edd236153c28a321e431dc1a6d5f99bcaa64f26f21c60222a1db2057365 backend/pyproject.toml + 551f6aa4a6a8f1efb7f6259dc63777c40c09b2f520dea217575b94b12178f7d5 backend/requirements-hashes.txt + 761ceb9f7042ffa9538c5a596ad113a3ee59f27381c4a44d34df82338e5b913a backend/requirements-agent.txt + fa28138f637a2c2baddd528893cfb04be2d9064afcadd538fb7fa5000be7f82b backend/uv.lock + EOF + ( + cd /tmp/httpx2-resolver + sha256sum -c /tmp/httpx2-expected.sha256 + grep -Fx 'candidate=httpx2==2.13.0' .resolver-evidence/upstream.txt + grep -Fx 'expected_httpcore2=httpcore2==2.13.0' .resolver-evidence/upstream.txt + grep -Fx 'optional_agent_consumer=genai-prices==0.0.71' .resolver-evidence/upstream.txt + grep -Fx 'optional_agent_requirement=httpx2>=2.0' .resolver-evidence/upstream.txt + ! grep -R -n -E 'httpx2==2\.5\.0|httpcore2==2\.5\.0' \ + backend/requirements.txt backend/pyproject.toml backend/requirements-hashes.txt \ + backend/requirements-agent.txt backend/uv.lock + ) + + - name: Adopt generated graph and update owner contracts + shell: bash + run: | + set -euo pipefail + cp /tmp/httpx2-resolver/backend/requirements.txt backend/requirements.txt + cp /tmp/httpx2-resolver/backend/pyproject.toml backend/pyproject.toml + cp /tmp/httpx2-resolver/backend/requirements-hashes.txt backend/requirements-hashes.txt + cp /tmp/httpx2-resolver/backend/requirements-agent.txt backend/requirements-agent.txt + cp /tmp/httpx2-resolver/backend/uv.lock backend/uv.lock + + python - <<'PY' + from pathlib import Path + + test_path = Path("backend/tests/test_container_dependency_pin_contract.py") + test_text = test_path.read_text() + old_records = ''' backend_records = hashed_requirement_records(\n read_repo_text("backend/requirements-hashes.txt")\n )\n strix_pins = exact_requirement_pins(read_repo_text("requirements-strix-ci.txt"))\n''' + new_records = ''' backend_records = hashed_requirement_records(\n read_repo_text("backend/requirements-hashes.txt")\n )\n agent_records = hashed_requirement_records(\n read_repo_text("backend/requirements-agent.txt")\n )\n strix_pins = exact_requirement_pins(read_repo_text("requirements-strix-ci.txt"))\n''' + if test_text.count(old_records) != 1: + raise SystemExit("unexpected backend lock-record test structure") + test_text = test_text.replace(old_records, new_records) + test_text = test_text.replace( + 'assert backend_pins["httpx2"] == "2.5.0"', + 'assert backend_pins["httpx2"] == "2.13.0"', + ) + test_text = test_text.replace('"httpx2==2.5.0"', '"httpx2==2.13.0"') + old_anchor = ''' assert "protobuf==7.35.1" in backend_records\n assert all(\n''' + new_anchor = ''' assert "protobuf==7.35.1" in backend_records\n assert "httpcore2==2.13.0" in backend_records\n assert agent_records["httpx2==2.13.0"] == backend_records["httpx2==2.13.0"]\n assert agent_records["httpcore2==2.13.0"] == backend_records["httpcore2==2.13.0"]\n assert all(\n''' + if test_text.count(old_anchor) != 1: + raise SystemExit("unexpected backend pin assertion structure") + test_text = test_text.replace(old_anchor, new_anchor) + tuple_anchor = ''' "httpx2==2.13.0",\n "protobuf==7.35.1",\n''' + tuple_replacement = ''' "httpx2==2.13.0",\n "httpcore2==2.13.0",\n "protobuf==7.35.1",\n''' + if test_text.count(tuple_anchor) != 1: + raise SystemExit("unexpected backend digest tuple structure") + test_path.write_text(test_text.replace(tuple_anchor, tuple_replacement)) + + changelog_path = Path("CHANGELOG.md") + changelog = changelog_path.read_text() + old_line = "- Starlette `TestClient`의 기존 `httpx2==2.5.0` pin을 core 개발·테스트 의존성으로 승격하고, deprecated `httpx` fallback 경고 억제를 제거했습니다." + new_line = "- Starlette `TestClient`의 preferred transport를 `httpx2==2.13.0` / `httpcore2==2.13.0`으로 고정하고 core와 Noema-agent hash lock을 동일한 의존성 그래프로 동기화했습니다. deprecated `httpx` fallback 경고 억제도 제거해 transport 회귀가 warnings-as-errors 테스트에서 드러나도록 했습니다." + if changelog.count(old_line) != 1: + raise SystemExit("unexpected CHANGELOG TestClient entry") + changelog_path.write_text(changelog.replace(old_line, new_line)) + PY + + cat > docs/doctoring/starlette-httpx2-testclient-dependency.md <<'EOF' + # Starlette TestClient `httpx2` dependency + + ## Observed failure + + Protected `develop@042b0c70531b229af3acbd0421a2f23098d848b3` pins Starlette + 1.3.1 but did not install Starlette's preferred `httpx2` TestClient transport. + Importing `starlette.testclient` therefore fell back to deprecated `httpx`, and + warnings-as-errors runs stopped during collection. Removing the warning filter + without installing the preferred transport would expose the defect without + repairing it. + + ## Decision and dependency boundary + + Pin `httpx2==2.13.0` and resolved `httpcore2==2.13.0` in the repository's + core development/test graph. The Noema-agent lock participates in the same + `pip --require-hashes` transaction as the core lock, so it carries the exact + same `httpx2`/`httpcore2` records and hash sets instead of retaining the old + 2.5.0 graph. `genai-prices==0.0.71` requires `httpx2>=2.0`, so this coherent + pin stays within its declared consumer contract. + + The generated `uv.lock` also records the 2.13.0 platform split: `httpcore2` + on non-Emscripten platforms and `httpx2-jsfetch==1.0` on Emscripten. Naruon's + application HTTP clients remain on their existing `httpx` path; this change is + the Starlette TestClient transport boundary, not an application-client rewrite. + + The accepted five-file candidate was generated read-only with Python 3.14 and + `uv==0.10.0`, then checked as one core+agent hash-locked dry-run. Artifact + `10748790356` from Application CI `35844541085` has archive digest + `sha256:5355320323967e3cd58bdd8f05d1914c63d68790f28bf28b47bfba7fe019942b`. + Its independently verified file digests are: + + - `requirements.txt`: `87f603d06eb05fa234163003a2feda98f024b80751cc9eb919aedb261136482f`; + - `pyproject.toml`: `faab5edd236153c28a321e431dc1a6d5f99bcaa64f26f21c60222a1db2057365`; + - `requirements-hashes.txt`: `551f6aa4a6a8f1efb7f6259dc63777c40c09b2f520dea217575b94b12178f7d5`; + - `requirements-agent.txt`: `761ceb9f7042ffa9538c5a596ad113a3ee59f27381c4a44d34df82338e5b913a`; + - `uv.lock`: `fa28138f637a2c2baddd528893cfb04be2d9064afcadd538fb7fa5000be7f82b`. + + These digests establish the adopted bytes; they do not transfer merge or + release authority. The temporary resolver/adoption workflow is removed from + the adopted candidate before ordinary exact-head CI is evaluated. + + ## Verification and rollback + + Run from the repository root: + + ```bash + cd backend && uv lock --check + python -m pip install --disable-pip-version-check --require-hashes \ + -r backend/requirements-hashes.txt -r backend/requirements-agent.txt + cd backend && python -m pytest -q -W error tests/test_container_dependency_pin_contract.py + cd backend && python -m ruff check tests/test_container_dependency_pin_contract.py + ``` + + The structural regression verifies the direct 2.13.0 pin, the core hash-lock + records, exact core/agent hash equality for `httpx2` and `httpcore2`, and the + runtime module selected by `starlette.testclient`. Full acceptance still + requires the normal helper-free current-head Application CI, Security, CodeQL, + required workflows and qualifying independent review. + + Rollback removes the direct pin, both coherent lock records, runtime assertion, + and obsolete-warning-filter removal together. Do not restore only the warning + suppression or leave the core and optional-agent locks on different graphs. + + ## References + + Kludex. (2026). *Starlette release notes*. GitHub. + https://github.com/Kludex/starlette/blob/main/docs/release-notes.md + + Python Packaging Authority. (2026). *httpx2 2.13.0 file details and provenance*. + PyPI. https://pypi.org/project/httpx2/2.13.0/ + + Pydantic. (2026). *HTTPX2 v2.13.0* [Source code]. GitHub. + https://github.com/pydantic/httpx2/tree/v2.13.0 + EOF + sed -i 's/^ //' docs/doctoring/starlette-httpx2-testclient-dependency.md + + # Remove the temporary resolver/adopter from the candidate itself. + git show "${{ github.event.pull_request.base.sha }}:.github/workflows/app-ci.yml" > .github/workflows/app-ci.yml + git diff --check + + - name: Verify helper-free coherent candidate locally + shell: bash + run: | + set -euo pipefail + python -m pip install --disable-pip-version-check "uv==${UV_VERSION}" + ( + cd backend + uv lock --check + ) + python -m pip install --disable-pip-version-check --require-hashes \ + -r backend/requirements-hashes.txt \ + -r backend/requirements-agent.txt + ( + cd backend + python -m pytest -q -W error tests/test_container_dependency_pin_contract.py + python -m ruff check tests/test_container_dependency_pin_contract.py + ) + ! grep -R -n -E 'httpx2==2\.5\.0|httpcore2==2\.5\.0' \ + backend/requirements.txt backend/pyproject.toml backend/requirements-hashes.txt \ + backend/requirements-agent.txt backend/uv.lock + + cat > /tmp/expected-paths <<'EOF' + .github/workflows/app-ci.yml + CHANGELOG.md + backend/pyproject.toml + backend/requirements-agent.txt + backend/requirements-hashes.txt + backend/requirements.txt + backend/tests/test_container_dependency_pin_contract.py + backend/uv.lock + docs/doctoring/starlette-httpx2-testclient-dependency.md + EOF + sed -i 's/^ //' /tmp/expected-paths + git diff --name-only | sort > /tmp/actual-paths + sort -o /tmp/expected-paths /tmp/expected-paths + diff -u /tmp/expected-paths /tmp/actual-paths + + - name: Ordinary non-force adoption push + shell: bash + run: | + set -euo pipefail + branch="${{ github.event.pull_request.head.ref }}" + git fetch --no-tags origin "refs/heads/${branch}:refs/remotes/origin/${branch}" + test "$(git rev-parse HEAD)" = "$(git rev-parse "refs/remotes/origin/${branch}")" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -- \ + .github/workflows/app-ci.yml \ + CHANGELOG.md \ + backend/pyproject.toml \ + backend/requirements-agent.txt \ + backend/requirements-hashes.txt \ + backend/requirements.txt \ + backend/tests/test_container_dependency_pin_contract.py \ + backend/uv.lock \ + docs/doctoring/starlette-httpx2-testclient-dependency.md + git diff --cached --check + git commit -m "fix(test): adopt coherent httpx2 2.13.0 dependency graph" + git push origin "HEAD:refs/heads/${branch}" From 65c16a5807e1f0b04bc2962cec03f00eafbe93a4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 04:45:42 +0900 Subject: [PATCH 23/28] ci(test): stage adopter base-absence repair --- ...porary-httpx2-adopter-base-absence-fix.yml | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/temporary-httpx2-adopter-base-absence-fix.yml diff --git a/.github/workflows/temporary-httpx2-adopter-base-absence-fix.yml b/.github/workflows/temporary-httpx2-adopter-base-absence-fix.yml new file mode 100644 index 000000000..93b73facf --- /dev/null +++ b/.github/workflows/temporary-httpx2-adopter-base-absence-fix.yml @@ -0,0 +1,58 @@ +name: Temporary httpx2 adopter base-absence fix + +on: + push: + branches: + - codex/starlette-testclient-dependency + +permissions: + contents: write + +jobs: + repair-adopter: + if: github.repository == 'ContextualWisdomLab/naruon' + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout exact branch head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: true + + - name: Replace invalid base-file restore with base-aware removal + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + python - <<'PY' + from pathlib import Path + + path = Path('.github/workflows/app-ci.yml') + text = path.read_text() + old = ''' # Remove the temporary resolver/adopter from the candidate itself.\n git show "${{ github.event.pull_request.base.sha }}:.github/workflows/app-ci.yml" > .github/workflows/app-ci.yml\n git diff --check\n''' + new = ''' # Remove the temporary resolver/adopter from the candidate itself.\n if git cat-file -e "${{ github.event.pull_request.base.sha }}:.github/workflows/app-ci.yml" 2>/dev/null; then\n git show "${{ github.event.pull_request.base.sha }}:.github/workflows/app-ci.yml" > .github/workflows/app-ci.yml\n else\n git rm .github/workflows/app-ci.yml\n fi\n git diff --check\n''' + if text.count(old) != 1: + raise SystemExit('unexpected app-ci adopter restore structure') + path.write_text(text.replace(old, new)) + PY + rm .github/workflows/temporary-httpx2-adopter-base-absence-fix.yml + git diff --check + + - name: Ordinary non-force repair push + shell: bash + run: | + set -euo pipefail + branch="${GITHUB_REF_NAME}" + git fetch --no-tags origin "refs/heads/${branch}:refs/remotes/origin/${branch}" + test "$(git rev-parse HEAD)" = "$(git rev-parse "refs/remotes/origin/${branch}")" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -- .github/workflows/app-ci.yml .github/workflows/temporary-httpx2-adopter-base-absence-fix.yml + git diff --cached --check + git commit -m "fix(ci): handle missing protected-base app-ci during adoption" + git push origin "HEAD:refs/heads/${branch}" From 45d7c96d9a45caab004dd7e57de2952e328e7623 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 09:42:18 +0900 Subject: [PATCH 24/28] fix(ci): preserve pull-request expression in adopter repair --- ...emporary-httpx2-adopter-base-absence-fix.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/temporary-httpx2-adopter-base-absence-fix.yml b/.github/workflows/temporary-httpx2-adopter-base-absence-fix.yml index 93b73facf..4c16f0462 100644 --- a/.github/workflows/temporary-httpx2-adopter-base-absence-fix.yml +++ b/.github/workflows/temporary-httpx2-adopter-base-absence-fix.yml @@ -34,8 +34,21 @@ jobs: path = Path('.github/workflows/app-ci.yml') text = path.read_text() - old = ''' # Remove the temporary resolver/adopter from the candidate itself.\n git show "${{ github.event.pull_request.base.sha }}:.github/workflows/app-ci.yml" > .github/workflows/app-ci.yml\n git diff --check\n''' - new = ''' # Remove the temporary resolver/adopter from the candidate itself.\n if git cat-file -e "${{ github.event.pull_request.base.sha }}:.github/workflows/app-ci.yml" 2>/dev/null; then\n git show "${{ github.event.pull_request.base.sha }}:.github/workflows/app-ci.yml" > .github/workflows/app-ci.yml\n else\n git rm .github/workflows/app-ci.yml\n fi\n git diff --check\n''' + expr = '$' + '{{ github.event.pull_request.base.sha }}' + old = ( + ' # Remove the temporary resolver/adopter from the candidate itself.\n' + f' git show "{expr}:.github/workflows/app-ci.yml" > .github/workflows/app-ci.yml\n' + ' git diff --check\n' + ) + new = ( + ' # Remove the temporary resolver/adopter from the candidate itself.\n' + f' if git cat-file -e "{expr}:.github/workflows/app-ci.yml" 2>/dev/null; then\n' + f' git show "{expr}:.github/workflows/app-ci.yml" > .github/workflows/app-ci.yml\n' + ' else\n' + ' git rm .github/workflows/app-ci.yml\n' + ' fi\n' + ' git diff --check\n' + ) if text.count(old) != 1: raise SystemExit('unexpected app-ci adopter restore structure') path.write_text(text.replace(old, new)) From 2267a7146dd37b8c2bacf133da20e0d97ccb3d2f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 13:54:30 +0900 Subject: [PATCH 25/28] fix(ci): split adopter repair push diagnostics --- ...porary-httpx2-adopter-base-absence-fix.yml | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/.github/workflows/temporary-httpx2-adopter-base-absence-fix.yml b/.github/workflows/temporary-httpx2-adopter-base-absence-fix.yml index 4c16f0462..4f80801e4 100644 --- a/.github/workflows/temporary-httpx2-adopter-base-absence-fix.yml +++ b/.github/workflows/temporary-httpx2-adopter-base-absence-fix.yml @@ -24,7 +24,7 @@ jobs: ref: ${{ github.sha }} persist-credentials: true - - name: Replace invalid base-file restore with base-aware removal + - name: Apply base-aware adopter cleanup shell: bash run: | set -euo pipefail @@ -56,16 +56,38 @@ jobs: rm .github/workflows/temporary-httpx2-adopter-base-absence-fix.yml git diff --check - - name: Ordinary non-force repair push + - name: Verify remote head equality shell: bash run: | set -euo pipefail branch="${GITHUB_REF_NAME}" git fetch --no-tags origin "refs/heads/${branch}:refs/remotes/origin/${branch}" test "$(git rev-parse HEAD)" = "$(git rev-parse "refs/remotes/origin/${branch}")" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Stage exact repair delta + shell: bash + run: | + set -euo pipefail git add -- .github/workflows/app-ci.yml .github/workflows/temporary-httpx2-adopter-base-absence-fix.yml git diff --cached --check + git diff --cached --name-only | sort | tee /tmp/staged-paths + printf '%s\n' \ + .github/workflows/app-ci.yml \ + .github/workflows/temporary-httpx2-adopter-base-absence-fix.yml \ + | sort > /tmp/expected-paths + diff -u /tmp/expected-paths /tmp/staged-paths + + - name: Commit repair locally + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git commit -m "fix(ci): handle missing protected-base app-ci during adoption" - git push origin "HEAD:refs/heads/${branch}" + + - name: Push repair non-force + shell: bash + run: | + set -euo pipefail + branch="${GITHUB_REF_NAME}" + git push --verbose origin "HEAD:refs/heads/${branch}" From 4e5f8609f627c7d1bd24bf148fa11682ae67e503 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 17:47:23 +0900 Subject: [PATCH 26/28] fix(ci): replace workflow self-mutation with product-only adopter --- .github/workflows/app-ci.yml | 444 ------------------ ...porary-httpx2-adopter-base-absence-fix.yml | 93 ---- .../temporary-httpx2-product-adopter.yml | 244 ++++++++++ 3 files changed, 244 insertions(+), 537 deletions(-) delete mode 100644 .github/workflows/temporary-httpx2-adopter-base-absence-fix.yml create mode 100644 .github/workflows/temporary-httpx2-product-adopter.yml diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml index 6b43c04fd..e8f445748 100644 --- a/.github/workflows/app-ci.yml +++ b/.github/workflows/app-ci.yml @@ -137,447 +137,3 @@ jobs: NARUON_FULL_PRODUCT_BASE_URL: "http://127.0.0.1:3001" NARUON_FULL_PRODUCT_SCREENSHOT_DIR: "/tmp/naruon-full-product-smoke" run: cd frontend && pnpm run full:smoke - - resolve_httpx2_candidate: - name: resolve httpx2 2.13.0 candidate - if: >- - github.event_name == 'pull_request' && - github.event.pull_request.number == 1565 && - github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.head.ref == 'codex/starlette-testclient-dependency' - runs-on: ubuntu-latest - permissions: - contents: read - env: - UV_VERSION: "0.10.0" - HTTPX2_VERSION: "2.13.0" - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout exact PR head - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - ref: ${{ github.event.pull_request.head.sha }} - - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - - - name: Install pinned resolver - run: python -m pip install --disable-pip-version-check "uv==${UV_VERSION}" - - - name: Resolve coherent core and optional-agent TestClient dependencies without writing repository history - shell: bash - run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - - target = "httpx2==2.13.0" - for path in (Path("backend/requirements.txt"), Path("backend/pyproject.toml")): - text = path.read_text() - old = "httpx2==2.5.0" - if text.count(old) != 1: - raise SystemExit(f"expected one {old} in {path}, found {text.count(old)}") - path.write_text(text.replace(old, target)) - PY - ( - cd backend - uv lock --upgrade-package "httpx2==${HTTPX2_VERSION}" - uv lock --check - ) - uv pip compile \ - --generate-hashes \ - --python-version 3.14 \ - --python-platform x86_64-unknown-linux-gnu \ - --output-file backend/requirements-hashes.txt \ - backend/requirements.txt - grep -F "httpx2==${HTTPX2_VERSION}" backend/requirements-hashes.txt - grep -F "httpcore2==2.13.0" backend/requirements-hashes.txt - - python - <<'PY' - from pathlib import Path - - core_path = Path("backend/requirements-hashes.txt") - agent_path = Path("backend/requirements-agent.txt") - core_text = core_path.read_text() - agent_text = agent_path.read_text() - - def record(text: str, package: str) -> tuple[str, list[str]]: - lines = text.splitlines() - start = next( - (i for i, line in enumerate(lines) if line.startswith(f"{package}==")), - None, - ) - if start is None: - raise SystemExit(f"missing generated core record for {package}") - pin = lines[start].split(" \\", 1)[0] - hashes: list[str] = [] - for line in lines[start + 1 :]: - stripped = line.strip() - if stripped.startswith("--hash=sha256:"): - hashes.append(stripped.rstrip(" \\").removeprefix("--hash=sha256:")) - continue - if stripped.startswith("#"): - break - if line and not line.startswith((" ", "\t")): - break - if not hashes: - raise SystemExit(f"generated core record has no hashes for {package}") - return pin, hashes - - def replace_record(text: str, package: str, via: str) -> str: - pin, hashes = record(core_text, package) - lines = text.splitlines() - start = next( - (i for i, line in enumerate(lines) if line.startswith(f"{package}==")), - None, - ) - if start is None: - raise SystemExit(f"missing optional-agent record for {package}") - end = start + 1 - while end < len(lines): - stripped = lines[end].strip() - if stripped.startswith("--hash=sha256:") or stripped.startswith("#"): - end += 1 - continue - break - rendered = [f"{pin} \\"] - for index, digest in enumerate(hashes): - suffix = " \\" if index < len(hashes) - 1 else "" - rendered.append(f" --hash=sha256:{digest}{suffix}") - rendered.append(f" # via {via}") - return "\n".join(lines[:start] + rendered + lines[end:]) + "\n" - - agent_text = replace_record(agent_text, "httpcore2", "httpx2") - agent_text = replace_record(agent_text, "httpx2", "genai-prices") - if "httpx2==2.5.0" in agent_text or "httpcore2==2.5.0" in agent_text: - raise SystemExit("stale 2.5.0 TestClient dependency remains in optional-agent lock") - agent_path.write_text(agent_text) - PY - - python -m pip install --disable-pip-version-check --dry-run --ignore-installed --require-hashes \ - -r backend/requirements-hashes.txt \ - -r backend/requirements-agent.txt - grep -F "httpx2==${HTTPX2_VERSION}" backend/requirements-agent.txt - grep -F "httpcore2==2.13.0" backend/requirements-agent.txt - git diff --check - mkdir -p .resolver-evidence - git diff --binary -- \ - backend/requirements.txt \ - backend/pyproject.toml \ - backend/requirements-hashes.txt \ - backend/requirements-agent.txt \ - backend/uv.lock \ - > .resolver-evidence/generated.diff - sha256sum \ - backend/requirements.txt \ - backend/pyproject.toml \ - backend/requirements-hashes.txt \ - backend/requirements-agent.txt \ - backend/uv.lock \ - | tee .resolver-evidence/sha256.txt - cat > .resolver-evidence/upstream.txt <<'EOF' - candidate=httpx2==2.13.0 - expected_httpcore2=httpcore2==2.13.0 - optional_agent_consumer=genai-prices==0.0.71 - optional_agent_requirement=httpx2>=2.0 - upstream_release=v2.13.0 - upstream_published_at=2026-09-14T14:17:29Z - EOF - - - name: Upload exact generated resolver evidence - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: httpx2-2.13.0-resolution-${{ github.event.pull_request.head.sha }} - path: | - backend/requirements.txt - backend/pyproject.toml - backend/requirements-hashes.txt - backend/requirements-agent.txt - backend/uv.lock - .resolver-evidence/generated.diff - .resolver-evidence/sha256.txt - .resolver-evidence/upstream.txt - if-no-files-found: error - retention-days: 7 - compression-level: 0 - - adopt_httpx2_candidate: - name: adopt verified httpx2 2.13.0 graph - needs: resolve_httpx2_candidate - if: >- - github.event_name == 'pull_request' && - github.event.pull_request.number == 1565 && - github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.head.ref == 'codex/starlette-testclient-dependency' - runs-on: ubuntu-latest - permissions: - actions: read - contents: write - env: - UV_VERSION: "0.10.0" - GH_TOKEN: ${{ github.token }} - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout exact PR head for ordinary adoption - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: true - - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - - - name: Recover and independently verify exact resolver artifact - shell: bash - run: | - set -euo pipefail - test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha }}" - ARTIFACT_NAME="httpx2-2.13.0-resolution-${{ github.event.pull_request.head.sha }}" - export ARTIFACT_NAME - artifact_json="$(curl --fail --silent --show-error \ - -H "Authorization: Bearer ${GH_TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts?per_page=100")" - read -r ARTIFACT_ID ARTIFACT_DIGEST < <( - printf '%s' "${artifact_json}" | python -c ' - import json, os, sys - data = json.load(sys.stdin) - matches = [a for a in data.get("artifacts", []) if a.get("name") == os.environ["ARTIFACT_NAME"] and not a.get("expired")] - if len(matches) != 1: - raise SystemExit(f"expected exactly one live resolver artifact, found {len(matches)}") - artifact = matches[0] - print(artifact["id"], artifact["digest"]) - ' - ) - curl --fail --silent --show-error --location \ - -H "Authorization: Bearer ${GH_TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" \ - --output /tmp/httpx2-resolver.zip - printf '%s %s\n' "${ARTIFACT_DIGEST#sha256:}" /tmp/httpx2-resolver.zip | sha256sum -c - - rm -rf /tmp/httpx2-resolver - mkdir -p /tmp/httpx2-resolver - unzip -q /tmp/httpx2-resolver.zip -d /tmp/httpx2-resolver - ( - cd /tmp/httpx2-resolver - sha256sum -c .resolver-evidence/sha256.txt - ) - cat > /tmp/httpx2-expected.sha256 <<'EOF' - 87f603d06eb05fa234163003a2feda98f024b80751cc9eb919aedb261136482f backend/requirements.txt - faab5edd236153c28a321e431dc1a6d5f99bcaa64f26f21c60222a1db2057365 backend/pyproject.toml - 551f6aa4a6a8f1efb7f6259dc63777c40c09b2f520dea217575b94b12178f7d5 backend/requirements-hashes.txt - 761ceb9f7042ffa9538c5a596ad113a3ee59f27381c4a44d34df82338e5b913a backend/requirements-agent.txt - fa28138f637a2c2baddd528893cfb04be2d9064afcadd538fb7fa5000be7f82b backend/uv.lock - EOF - ( - cd /tmp/httpx2-resolver - sha256sum -c /tmp/httpx2-expected.sha256 - grep -Fx 'candidate=httpx2==2.13.0' .resolver-evidence/upstream.txt - grep -Fx 'expected_httpcore2=httpcore2==2.13.0' .resolver-evidence/upstream.txt - grep -Fx 'optional_agent_consumer=genai-prices==0.0.71' .resolver-evidence/upstream.txt - grep -Fx 'optional_agent_requirement=httpx2>=2.0' .resolver-evidence/upstream.txt - ! grep -R -n -E 'httpx2==2\.5\.0|httpcore2==2\.5\.0' \ - backend/requirements.txt backend/pyproject.toml backend/requirements-hashes.txt \ - backend/requirements-agent.txt backend/uv.lock - ) - - - name: Adopt generated graph and update owner contracts - shell: bash - run: | - set -euo pipefail - cp /tmp/httpx2-resolver/backend/requirements.txt backend/requirements.txt - cp /tmp/httpx2-resolver/backend/pyproject.toml backend/pyproject.toml - cp /tmp/httpx2-resolver/backend/requirements-hashes.txt backend/requirements-hashes.txt - cp /tmp/httpx2-resolver/backend/requirements-agent.txt backend/requirements-agent.txt - cp /tmp/httpx2-resolver/backend/uv.lock backend/uv.lock - - python - <<'PY' - from pathlib import Path - - test_path = Path("backend/tests/test_container_dependency_pin_contract.py") - test_text = test_path.read_text() - old_records = ''' backend_records = hashed_requirement_records(\n read_repo_text("backend/requirements-hashes.txt")\n )\n strix_pins = exact_requirement_pins(read_repo_text("requirements-strix-ci.txt"))\n''' - new_records = ''' backend_records = hashed_requirement_records(\n read_repo_text("backend/requirements-hashes.txt")\n )\n agent_records = hashed_requirement_records(\n read_repo_text("backend/requirements-agent.txt")\n )\n strix_pins = exact_requirement_pins(read_repo_text("requirements-strix-ci.txt"))\n''' - if test_text.count(old_records) != 1: - raise SystemExit("unexpected backend lock-record test structure") - test_text = test_text.replace(old_records, new_records) - test_text = test_text.replace( - 'assert backend_pins["httpx2"] == "2.5.0"', - 'assert backend_pins["httpx2"] == "2.13.0"', - ) - test_text = test_text.replace('"httpx2==2.5.0"', '"httpx2==2.13.0"') - old_anchor = ''' assert "protobuf==7.35.1" in backend_records\n assert all(\n''' - new_anchor = ''' assert "protobuf==7.35.1" in backend_records\n assert "httpcore2==2.13.0" in backend_records\n assert agent_records["httpx2==2.13.0"] == backend_records["httpx2==2.13.0"]\n assert agent_records["httpcore2==2.13.0"] == backend_records["httpcore2==2.13.0"]\n assert all(\n''' - if test_text.count(old_anchor) != 1: - raise SystemExit("unexpected backend pin assertion structure") - test_text = test_text.replace(old_anchor, new_anchor) - tuple_anchor = ''' "httpx2==2.13.0",\n "protobuf==7.35.1",\n''' - tuple_replacement = ''' "httpx2==2.13.0",\n "httpcore2==2.13.0",\n "protobuf==7.35.1",\n''' - if test_text.count(tuple_anchor) != 1: - raise SystemExit("unexpected backend digest tuple structure") - test_path.write_text(test_text.replace(tuple_anchor, tuple_replacement)) - - changelog_path = Path("CHANGELOG.md") - changelog = changelog_path.read_text() - old_line = "- Starlette `TestClient`의 기존 `httpx2==2.5.0` pin을 core 개발·테스트 의존성으로 승격하고, deprecated `httpx` fallback 경고 억제를 제거했습니다." - new_line = "- Starlette `TestClient`의 preferred transport를 `httpx2==2.13.0` / `httpcore2==2.13.0`으로 고정하고 core와 Noema-agent hash lock을 동일한 의존성 그래프로 동기화했습니다. deprecated `httpx` fallback 경고 억제도 제거해 transport 회귀가 warnings-as-errors 테스트에서 드러나도록 했습니다." - if changelog.count(old_line) != 1: - raise SystemExit("unexpected CHANGELOG TestClient entry") - changelog_path.write_text(changelog.replace(old_line, new_line)) - PY - - cat > docs/doctoring/starlette-httpx2-testclient-dependency.md <<'EOF' - # Starlette TestClient `httpx2` dependency - - ## Observed failure - - Protected `develop@042b0c70531b229af3acbd0421a2f23098d848b3` pins Starlette - 1.3.1 but did not install Starlette's preferred `httpx2` TestClient transport. - Importing `starlette.testclient` therefore fell back to deprecated `httpx`, and - warnings-as-errors runs stopped during collection. Removing the warning filter - without installing the preferred transport would expose the defect without - repairing it. - - ## Decision and dependency boundary - - Pin `httpx2==2.13.0` and resolved `httpcore2==2.13.0` in the repository's - core development/test graph. The Noema-agent lock participates in the same - `pip --require-hashes` transaction as the core lock, so it carries the exact - same `httpx2`/`httpcore2` records and hash sets instead of retaining the old - 2.5.0 graph. `genai-prices==0.0.71` requires `httpx2>=2.0`, so this coherent - pin stays within its declared consumer contract. - - The generated `uv.lock` also records the 2.13.0 platform split: `httpcore2` - on non-Emscripten platforms and `httpx2-jsfetch==1.0` on Emscripten. Naruon's - application HTTP clients remain on their existing `httpx` path; this change is - the Starlette TestClient transport boundary, not an application-client rewrite. - - The accepted five-file candidate was generated read-only with Python 3.14 and - `uv==0.10.0`, then checked as one core+agent hash-locked dry-run. Artifact - `10748790356` from Application CI `35844541085` has archive digest - `sha256:5355320323967e3cd58bdd8f05d1914c63d68790f28bf28b47bfba7fe019942b`. - Its independently verified file digests are: - - - `requirements.txt`: `87f603d06eb05fa234163003a2feda98f024b80751cc9eb919aedb261136482f`; - - `pyproject.toml`: `faab5edd236153c28a321e431dc1a6d5f99bcaa64f26f21c60222a1db2057365`; - - `requirements-hashes.txt`: `551f6aa4a6a8f1efb7f6259dc63777c40c09b2f520dea217575b94b12178f7d5`; - - `requirements-agent.txt`: `761ceb9f7042ffa9538c5a596ad113a3ee59f27381c4a44d34df82338e5b913a`; - - `uv.lock`: `fa28138f637a2c2baddd528893cfb04be2d9064afcadd538fb7fa5000be7f82b`. - - These digests establish the adopted bytes; they do not transfer merge or - release authority. The temporary resolver/adoption workflow is removed from - the adopted candidate before ordinary exact-head CI is evaluated. - - ## Verification and rollback - - Run from the repository root: - - ```bash - cd backend && uv lock --check - python -m pip install --disable-pip-version-check --require-hashes \ - -r backend/requirements-hashes.txt -r backend/requirements-agent.txt - cd backend && python -m pytest -q -W error tests/test_container_dependency_pin_contract.py - cd backend && python -m ruff check tests/test_container_dependency_pin_contract.py - ``` - - The structural regression verifies the direct 2.13.0 pin, the core hash-lock - records, exact core/agent hash equality for `httpx2` and `httpcore2`, and the - runtime module selected by `starlette.testclient`. Full acceptance still - requires the normal helper-free current-head Application CI, Security, CodeQL, - required workflows and qualifying independent review. - - Rollback removes the direct pin, both coherent lock records, runtime assertion, - and obsolete-warning-filter removal together. Do not restore only the warning - suppression or leave the core and optional-agent locks on different graphs. - - ## References - - Kludex. (2026). *Starlette release notes*. GitHub. - https://github.com/Kludex/starlette/blob/main/docs/release-notes.md - - Python Packaging Authority. (2026). *httpx2 2.13.0 file details and provenance*. - PyPI. https://pypi.org/project/httpx2/2.13.0/ - - Pydantic. (2026). *HTTPX2 v2.13.0* [Source code]. GitHub. - https://github.com/pydantic/httpx2/tree/v2.13.0 - EOF - sed -i 's/^ //' docs/doctoring/starlette-httpx2-testclient-dependency.md - - # Remove the temporary resolver/adopter from the candidate itself. - git show "${{ github.event.pull_request.base.sha }}:.github/workflows/app-ci.yml" > .github/workflows/app-ci.yml - git diff --check - - - name: Verify helper-free coherent candidate locally - shell: bash - run: | - set -euo pipefail - python -m pip install --disable-pip-version-check "uv==${UV_VERSION}" - ( - cd backend - uv lock --check - ) - python -m pip install --disable-pip-version-check --require-hashes \ - -r backend/requirements-hashes.txt \ - -r backend/requirements-agent.txt - ( - cd backend - python -m pytest -q -W error tests/test_container_dependency_pin_contract.py - python -m ruff check tests/test_container_dependency_pin_contract.py - ) - ! grep -R -n -E 'httpx2==2\.5\.0|httpcore2==2\.5\.0' \ - backend/requirements.txt backend/pyproject.toml backend/requirements-hashes.txt \ - backend/requirements-agent.txt backend/uv.lock - - cat > /tmp/expected-paths <<'EOF' - .github/workflows/app-ci.yml - CHANGELOG.md - backend/pyproject.toml - backend/requirements-agent.txt - backend/requirements-hashes.txt - backend/requirements.txt - backend/tests/test_container_dependency_pin_contract.py - backend/uv.lock - docs/doctoring/starlette-httpx2-testclient-dependency.md - EOF - sed -i 's/^ //' /tmp/expected-paths - git diff --name-only | sort > /tmp/actual-paths - sort -o /tmp/expected-paths /tmp/expected-paths - diff -u /tmp/expected-paths /tmp/actual-paths - - - name: Ordinary non-force adoption push - shell: bash - run: | - set -euo pipefail - branch="${{ github.event.pull_request.head.ref }}" - git fetch --no-tags origin "refs/heads/${branch}:refs/remotes/origin/${branch}" - test "$(git rev-parse HEAD)" = "$(git rev-parse "refs/remotes/origin/${branch}")" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -- \ - .github/workflows/app-ci.yml \ - CHANGELOG.md \ - backend/pyproject.toml \ - backend/requirements-agent.txt \ - backend/requirements-hashes.txt \ - backend/requirements.txt \ - backend/tests/test_container_dependency_pin_contract.py \ - backend/uv.lock \ - docs/doctoring/starlette-httpx2-testclient-dependency.md - git diff --cached --check - git commit -m "fix(test): adopt coherent httpx2 2.13.0 dependency graph" - git push origin "HEAD:refs/heads/${branch}" diff --git a/.github/workflows/temporary-httpx2-adopter-base-absence-fix.yml b/.github/workflows/temporary-httpx2-adopter-base-absence-fix.yml deleted file mode 100644 index 4f80801e4..000000000 --- a/.github/workflows/temporary-httpx2-adopter-base-absence-fix.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: Temporary httpx2 adopter base-absence fix - -on: - push: - branches: - - codex/starlette-testclient-dependency - -permissions: - contents: write - -jobs: - repair-adopter: - if: github.repository == 'ContextualWisdomLab/naruon' - runs-on: ubuntu-latest - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout exact branch head - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - persist-credentials: true - - - name: Apply base-aware adopter cleanup - shell: bash - run: | - set -euo pipefail - test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" - python - <<'PY' - from pathlib import Path - - path = Path('.github/workflows/app-ci.yml') - text = path.read_text() - expr = '$' + '{{ github.event.pull_request.base.sha }}' - old = ( - ' # Remove the temporary resolver/adopter from the candidate itself.\n' - f' git show "{expr}:.github/workflows/app-ci.yml" > .github/workflows/app-ci.yml\n' - ' git diff --check\n' - ) - new = ( - ' # Remove the temporary resolver/adopter from the candidate itself.\n' - f' if git cat-file -e "{expr}:.github/workflows/app-ci.yml" 2>/dev/null; then\n' - f' git show "{expr}:.github/workflows/app-ci.yml" > .github/workflows/app-ci.yml\n' - ' else\n' - ' git rm .github/workflows/app-ci.yml\n' - ' fi\n' - ' git diff --check\n' - ) - if text.count(old) != 1: - raise SystemExit('unexpected app-ci adopter restore structure') - path.write_text(text.replace(old, new)) - PY - rm .github/workflows/temporary-httpx2-adopter-base-absence-fix.yml - git diff --check - - - name: Verify remote head equality - shell: bash - run: | - set -euo pipefail - branch="${GITHUB_REF_NAME}" - git fetch --no-tags origin "refs/heads/${branch}:refs/remotes/origin/${branch}" - test "$(git rev-parse HEAD)" = "$(git rev-parse "refs/remotes/origin/${branch}")" - - - name: Stage exact repair delta - shell: bash - run: | - set -euo pipefail - git add -- .github/workflows/app-ci.yml .github/workflows/temporary-httpx2-adopter-base-absence-fix.yml - git diff --cached --check - git diff --cached --name-only | sort | tee /tmp/staged-paths - printf '%s\n' \ - .github/workflows/app-ci.yml \ - .github/workflows/temporary-httpx2-adopter-base-absence-fix.yml \ - | sort > /tmp/expected-paths - diff -u /tmp/expected-paths /tmp/staged-paths - - - name: Commit repair locally - shell: bash - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "fix(ci): handle missing protected-base app-ci during adoption" - - - name: Push repair non-force - shell: bash - run: | - set -euo pipefail - branch="${GITHUB_REF_NAME}" - git push --verbose origin "HEAD:refs/heads/${branch}" diff --git a/.github/workflows/temporary-httpx2-product-adopter.yml b/.github/workflows/temporary-httpx2-product-adopter.yml new file mode 100644 index 000000000..814002fe4 --- /dev/null +++ b/.github/workflows/temporary-httpx2-product-adopter.yml @@ -0,0 +1,244 @@ +name: Temporary httpx2 product adopter + +on: + push: + branches: + - codex/starlette-testclient-dependency + +permissions: + actions: read + contents: write + +jobs: + adopt: + if: github.repository == 'ContextualWisdomLab/naruon' + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout exact branch head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: true + + - name: Recover and verify frozen resolver artifact + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_ID: "10748790356" + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + if grep -Fxq 'httpx2==2.13.0' backend/requirements.txt; then + echo 'httpx2 2.13.0 already adopted; nothing to mutate.' + touch /tmp/already-adopted + exit 0 + fi + grep -Fx 'httpx2==2.5.0' backend/requirements.txt + curl --fail --silent --show-error --location \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" \ + --output /tmp/httpx2-resolver.zip + rm -rf /tmp/httpx2-resolver + mkdir -p /tmp/httpx2-resolver + unzip -q /tmp/httpx2-resolver.zip -d /tmp/httpx2-resolver + cat > /tmp/httpx2-expected.sha256 <<'EOF' + 87f603d06eb05fa234163003a2feda98f024b80751cc9eb919aedb261136482f backend/requirements.txt + faab5edd236153c28a321e431dc1a6d5f99bcaa64f26f21c60222a1db2057365 backend/pyproject.toml + 551f6aa4a6a8f1efb7f6259dc63777c40c09b2f520dea217575b94b12178f7d5 backend/requirements-hashes.txt + 761ceb9f7042ffa9538c5a596ad113a3ee59f27381c4a44d34df82338e5b913a backend/requirements-agent.txt + fa28138f637a2c2baddd528893cfb04be2d9064afcadd538fb7fa5000be7f82b backend/uv.lock + EOF + sed -i 's/^ //' /tmp/httpx2-expected.sha256 + ( + cd /tmp/httpx2-resolver + sha256sum -c /tmp/httpx2-expected.sha256 + grep -Fx 'candidate=httpx2==2.13.0' .resolver-evidence/upstream.txt + grep -Fx 'expected_httpcore2=httpcore2==2.13.0' .resolver-evidence/upstream.txt + grep -Fx 'optional_agent_consumer=genai-prices==0.0.71' .resolver-evidence/upstream.txt + grep -Fx 'optional_agent_requirement=httpx2>=2.0' .resolver-evidence/upstream.txt + ) + + - name: Adopt verified product graph + if: hashFiles('/tmp/already-adopted') == '' + shell: bash + run: | + set -euo pipefail + cp /tmp/httpx2-resolver/backend/requirements.txt backend/requirements.txt + cp /tmp/httpx2-resolver/backend/pyproject.toml backend/pyproject.toml + cp /tmp/httpx2-resolver/backend/requirements-hashes.txt backend/requirements-hashes.txt + cp /tmp/httpx2-resolver/backend/requirements-agent.txt backend/requirements-agent.txt + cp /tmp/httpx2-resolver/backend/uv.lock backend/uv.lock + + python - <<'PY' + from pathlib import Path + + test_path = Path('backend/tests/test_container_dependency_pin_contract.py') + test_text = test_path.read_text() + old_records = ''' backend_records = hashed_requirement_records(\n read_repo_text("backend/requirements-hashes.txt")\n )\n strix_pins = exact_requirement_pins(read_repo_text("requirements-strix-ci.txt"))\n''' + new_records = ''' backend_records = hashed_requirement_records(\n read_repo_text("backend/requirements-hashes.txt")\n )\n agent_records = hashed_requirement_records(\n read_repo_text("backend/requirements-agent.txt")\n )\n strix_pins = exact_requirement_pins(read_repo_text("requirements-strix-ci.txt"))\n''' + if test_text.count(old_records) != 1: + raise SystemExit('unexpected backend lock-record test structure') + test_text = test_text.replace(old_records, new_records) + test_text = test_text.replace( + 'assert backend_pins["httpx2"] == "2.5.0"', + 'assert backend_pins["httpx2"] == "2.13.0"', + ) + test_text = test_text.replace('"httpx2==2.5.0"', '"httpx2==2.13.0"') + old_anchor = ''' assert "protobuf==7.35.1" in backend_records\n assert all(\n''' + new_anchor = ''' assert "protobuf==7.35.1" in backend_records\n assert "httpcore2==2.13.0" in backend_records\n assert agent_records["httpx2==2.13.0"] == backend_records["httpx2==2.13.0"]\n assert agent_records["httpcore2==2.13.0"] == backend_records["httpcore2==2.13.0"]\n assert all(\n''' + if test_text.count(old_anchor) != 1: + raise SystemExit('unexpected backend pin assertion structure') + test_text = test_text.replace(old_anchor, new_anchor) + tuple_anchor = ''' "httpx2==2.13.0",\n "protobuf==7.35.1",\n''' + tuple_replacement = ''' "httpx2==2.13.0",\n "httpcore2==2.13.0",\n "protobuf==7.35.1",\n''' + if test_text.count(tuple_anchor) != 1: + raise SystemExit('unexpected backend digest tuple structure') + test_path.write_text(test_text.replace(tuple_anchor, tuple_replacement)) + + changelog_path = Path('CHANGELOG.md') + changelog = changelog_path.read_text() + old_line = '- Starlette `TestClient`의 기존 `httpx2==2.5.0` pin을 core 개발·테스트 의존성으로 승격하고, deprecated `httpx` fallback 경고 억제를 제거했습니다.' + new_line = '- Starlette `TestClient`의 preferred transport를 `httpx2==2.13.0` / `httpcore2==2.13.0`으로 고정하고 core와 Noema-agent hash lock을 동일한 의존성 그래프로 동기화했습니다. deprecated `httpx` fallback 경고 억제도 제거해 transport 회귀가 warnings-as-errors 테스트에서 드러나도록 했습니다.' + if changelog.count(old_line) != 1: + raise SystemExit('unexpected CHANGELOG TestClient entry') + changelog_path.write_text(changelog.replace(old_line, new_line)) + PY + + cat > docs/doctoring/starlette-httpx2-testclient-dependency.md <<'EOF' + # Starlette TestClient `httpx2` dependency + + ## Observed failure + + Protected `develop@042b0c70531b229af3acbd0421a2f23098d848b3` pins Starlette + 1.3.1 but did not install Starlette's preferred `httpx2` TestClient transport. + Importing `starlette.testclient` therefore fell back to deprecated `httpx`, and + warnings-as-errors runs stopped during collection. Removing the warning filter + without installing the preferred transport would expose the defect without + repairing it. + + ## Decision and dependency boundary + + Pin `httpx2==2.13.0` and resolved `httpcore2==2.13.0` in the repository's + core development/test graph. The Noema-agent lock participates in the same + `pip --require-hashes` transaction as the core lock, so it carries the exact + same `httpx2`/`httpcore2` records and hash sets instead of retaining the old + 2.5.0 graph. `genai-prices==0.0.71` requires `httpx2>=2.0`, so this coherent + pin stays within its declared consumer contract. + + The generated `uv.lock` also records the 2.13.0 platform split: `httpcore2` + on non-Emscripten platforms and `httpx2-jsfetch==1.0` on Emscripten. Naruon's + application HTTP clients remain on their existing `httpx` path; this change is + the Starlette TestClient transport boundary, not an application-client rewrite. + + The accepted five-file candidate was generated read-only with Python 3.14 and + `uv==0.10.0`, then checked as one core+agent hash-locked dry-run. Artifact + `10748790356` from Application CI `35844541085` has archive digest + `sha256:5355320323967e3cd58bdd8f05d1914c63d68790f28bf28b47bfba7fe019942b`. + Its independently verified file digests are: + + - `requirements.txt`: `87f603d06eb05fa234163003a2feda98f024b80751cc9eb919aedb261136482f`; + - `pyproject.toml`: `faab5edd236153c28a321e431dc1a6d5f99bcaa64f26f21c60222a1db2057365`; + - `requirements-hashes.txt`: `551f6aa4a6a8f1efb7f6259dc63777c40c09b2f520dea217575b94b12178f7d5`; + - `requirements-agent.txt`: `761ceb9f7042ffa9538c5a596ad113a3ee59f27381c4a44d34df82338e5b913a`; + - `uv.lock`: `fa28138f637a2c2baddd528893cfb04be2d9064afcadd538fb7fa5000be7f82b`. + + These digests establish the adopted bytes; they do not transfer merge or + release authority. Temporary adoption machinery is removed after the product + commit is verified so the normal exact-head gates evaluate only product source. + + ## Verification and rollback + + Run from the repository root: + + ```bash + cd backend && uv lock --check + python -m pip install --disable-pip-version-check --require-hashes \ + -r backend/requirements-hashes.txt -r backend/requirements-agent.txt + cd backend && python -m pytest -q -W error tests/test_container_dependency_pin_contract.py + cd backend && python -m ruff check tests/test_container_dependency_pin_contract.py + ``` + + The structural regression verifies the direct 2.13.0 pin, the core hash-lock + records, exact core/agent hash equality for `httpx2` and `httpcore2`, and the + runtime module selected by `starlette.testclient`. Full acceptance still + requires the normal helper-free current-head Application CI, Security, CodeQL, + required workflows and qualifying independent review. + + Rollback removes the direct pin, both coherent lock records, runtime assertion, + and obsolete-warning-filter removal together. Do not restore only the warning + suppression or leave the core and optional-agent locks on different graphs. + + ## References + + Kludex. (2026). *Starlette release notes*. GitHub. + https://github.com/Kludex/starlette/blob/main/docs/release-notes.md + + Python Packaging Authority. (2026). *httpx2 2.13.0 file details and provenance*. + PyPI. https://pypi.org/project/httpx2/2.13.0/ + + Pydantic. (2026). *HTTPX2 v2.13.0* [Source code]. GitHub. + https://github.com/pydantic/httpx2/tree/v2.13.0 + EOF + sed -i 's/^ //' docs/doctoring/starlette-httpx2-testclient-dependency.md + + git diff --check + ! grep -R -n -E 'httpx2==2\.5\.0|httpcore2==2\.5\.0' \ + backend/requirements.txt backend/pyproject.toml backend/requirements-hashes.txt \ + backend/requirements-agent.txt backend/uv.lock + + - name: Verify coherent candidate + if: hashFiles('/tmp/already-adopted') == '' + shell: bash + run: | + set -euo pipefail + python -m pip install --disable-pip-version-check 'uv==0.10.0' + (cd backend && uv lock --check) + python -m pip install --disable-pip-version-check --require-hashes \ + -r backend/requirements-hashes.txt \ + -r backend/requirements-agent.txt + (cd backend && python -m pytest -q -W error tests/test_container_dependency_pin_contract.py) + (cd backend && python -m ruff check tests/test_container_dependency_pin_contract.py) + + - name: Commit and push product delta non-force + if: hashFiles('/tmp/already-adopted') == '' + shell: bash + run: | + set -euo pipefail + branch="${GITHUB_REF_NAME}" + git fetch --no-tags origin "refs/heads/${branch}:refs/remotes/origin/${branch}" + test "$(git rev-parse HEAD)" = "$(git rev-parse "refs/remotes/origin/${branch}")" + git add -- \ + CHANGELOG.md \ + backend/pyproject.toml \ + backend/requirements-agent.txt \ + backend/requirements-hashes.txt \ + backend/requirements.txt \ + backend/tests/test_container_dependency_pin_contract.py \ + backend/uv.lock \ + docs/doctoring/starlette-httpx2-testclient-dependency.md + git diff --cached --check + git diff --cached --name-only | sort > /tmp/actual-paths + cat > /tmp/expected-paths <<'EOF' + CHANGELOG.md + backend/pyproject.toml + backend/requirements-agent.txt + backend/requirements-hashes.txt + backend/requirements.txt + backend/tests/test_container_dependency_pin_contract.py + backend/uv.lock + docs/doctoring/starlette-httpx2-testclient-dependency.md + EOF + sed -i 's/^ //' /tmp/expected-paths + sort -o /tmp/expected-paths /tmp/expected-paths + diff -u /tmp/expected-paths /tmp/actual-paths + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m 'fix(test): adopt coherent httpx2 2.13.0 dependency graph' + git push --verbose origin "HEAD:refs/heads/${branch}" From 2a87cb99205d29c04db188d4ddc0a44795bb1553 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 21:48:18 +0900 Subject: [PATCH 27/28] fix(test): parse hash-pinned requirements with extras --- .../test_container_dependency_pin_contract.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_container_dependency_pin_contract.py b/backend/tests/test_container_dependency_pin_contract.py index 0141866cb..7a2936134 100644 --- a/backend/tests/test_container_dependency_pin_contract.py +++ b/backend/tests/test_container_dependency_pin_contract.py @@ -18,7 +18,9 @@ REPO_ROOT = Path(__file__).resolve().parents[2] _HASH_PATTERN = re.compile(r"--hash=sha256:([0-9a-f]{64})") -_EXACT_PIN_PATTERN = re.compile(r"^([A-Za-z0-9_.-]+)==([^\\\s]+)") +_EXACT_PIN_PATTERN = re.compile( + r"^([A-Za-z0-9_.-]+)(?:\[[A-Za-z0-9_.-]+(?:,[A-Za-z0-9_.-]+)*\])?==([^\\\s]+)" +) def read_repo_text(relative_path: str) -> str: @@ -83,6 +85,17 @@ def importer_resolution(importer_section: dict[str, object], group: str, name: s return resolution +def test_hash_lock_parser_accepts_pinned_requirements_with_extras() -> None: + """Keep PEP 508 extras attached to a package without orphaning its hashes.""" + digest = "a" * 64 + lock_text = f"package[extra]==1.2.3 \\\n --hash=sha256:{digest}\n" + + assert exact_requirement_pins(lock_text) == {"package": "1.2.3"} + assert hashed_requirement_records(lock_text) == { + "package==1.2.3": frozenset({digest}) + } + + def test_container_provenance_dependency_pins_match_reviewed_manifests() -> None: """Keep backend, Strix, and frontend dependency floors reviewable together.""" backend_pins = exact_requirement_pins(read_repo_text("backend/requirements.txt")) From 42e7bc510027ee99bd9b0871835bf8b55a921055 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:31:00 +0000 Subject: [PATCH 28/28] fix(test): adopt coherent httpx2 2.13.0 dependency graph --- CHANGELOG.md | 2 +- backend/pyproject.toml | 2 +- backend/requirements-agent.txt | 10 ++- backend/requirements-hashes.txt | 12 +-- backend/requirements.txt | 2 +- .../test_container_dependency_pin_contract.py | 13 ++- backend/uv.lock | 30 ++++--- .../starlette-httpx2-testclient-dependency.md | 81 ++++++++++++------- 8 files changed, 98 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0f3cf8c3..50967506e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ ## [Unreleased] -- Starlette `TestClient`의 기존 `httpx2==2.5.0` pin을 core 개발·테스트 의존성으로 승격하고, deprecated `httpx` fallback 경고 억제를 제거했습니다. +- Starlette `TestClient`의 preferred transport를 `httpx2==2.13.0` / `httpcore2==2.13.0`으로 고정하고 core와 Noema-agent hash lock을 동일한 의존성 그래프로 동기화했습니다. deprecated `httpx` fallback 경고 억제도 제거해 transport 회귀가 warnings-as-errors 테스트에서 드러나도록 했습니다. - 긴 이메일·첨부 본문을 의미 단위 청크로 임베딩한 뒤 기존 email/attachment 벡터 계약으로 평균화하고, 청크 요청·벡터 누적을 제한된 창으로 처리합니다. OpenAI `text-embedding-3-*`에는 저장 차원(`1536`)을 직접 요청하도록 보강했습니다. 합성 메일 fixture 5건(70청크)과 provider 요청 계약으로 1,536차원 벡터 경로를 검증했으며, 실행 시 선택한 임베딩 제공자에 본문·파싱된 첨부 텍스트를 전송할 수 있습니다. 회사 기밀 데이터는 fixture·commit·PR·log에 포함하지 않습니다. - EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다. diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 046829400..4c02e27a3 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ [dependency-groups] dev = [ "coverage==7.15.1", - "httpx2==2.5.0", + "httpx2==2.13.0", "pytest==9.1.1", "pytest-asyncio==1.4.0", "ruff==0.15.21", diff --git a/backend/requirements-agent.txt b/backend/requirements-agent.txt index fa5e75d6b..ff8599569 100644 --- a/backend/requirements-agent.txt +++ b/backend/requirements-agent.txt @@ -24,11 +24,13 @@ genai-prices==0.0.71 \ griffelib==2.1.0 \ --hash=sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00 # via pydantic-ai-slim -httpcore2==2.5.0 \ - --hash=sha256:5ce35188de461d31e8d000bfb8ef8bf22c6c16587a211e5571deaa5e9bdf842a +httpcore2==2.13.0 \ + --hash=sha256:2adc8be4fb285fbcd6d894298db3b52c177e74b6674eda3a76bd36be3292a3db \ + --hash=sha256:35ae5be347aa40467b4a5dc032ac67ebb6d27189fc97e8cebcf99616f6a1bb9e # via httpx2 -httpx2==2.5.0 \ - --hash=sha256:3d2d4d9cf4b61f1a1f46a95947cfdb47e80cb56a2f91c6256ac8f58e4891df41 +httpx2==2.13.0 \ + --hash=sha256:81bd07dc67a3701729ef1f777a3c00c915d4539604fdb5afd327f8682f6b7b44 \ + --hash=sha256:fc12720cedf72faa26cca6b4ca394e05c894e7d7933fc45cafe767960804e49a # via genai-prices logfire-api==4.37.0 \ --hash=sha256:1d756f8ba23aa56d438e0ba2c0f529a00fcac975b8785c561b058267f9465088 diff --git a/backend/requirements-hashes.txt b/backend/requirements-hashes.txt index cd0627bc6..17ea49bbd 100644 --- a/backend/requirements-hashes.txt +++ b/backend/requirements-hashes.txt @@ -552,9 +552,9 @@ httpcore==1.0.9 \ --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 # via httpx -httpcore2==2.5.0 \ - --hash=sha256:5ce35188de461d31e8d000bfb8ef8bf22c6c16587a211e5571deaa5e9bdf842a \ - --hash=sha256:88aa170137c17328d5ac44234f9fd10706466d5fb347f3edac4d39b91137b09d +httpcore2==2.13.0 \ + --hash=sha256:2adc8be4fb285fbcd6d894298db3b52c177e74b6674eda3a76bd36be3292a3db \ + --hash=sha256:35ae5be347aa40467b4a5dc032ac67ebb6d27189fc97e8cebcf99616f6a1bb9e # via httpx2 httplib2==0.32.0 \ --hash=sha256:48a0ef30a42db65d8f3399045e1d09ab0ba66e3b9efc360d07f80ea55d286025 \ @@ -569,9 +569,9 @@ httpx==0.28.1 \ # -r backend/requirements.txt # langsmith # openai -httpx2==2.5.0 \ - --hash=sha256:3d2d4d9cf4b61f1a1f46a95947cfdb47e80cb56a2f91c6256ac8f58e4891df41 \ - --hash=sha256:e2df9cb4611021527ff8a675b1c320b610a2ec397acc8d6fe6e91df2d9b33c29 +httpx2==2.13.0 \ + --hash=sha256:81bd07dc67a3701729ef1f777a3c00c915d4539604fdb5afd327f8682f6b7b44 \ + --hash=sha256:fc12720cedf72faa26cca6b4ca394e05c894e7d7933fc45cafe767960804e49a # via -r backend/requirements.txt icalendar==7.2.0 \ --hash=sha256:32dacc396101825b82f9f1bbdf691c02be613130d5ab7a457e553fcd20959fdd \ diff --git a/backend/requirements.txt b/backend/requirements.txt index 1929ec735..839c08fd0 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,7 +3,7 @@ starlette==1.3.1 uvicorn==0.51.0 pytest==9.1.1 httpx==0.28.1 -httpx2==2.5.0 +httpx2==2.13.0 pydantic-settings==2.14.2 aiosmtplib==5.1.2 aioimaplib==2.0.1 diff --git a/backend/tests/test_container_dependency_pin_contract.py b/backend/tests/test_container_dependency_pin_contract.py index 7a2936134..238709d15 100644 --- a/backend/tests/test_container_dependency_pin_contract.py +++ b/backend/tests/test_container_dependency_pin_contract.py @@ -102,6 +102,9 @@ def test_container_provenance_dependency_pins_match_reviewed_manifests() -> None backend_records = hashed_requirement_records( read_repo_text("backend/requirements-hashes.txt") ) + agent_records = hashed_requirement_records( + read_repo_text("backend/requirements-agent.txt") + ) strix_pins = exact_requirement_pins(read_repo_text("requirements-strix-ci.txt")) strix_records = hashed_requirement_records( read_repo_text("requirements-strix-ci-hashes.txt") @@ -110,16 +113,20 @@ def test_container_provenance_dependency_pins_match_reviewed_manifests() -> None frontend_lock = yaml.safe_load(read_repo_text("frontend/pnpm-lock.yaml")) assert backend_pins["cryptography"] == "50.0.0" - assert backend_pins["httpx2"] == "2.5.0" + assert backend_pins["httpx2"] == "2.13.0" assert backend_pins["protobuf"] == "7.35.1" assert "cryptography==50.0.0" in backend_records - assert "httpx2==2.5.0" in backend_records + assert "httpx2==2.13.0" in backend_records assert "protobuf==7.35.1" in backend_records + assert "httpcore2==2.13.0" in backend_records + assert agent_records["httpx2==2.13.0"] == backend_records["httpx2==2.13.0"] + assert agent_records["httpcore2==2.13.0"] == backend_records["httpcore2==2.13.0"] assert all( re.fullmatch(r"[0-9a-f]{64}", digest) for pin in ( "cryptography==50.0.0", - "httpx2==2.5.0", + "httpx2==2.13.0", + "httpcore2==2.13.0", "protobuf==7.35.1", ) for digest in backend_records[pin] diff --git a/backend/uv.lock b/backend/uv.lock index cad60793a..98d8c9aa0 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -685,15 +685,15 @@ wheels = [ [[package]] name = "httpcore2" -version = "2.5.0" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h11" }, { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/06/5c12df521b5322fb1114a83d46911b2fbcb8855ddb3a635f11c01a214af5/httpcore2-2.5.0.tar.gz", hash = "sha256:88aa170137c17328d5ac44234f9fd10706466d5fb347f3edac4d39b91137b09d", size = 64808, upload-time = "2026-06-25T14:16:56.472Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/8c/e925b1c92018abb3a1863ce1549d76d2381e334d21d65d4ac8f65dabd78a/httpcore2-2.13.0.tar.gz", hash = "sha256:2adc8be4fb285fbcd6d894298db3b52c177e74b6674eda3a76bd36be3292a3db", size = 67740, upload-time = "2026-09-14T14:18:04.717Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/a1/7564199d1a8728fe737b0a72e5b3f8d92dfe085a74ddf7cdd83bce5f206d/httpcore2-2.5.0-py3-none-any.whl", hash = "sha256:5ce35188de461d31e8d000bfb8ef8bf22c6c16587a211e5571deaa5e9bdf842a", size = 80330, upload-time = "2026-06-25T14:16:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0d/117a771a2bb91df334b66bf4da14cd02f21aefbcfe53180f336ce55e8f90/httpcore2-2.13.0-py3-none-any.whl", hash = "sha256:35ae5be347aa40467b4a5dc032ac67ebb6d27189fc97e8cebcf99616f6a1bb9e", size = 83162, upload-time = "2026-09-14T14:18:02.529Z" }, ] [[package]] @@ -725,18 +725,28 @@ wheels = [ [[package]] name = "httpx2" -version = "2.5.0" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "httpcore2" }, + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, { name = "idna" }, - { name = "truststore" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d0/e2/b5dedc0cf35aa65de5f541ccd30d2bc1fd7f1d43c9ab09f8ed9a7342317b/httpx2-2.5.0.tar.gz", hash = "sha256:e2df9cb4611021527ff8a675b1c320b610a2ec397acc8d6fe6e91df2d9b33c29", size = 83121, upload-time = "2026-06-25T14:16:57.491Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/a0/e9deef4654132857b5a5dbe4eddd0ac59c2814500e11f2f5044cd81103ee/httpx2-2.13.0.tar.gz", hash = "sha256:81bd07dc67a3701729ef1f777a3c00c915d4539604fdb5afd327f8682f6b7b44", size = 100290, upload-time = "2026-09-14T14:18:05.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/d1/a0c72b0e006df654709fbc366cc5bcb53e5aee13e1e3395152c6dd293376/httpx2-2.13.0-py3-none-any.whl", hash = "sha256:fc12720cedf72faa26cca6b4ca394e05c894e7d7933fc45cafe767960804e49a", size = 95565, upload-time = "2026-09-14T14:18:03.553Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/22/859d8252dad9bc9adee34b52e62cde621ece07b042ccb2ab4da1be46695f/httpx2-2.5.0-py3-none-any.whl", hash = "sha256:3d2d4d9cf4b61f1a1f46a95947cfdb47e80cb56a2f91c6256ac8f58e4891df41", size = 76652, upload-time = "2026-06-25T14:16:55.23Z" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -1095,7 +1105,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "coverage", specifier = "==7.15.1" }, - { name = "httpx2", specifier = "==2.5.0" }, + { name = "httpx2", specifier = "==2.13.0" }, { name = "pytest", specifier = "==9.1.1" }, { name = "pytest-asyncio", specifier = "==1.4.0" }, { name = "ruff", specifier = "==0.15.21" }, diff --git a/docs/doctoring/starlette-httpx2-testclient-dependency.md b/docs/doctoring/starlette-httpx2-testclient-dependency.md index c66b73990..1f5bf960e 100644 --- a/docs/doctoring/starlette-httpx2-testclient-dependency.md +++ b/docs/doctoring/starlette-httpx2-testclient-dependency.md @@ -3,46 +3,71 @@ ## Observed failure Protected `develop@042b0c70531b229af3acbd0421a2f23098d848b3` pins Starlette -1.3.1 but did not install `httpx2`. Importing `starlette.testclient` therefore -fell back to deprecated `httpx`; warning-as-error test runs stopped during -collection. Removing the warning filter without installing the preferred -transport would expose the defect without repairing it. - -## Decision and boundary - -Pin `httpx2==2.5.0` in the repository's existing combined backend -development/direct-test manifests and immutable locks. Keep application HTTP -clients on their existing `httpx` path. A runtime regression test imports -Starlette's TestClient module and verifies that its selected transport module is -`httpx2`; manifest and digest checks alone are insufficient evidence. - -Starlette 1.2.0 introduced TestClient support for `httpx2`, and 1.3.0 added it -to the `full` extra. The 2.5.0 wheel in this change matches PyPI's published -SHA-256 digest `3d2d4d9cf4b61f1a1f46a95947cfdb47e80cb56a2f91c6256ac8f58e4891df41`. -PyPI records a trusted-publishing attestation from the `pydantic/httpx2` -repository at tag `v2.5.0`. These facts establish origin and integrity; they do -not transfer current-head CI or protected-merge authority. +1.3.1 but did not install Starlette's preferred `httpx2` TestClient transport. +Importing `starlette.testclient` therefore fell back to deprecated `httpx`, and +warnings-as-errors runs stopped during collection. Removing the warning filter +without installing the preferred transport would expose the defect without +repairing it. + +## Decision and dependency boundary + +Pin `httpx2==2.13.0` and resolved `httpcore2==2.13.0` in the repository's +core development/test graph. The Noema-agent lock participates in the same +`pip --require-hashes` transaction as the core lock, so it carries the exact +same `httpx2`/`httpcore2` records and hash sets instead of retaining the old +2.5.0 graph. `genai-prices==0.0.71` requires `httpx2>=2.0`, so this coherent +pin stays within its declared consumer contract. + +The generated `uv.lock` also records the 2.13.0 platform split: `httpcore2` +on non-Emscripten platforms and `httpx2-jsfetch==1.0` on Emscripten. Naruon's +application HTTP clients remain on their existing `httpx` path; this change is +the Starlette TestClient transport boundary, not an application-client rewrite. + +The accepted five-file candidate was generated read-only with Python 3.14 and +`uv==0.10.0`, then checked as one core+agent hash-locked dry-run. Artifact +`10748790356` from Application CI `35844541085` has archive digest +`sha256:5355320323967e3cd58bdd8f05d1914c63d68790f28bf28b47bfba7fe019942b`. +Its independently verified file digests are: + +- `requirements.txt`: `87f603d06eb05fa234163003a2feda98f024b80751cc9eb919aedb261136482f`; +- `pyproject.toml`: `faab5edd236153c28a321e431dc1a6d5f99bcaa64f26f21c60222a1db2057365`; +- `requirements-hashes.txt`: `551f6aa4a6a8f1efb7f6259dc63777c40c09b2f520dea217575b94b12178f7d5`; +- `requirements-agent.txt`: `761ceb9f7042ffa9538c5a596ad113a3ee59f27381c4a44d34df82338e5b913a`; +- `uv.lock`: `fa28138f637a2c2baddd528893cfb04be2d9064afcadd538fb7fa5000be7f82b`. + +These digests establish the adopted bytes; they do not transfer merge or +release authority. Temporary adoption machinery is removed after the product +commit is verified so the normal exact-head gates evaluate only product source. ## Verification and rollback -Run from `backend/`: +Run from the repository root: ```bash -uv run --frozen pytest -q -W error tests/test_container_dependency_pin_contract.py -uv run --frozen ruff check tests/test_container_dependency_pin_contract.py +cd backend && uv lock --check +python -m pip install --disable-pip-version-check --require-hashes \ + -r backend/requirements-hashes.txt -r backend/requirements-agent.txt +cd backend && python -m pytest -q -W error tests/test_container_dependency_pin_contract.py +cd backend && python -m ruff check tests/test_container_dependency_pin_contract.py ``` -Rollback removes the direct pin, regenerated lock records, runtime assertion, +The structural regression verifies the direct 2.13.0 pin, the core hash-lock +records, exact core/agent hash equality for `httpx2` and `httpcore2`, and the +runtime module selected by `starlette.testclient`. Full acceptance still +requires the normal helper-free current-head Application CI, Security, CodeQL, +required workflows and qualifying independent review. + +Rollback removes the direct pin, both coherent lock records, runtime assertion, and obsolete-warning-filter removal together. Do not restore only the warning -suppression. +suppression or leave the core and optional-agent locks on different graphs. ## References Kludex. (2026). *Starlette release notes*. GitHub. https://github.com/Kludex/starlette/blob/main/docs/release-notes.md -Python Packaging Authority. (2026). *httpx2 2.5.0 file details and provenance*. -PyPI. https://pypi.org/project/httpx2/2.5.0/ +Python Packaging Authority. (2026). *httpx2 2.13.0 file details and provenance*. +PyPI. https://pypi.org/project/httpx2/2.13.0/ -Pydantic. (2026). *HTTPX2 v2.5.0* [Source code]. GitHub. -https://github.com/pydantic/httpx2/tree/v2.5.0 +Pydantic. (2026). *HTTPX2 v2.13.0* [Source code]. GitHub. +https://github.com/pydantic/httpx2/tree/v2.13.0