diff --git a/.github/workflows/repair-pr-224.yml b/.github/workflows/repair-pr-224.yml new file mode 100644 index 0000000..3e60627 --- /dev/null +++ b/.github/workflows/repair-pr-224.yml @@ -0,0 +1,261 @@ +name: Repair PR 224 multi-hash lock evidence + +on: + push: + branches: + - dependabot/pip/ruff-0.16.3 + paths: + - .github/workflows/repair-pr-224.yml + +permissions: + contents: write + +concurrency: + group: repair-pr-224-${{ github.ref }} + cancel-in-progress: false + +jobs: + repair: + if: >- + github.repository == 'ContextualWisdomLab/EgressWeave' && + github.ref == 'refs/heads/dependabot/pip/ruff-0.16.3' + runs-on: ubuntu-24.04 + timeout-minutes: 35 + env: + EXPECTED_PARENT_SHA: 006161e9530ae081623f81b6cf4d32bce6a55bcc + TARGET_BRANCH: dependabot/pip/ruff-0.16.3 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 2 + persist-credentials: false + - name: Verify exact repair boundary + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT_SHA" + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + test "$(git branch --show-current)" = "$TARGET_BRANCH" + - name: Apply minimal multi-hash parser correction and regressions + shell: bash + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + from textwrap import dedent + + script_path = Path("scripts/ci/generate_release_sbom.py") + script = script_path.read_text(encoding="utf-8") + + replacements = [ + ( + "def _load_runtime_lock(path: Path) -> dict[str, dict[str, str | None]]:\n", + "def _load_runtime_lock(\n" + " path: Path,\n" + ") -> dict[str, dict[str, str | tuple[str, ...] | None]]:\n", + ), + ( + ' """Load exact package versions, markers, and hashes from the CI lock."""\n', + ' """Load exact package versions, markers, and allowed hashes from the CI lock."""\n', + ), + ( + " entries: dict[str, dict[str, str | None]] = {}\n", + " entries: dict[\n" + " str, dict[str, str | tuple[str, ...] | None]\n" + " ] = {}\n", + ), + ( + ''' if line.count("--hash=sha256:") != 1: + raise SystemExit("runtime lock entries require exactly one SHA-256 hash") + requirement_text, digest = line.split("--hash=sha256:", 1) + digest = digest.strip() + if SHA256.fullmatch(digest) is None: + raise SystemExit("runtime lock contains a noncanonical SHA-256 hash") + ''', + ''' segments = line.split("--hash=sha256:") + if len(segments) < 2: + raise SystemExit( + "runtime lock entries require at least one SHA-256 hash" + ) + requirement_text = segments[0] + digests = tuple(segment.strip() for segment in segments[1:]) + if any(SHA256.fullmatch(digest) is None for digest in digests): + raise SystemExit("runtime lock contains a noncanonical SHA-256 hash") + if len(set(digests)) != len(digests): + raise SystemExit("runtime lock contains a duplicate SHA-256 hash") + ''', + ), + (' "sha256": digest,\n', ' "sha256": digests,\n'), + ( + ''' for name, component in components.items(): + locked = lock_entries.get(name) + expected = { + "version": component["version"], + "sha256": component["sha256"], + "marker": _canonical_marker(component["marker"], f"component {name}"), + } + if locked != expected: + raise SystemExit( + f"component {name!r} does not match the hash-locked runtime subset" + ) + ''', + ''' for name, component in components.items(): + locked = lock_entries.get(name) + expected_marker = _canonical_marker( + component["marker"], f"component {name}" + ) + if ( + locked is None + or locked["version"] != component["version"] + or locked["marker"] != expected_marker + or component["sha256"] not in locked["sha256"] + ): + raise SystemExit( + f"component {name!r} does not match the hash-locked runtime subset" + ) + ''', + ), + ] + for old, new in replacements: + if script.count(old) != 1: + raise SystemExit(f"runtime-lock repair boundary drifted: {old[:60]!r}") + script = script.replace(old, new, 1) + script_path.write_text(script, encoding="utf-8") + + Path("tests/test_release_lock_hash_sets.py").write_text( + dedent( + '''\ + """Regression contracts for portable multi-artifact hash locks.""" + + from __future__ import annotations + + import importlib.util + from pathlib import Path + + import pytest + + REPOSITORY_ROOT = Path(__file__).resolve().parents[1] + GENERATOR_PATH = REPOSITORY_ROOT / "scripts" / "ci" / "generate_release_sbom.py" + MANIFEST_PATH = REPOSITORY_ROOT / "scripts" / "ci" / "release_runtime_dependencies.json" + LOCK_PATH = REPOSITORY_ROOT / "requirements-ci.txt" + + + def _load_generator(): + """Load the repository SBOM generator without packaging it.""" + specification = importlib.util.spec_from_file_location( + "egressweave_release_lock_hash_sets", + GENERATOR_PATH, + ) + assert specification is not None and specification.loader is not None + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + return module + + + def test_runtime_lock_accepts_distinct_platform_hashes(tmp_path: Path) -> None: + """Preserve every exact artifact hash attached to one pinned version.""" + generator = _load_generator() + first_digest = "a" * 64 + second_digest = "b" * 64 + lock_path = tmp_path / "requirements-ci.txt" + lock_path.write_text( + "tool==1.2.3 " + f"--hash=sha256:{first_digest} " + f"--hash=sha256:{second_digest}\\n", + encoding="utf-8", + ) + + assert generator._load_runtime_lock(lock_path) == { + "tool": { + "version": "1.2.3", + "sha256": (first_digest, second_digest), + "marker": None, + } + } + + + def test_runtime_lock_rejects_duplicate_artifact_hashes(tmp_path: Path) -> None: + """Reject ambiguous duplicate evidence within one package entry.""" + generator = _load_generator() + digest = "a" * 64 + lock_path = tmp_path / "requirements-ci.txt" + lock_path.write_text( + f"tool==1.2.3 --hash=sha256:{digest} --hash=sha256:{digest}\\n", + encoding="utf-8", + ) + + with pytest.raises(SystemExit, match="duplicate SHA-256"): + generator._load_runtime_lock(lock_path) + + + def test_runtime_manifest_accepts_its_digest_among_platform_hashes( + tmp_path: Path, + ) -> None: + """Bind runtime evidence to one reviewed digest in a portable hash set.""" + generator = _load_generator() + lock_text = LOCK_PATH.read_text(encoding="utf-8") + needle = ( + "anyio==4.14.2 \\\\n" + " --hash=sha256:" + "9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494" + ) + replacement = needle + " \\\\n --hash=sha256:" + ("f" * 64) + assert lock_text.count(needle) == 1 + portable_lock = tmp_path / "requirements-ci.txt" + portable_lock.write_text( + lock_text.replace(needle, replacement, 1), + encoding="utf-8", + ) + + generator.validate_runtime_lock(MANIFEST_PATH, portable_lock) + ''' + ), + encoding="utf-8", + ) + + changelog_path = Path("CHANGELOG.md") + changelog = changelog_path.read_text(encoding="utf-8") + anchor = "### Fixed\n" + addition = ( + "- Accept distinct exact SHA-256 artifact hashes for one pinned CI package while " + "requiring every reviewed runtime SBOM digest to be present in that immutable set. " + "This preserves portable hash-locked tooling without weakening release evidence.\n" + ) + if changelog.count(anchor) != 1 or addition in changelog: + raise SystemExit("changelog insertion boundary drifted") + changelog_path.write_text( + changelog.replace(anchor, anchor + addition, 1), + encoding="utf-8", + ) + PY + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.14' + cache: pip + cache-dependency-path: requirements-ci.txt + - name: Install immutable CI toolchain + run: python -m pip install --require-hashes -r requirements-ci.txt + - name: Verify focused and complete GREEN + shell: bash + run: | + set -euo pipefail + ruff check . + coverage erase + coverage run -m pytest -q + coverage report -m + python -m compileall -q src tests scripts + git diff --check + - name: Publish verified repair and remove temporary authority + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + rm .github/workflows/repair-pr-224.yml + git add -A + git config user.name "CWL One-shot Repair" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "fix(sbom): accept portable multi-hash lock entries" + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git push origin "HEAD:refs/heads/${TARGET_BRANCH}" diff --git a/requirements-ci.txt b/requirements-ci.txt index 267acda..fffc84b 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -34,8 +34,25 @@ pytest==9.1.1 \ --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c pytest-asyncio==1.4.0 \ --hash=sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1 -ruff==0.16.1 \ - --hash=sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c +ruff==0.16.3 \ + --hash=sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b \ + --hash=sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7 \ + --hash=sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb \ + --hash=sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413 \ + --hash=sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a \ + --hash=sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474 \ + --hash=sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82 \ + --hash=sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84 \ + --hash=sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da \ + --hash=sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d \ + --hash=sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870 \ + --hash=sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a \ + --hash=sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506 \ + --hash=sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9 \ + --hash=sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2 \ + --hash=sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948 \ + --hash=sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50 \ + --hash=sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081 tomli==2.4.1 ; python_version < "3.11" \ --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe typing-extensions==4.16.0 ; python_version < "3.13" \