diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5508299..f1ed07b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,6 +7,27 @@ updates: schedule: interval: "weekly" open-pull-requests-limit: 10 + groups: + pydantic-runtime: + applies-to: "version-updates" + patterns: + - "pydantic" + - "pydantic-core" + httpx2-runtime: + applies-to: "version-updates" + patterns: + - "httpx2" + - "httpcore2" + pydantic-runtime-security: + applies-to: "security-updates" + patterns: + - "pydantic" + - "pydantic-core" + httpx2-runtime-security: + applies-to: "security-updates" + patterns: + - "httpx2" + - "httpcore2" - package-ecosystem: "docker" directory: "/services/account_unification" schedule: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 655ff06..69a49a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,36 @@ jobs: prune-cache: true - name: Install locked dependencies run: uv sync --locked --extra dev + - name: Verify uv lock dependency metadata + run: uv pip check + - name: Verify exported hash lock + run: | + exported_requirements="${RUNNER_TEMP}/keyverse-requirements-dev.txt" + tracked_requirements_body="${RUNNER_TEMP}/keyverse-tracked-requirements-body.txt" + exported_requirements_body="${RUNNER_TEMP}/keyverse-exported-requirements-body.txt" + uv export \ + --locked \ + --format requirements.txt \ + --extra dev \ + --no-emit-project \ + --output-file "${exported_requirements}" + + # uv records the chosen output path in its two-line generated-file + # header. Compare the package graph and hashes while ignoring only + # that environment-specific provenance text. + tail -n +3 requirements-dev.txt > "${tracked_requirements_body}" + tail -n +3 "${exported_requirements}" > "${exported_requirements_body}" + cmp --silent "${tracked_requirements_body}" "${exported_requirements_body}" || { + diff --unified "${tracked_requirements_body}" "${exported_requirements_body}" + exit 1 + } + + export_lock_venv="${RUNNER_TEMP}/keyverse-export-lock" + uv venv "${export_lock_venv}" + VIRTUAL_ENV="${export_lock_venv}" uv pip install \ + --require-hashes \ + -r requirements-dev.txt + VIRTUAL_ENV="${export_lock_venv}" uv pip check - name: Lint run: uv run ruff check app tests tools - name: Docstrings diff --git a/CHANGELOG.md b/CHANGELOG.md index 4639f1a..e417773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,9 @@ Keep a Changelog, and releases use semantic versioning. - Added the product/technical gap baseline and its APA 7th doctoring record, including the current exact-head PR/Issue inventory and explicit `gap-not-claimed` runtime and release boundaries. +- Exact-coupled `pydantic`/`pydantic-core` and `httpx2`/`httpcore2` updates are + grouped into atomic Dependabot pull requests, while CI independently installs + and checks both `uv.lock` and the exported hash-locked requirements graph. - Relying-party deployment controllers now send validated, secret-free metadata to Keyverse desired-state PUT instead of applying client representations directly to Keycloak; confidential credential placement remains a separate diff --git a/docs/doctoring/dependabot-coupled-security-updates.md b/docs/doctoring/dependabot-coupled-security-updates.md new file mode 100644 index 0000000..b734438 --- /dev/null +++ b/docs/doctoring/dependabot-coupled-security-updates.md @@ -0,0 +1,38 @@ +# Coupled Dependabot security updates — doctoring record + +## Status + +Active pull-request evidence only. This record describes the dependency-update contract on the contributor branch and does not claim protected `main` ships it until normal protected integration completes. + +## Problem + +Keyverse deliberately keeps `pydantic` with `pydantic-core` and `httpx2` with `httpcore2` in atomic Dependabot version-update pull requests because each parent/child pair is resolved together in the reviewed lock graph. The existing `groups` entries used `applies-to: version-updates`, so a Dependabot security update could still raise one member of a coupled pair independently. The dual-lock CI gate would then fail closed, but the security-update path would create a preventable blocked pull request rather than proposing one coherent graph. + +## Decision + +Define a separately named security-update group for each exact-coupled pair while retaining the existing version-update group. GitHub documents that one group rule cannot apply to both version and security updates; matching criteria must be represented as separately named groups with `applies-to: security-updates` for the security path. + +The executable regression requires all four groups and their exact package sets: + +- `pydantic-runtime` — version updates for `pydantic` and `pydantic-core`; +- `httpx2-runtime` — version updates for `httpx2` and `httpcore2`; +- `pydantic-runtime-security` — security updates for `pydantic` and `pydantic-core`; and +- `httpx2-runtime-security` — security updates for `httpx2` and `httpcore2`. + +This changes Dependabot proposal grouping only. It does not weaken the existing `uv.lock` / exported hash-lock equivalence check, vulnerability scanning, review requirements, or protected-branch merge gates. + +## TDD traceability + +RED commit `f30c9b272af07e9592397d9e46d700493aeebe2b` expanded `test_exact_coupled_dependencies_update_atomically` to require the two security-update groups while the configuration still contained only the two version-update groups. GREEN commit `c593f8de3ca534bd58ee88d5f7b1bbbf15002d22` added the missing security-update groups without changing their package boundaries. + +Hosted exact-head CI remains the authoritative execution evidence. Queued, skipped, stale, predecessor-head, synthetic-only, author-only, or model-only evidence is not promoted to passing. + +## Rollback + +Rollback removes the two `*-security` group entries and this regression together. The operational consequence is not an unsafe merge: dual-lock CI remains fail-closed. Instead, Dependabot security updates for an exact-coupled parent/child pair may again arrive separately and become unnecessarily blocked. + +## References — APA 7th + +GitHub, Inc. (2026a). *Configuring Dependabot security updates*. GitHub Docs. Retrieved August 26, 2026, from https://docs.github.com/en/code-security/how-tos/secure-your-supply-chain/secure-your-dependencies/configure-security-updates + +GitHub, Inc. (2026b). *Dependabot errors*. GitHub Docs. Retrieved August 26, 2026, from https://docs.github.com/en/code-security/reference/supply-chain-security/troubleshoot-dependabot/dependabot-errors diff --git a/services/account_unification/requirements-dev.txt b/services/account_unification/requirements-dev.txt index e071985..4bb3135 100644 --- a/services/account_unification/requirements-dev.txt +++ b/services/account_unification/requirements-dev.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv export --format requirements.txt --extra dev --no-emit-project --output-file requirements-dev.txt +# uv export --locked --format requirements.txt --extra dev --no-emit-project --output-file requirements-dev.txt annotated-doc==0.0.5 \ --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \ --hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb diff --git a/services/account_unification/tests/test_dependency_update_policy.py b/services/account_unification/tests/test_dependency_update_policy.py new file mode 100644 index 0000000..b26063f --- /dev/null +++ b/services/account_unification/tests/test_dependency_update_policy.py @@ -0,0 +1,112 @@ +"""Contracts for atomic dependency updates and dual-lock verification.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + + +def _repository_root() -> Path: + """Return the Keyverse repository root from this test module.""" + return Path(__file__).resolve().parents[3] + + +def _dependabot_document() -> dict[str, object]: + """Parse and return the repository Dependabot configuration.""" + document = yaml.safe_load( + (_repository_root() / ".github" / "dependabot.yml").read_text( + encoding="utf-8" + ) + ) + assert isinstance(document, dict) + return document + + +def _pip_update() -> dict[str, object]: + """Return the account-unification pip update configuration.""" + updates = _dependabot_document().get("updates") + assert isinstance(updates, list) + + for update in updates: + if not isinstance(update, dict): + continue + if update.get("package-ecosystem") != "pip": + continue + if update.get("directory") != "/services/account_unification": + continue + return update + raise AssertionError("account-unification pip update configuration is missing") + + +def _ci_source() -> str: + """Return the repository CI workflow source.""" + return ( + _repository_root() / ".github" / "workflows" / "ci.yml" + ).read_text(encoding="utf-8") + + +def test_exact_coupled_dependencies_update_atomically() -> None: + """Packages with exact child pins stay atomic for version and security updates.""" + groups = _pip_update().get("groups") + assert isinstance(groups, dict) + + expected_groups = { + "pydantic-runtime": ("version-updates", {"pydantic", "pydantic-core"}), + "httpx2-runtime": ("version-updates", {"httpx2", "httpcore2"}), + "pydantic-runtime-security": ( + "security-updates", + {"pydantic", "pydantic-core"}, + ), + "httpx2-runtime-security": ( + "security-updates", + {"httpx2", "httpcore2"}, + ), + } + for group_name, (update_scope, expected_patterns) in expected_groups.items(): + group = groups.get(group_name) + assert isinstance(group, dict) + assert group.get("applies-to") == update_scope + patterns = group.get("patterns") + assert isinstance(patterns, list) + assert set(patterns) == expected_patterns + + +def test_ci_proves_both_lock_representations_are_equivalent() -> None: + """CI compares lock bodies before installing and checking both forms.""" + workflow = _ci_source() + + assert "uv sync --locked --extra dev" in workflow + assert "Verify uv lock dependency metadata" in workflow + assert "Verify exported hash lock" in workflow + assert workflow.count("uv pip check") == 2 + assert "uv export" in workflow + assert "--format requirements.txt" in workflow + assert "--extra dev" in workflow + assert "--no-emit-project" in workflow + assert '--output-file "${exported_requirements}"' in workflow + assert ( + 'tracked_requirements_body="${RUNNER_TEMP}/keyverse-tracked-requirements-body.txt"' + in workflow + ) + assert ( + 'exported_requirements_body="${RUNNER_TEMP}/keyverse-exported-requirements-body.txt"' + in workflow + ) + assert 'tail -n +3 requirements-dev.txt > "${tracked_requirements_body}"' in workflow + assert ( + 'tail -n +3 "${exported_requirements}" > "${exported_requirements_body}"' + in workflow + ) + assert ( + 'cmp --silent "${tracked_requirements_body}" "${exported_requirements_body}"' + in workflow + ) + assert ( + 'diff --unified "${tracked_requirements_body}" "${exported_requirements_body}"' + in workflow + ) + assert "--require-hashes" in workflow + assert "-r requirements-dev.txt" in workflow + assert 'VIRTUAL_ENV="${export_lock_venv}" uv pip install' in workflow + assert 'VIRTUAL_ENV="${export_lock_venv}" uv pip check' in workflow