diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c329a6d..78d0475 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -17,5 +17,6 @@ - [ ] The full test suite passes locally or the CI result is linked. - [ ] Documentation or examples were updated when user-facing behavior changed. - [ ] No secrets, credentials, generated provider files, or unrelated changes are included. +- [ ] Any CI action or standalone-tool pin change also updates `ci/digests.txt`. Legacy cleanup PRs may receive missing tests as a one-time maintainer courtesy when the change is already otherwise clean. New changes are expected to include relevant tests and keep the full suite passing. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f27f9aa..10ee74c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: requirements*.txt - name: Install build tooling - run: python -m pip install "build==1.2.2.post1" + run: python -m pip install "build==1.2.2.post1" "twine==7.0.0" - name: Build source and wheel distributions run: python -m build --sdist --wheel @@ -75,6 +75,85 @@ jobs: print(f"verified wheel contents: {wheels[0]}") PY + - name: Validate distribution metadata + run: | + mkdir -p package-evidence + python -m twine check dist/* | tee package-evidence/twine-check.txt + sha256sum dist/* | tee package-evidence/SHA256SUMS + + - name: Verify installed wheel contract + run: | + mapfile -t wheels < <(find "$GITHUB_WORKSPACE/dist" -maxdepth 1 -type f -name '*.whl' -print) + if [ "${#wheels[@]}" -ne 1 ]; then + echo "expected exactly one wheel, found ${#wheels[@]}" + exit 1 + fi + + wheel_env="$RUNNER_TEMP/obliteratus-wheel-env" + wheel_cwd="$RUNNER_TEMP/obliteratus-wheel-cwd" + python -m venv "$wheel_env" + mkdir -p "$wheel_cwd" + "$wheel_env/bin/python" -m pip install --no-cache-dir "rich==15.0.0" + "$wheel_env/bin/python" -m pip install --no-cache-dir --no-deps "${wheels[0]}" + + cd "$wheel_cwd" + "$wheel_env/bin/python" -I - <<'PY' | tee "$GITHUB_WORKSPACE/package-evidence/wheel-import.txt" + import importlib.metadata + from pathlib import Path + + import obliteratus + + origin = Path(obliteratus.__file__).resolve() + assert "site-packages" in origin.parts, origin + assert obliteratus.__version__ == importlib.metadata.version("obliteratus") + print(f"installed wheel import: {origin}") + print(f"version: {obliteratus.__version__}") + PY + "$wheel_env/bin/python" -I -m obliteratus --help > "$GITHUB_WORKSPACE/package-evidence/wheel-module-help.txt" + "$wheel_env/bin/obliteratus" --help > "$GITHUB_WORKSPACE/package-evidence/wheel-console-help.txt" + + - name: Verify installed sdist contract + run: | + mapfile -t sdists < <(find "$GITHUB_WORKSPACE/dist" -maxdepth 1 -type f -name '*.tar.gz' -print) + if [ "${#sdists[@]}" -ne 1 ]; then + echo "expected exactly one sdist, found ${#sdists[@]}" + exit 1 + fi + + sdist_env="$RUNNER_TEMP/obliteratus-sdist-env" + sdist_cwd="$RUNNER_TEMP/obliteratus-sdist-cwd" + python -m venv "$sdist_env" + mkdir -p "$sdist_cwd" + "$sdist_env/bin/python" -m pip install --no-cache-dir "rich==15.0.0" + "$sdist_env/bin/python" -m pip install --no-cache-dir --no-deps "${sdists[0]}" + + cd "$sdist_cwd" + "$sdist_env/bin/python" -I - <<'PY' | tee "$GITHUB_WORKSPACE/package-evidence/sdist-import.txt" + import importlib.metadata + from pathlib import Path + + import obliteratus + + origin = Path(obliteratus.__file__).resolve() + assert "site-packages" in origin.parts, origin + assert obliteratus.__version__ == importlib.metadata.version("obliteratus") + print(f"installed sdist import: {origin}") + print(f"version: {obliteratus.__version__}") + PY + "$sdist_env/bin/python" -I -m obliteratus --help > "$GITHUB_WORKSPACE/package-evidence/sdist-module-help.txt" + "$sdist_env/bin/obliteratus" --help > "$GITHUB_WORKSPACE/package-evidence/sdist-console-help.txt" + + - name: Upload distributions and package evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: distributions-py3.12 + path: | + dist/ + package-evidence/ + if-no-files-found: error + retention-days: 14 + lint: name: Ruff runs-on: ubuntu-latest @@ -94,6 +173,21 @@ jobs: - name: Install Ruff run: python -m pip install "ruff==0.8.6" + - name: Install actionlint with checksum verification + env: + ACTIONLINT_VERSION: "1.7.12" + ACTIONLINT_SHA256: "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" + run: | + archive="$RUNNER_TEMP/actionlint.tar.gz" + curl -fsSLo "$archive" \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + echo "${ACTIONLINT_SHA256} ${archive}" | sha256sum -c - + tar -xzf "$archive" -C "$RUNNER_TEMP" actionlint + + - name: Validate GitHub Actions workflows + run: | + "$RUNNER_TEMP/actionlint" -no-color + - name: Enforce Ruff F gate run: >- python -m ruff check --select F obliteratus tests diff --git a/WORKSPACE.md b/WORKSPACE.md index 63f4d69..8e6f6eb 100644 --- a/WORKSPACE.md +++ b/WORKSPACE.md @@ -50,6 +50,11 @@ Canonical required checks: - `python -c 'import obliteratus; print(obliteratus.__version__)'` - `python -m obliteratus --help` +CI additionally validates wheel and sdist metadata, installs each distribution +in an independent environment outside the checkout, exercises both CLI entry +paths, and retains the distributions plus evidence. Immutable CI action/tool +pins are recorded in [ci/digests.txt](ci/digests.txt). + GPU, MPS, model-download, network, and remote-execution checks are conditional release or risk-surface gates, not part of the default CPU job. diff --git a/ci/digests.txt b/ci/digests.txt new file mode 100644 index 0000000..6f60999 --- /dev/null +++ b/ci/digests.txt @@ -0,0 +1,5 @@ +# kind name immutable pin resolved version pinned rationale +action actions/checkout 3d3c42e5aac5ba805825da76410c181273ba90b1 v7.0.1 2026-08-14 baseline pin (#59) +action actions/setup-python 5fda3b95a4ea91299a34e894583c3862153e4b97 v7.0.0 2026-08-14 baseline pin (#59) +action actions/upload-artifact 043fb46d1a93c77aae656e7c1c64a875d1fc6a0a v7.0.1 2026-08-14 test/package evidence (#64, #65) +tool rhysd/actionlint sha256:8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 v1.7.12 2026-08-14 workflow syntax/policy gate (#65) diff --git a/tests/test_ci_policy.py b/tests/test_ci_policy.py new file mode 100644 index 0000000..2abda68 --- /dev/null +++ b/tests/test_ci_policy.py @@ -0,0 +1,47 @@ +"""Repository contracts for immutable CI execution dependencies.""" + +from __future__ import annotations + +import re +from pathlib import Path + + +ROOT = Path(__file__).parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "ci.yml" +MANIFEST = ROOT / "ci" / "digests.txt" +ACTION_REF = re.compile( + r"^\s*uses:\s*([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)@([0-9a-f]{40})\s+#\s+(\S+)\s*$", +) + + +def _manifest_entries() -> dict[tuple[str, str], tuple[str, str]]: + entries: dict[tuple[str, str], tuple[str, str]] = {} + for line in MANIFEST.read_text(encoding="utf-8").splitlines(): + if not line or line.startswith("#"): + continue + kind, name, pin, version, _date, _rationale = line.split(maxsplit=5) + entries[(kind, name)] = (pin, version) + return entries + + +def test_every_external_action_is_sha_pinned_and_manifested(): + entries = _manifest_entries() + uses_lines = [ + line for line in WORKFLOW.read_text(encoding="utf-8").splitlines() + if "uses:" in line and "uses: ./" not in line + ] + + assert uses_lines + for line in uses_lines: + match = ACTION_REF.match(line) + assert match is not None, f"external action is not SHA-pinned with a version comment: {line}" + name, pin, version = match.groups() + assert entries[("action", name)] == (pin, version) + + +def test_actionlint_version_and_checksum_match_manifest(): + workflow = WORKFLOW.read_text(encoding="utf-8") + pin, version = _manifest_entries()[("tool", "rhysd/actionlint")] + + assert f'ACTIONLINT_VERSION: "{version.removeprefix("v")}"' in workflow + assert f'ACTIONLINT_SHA256: "{pin.removeprefix("sha256:")}"' in workflow