From e715e4879deb3dd20665389793895ece6e756d16 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Tue, 10 Nov 2026 00:50:40 -0800 Subject: [PATCH 1/9] fix: unblock release provenance with research-repo-tools v0.2.8 - Adopt the published supplier in the manifest, lockfile, source policy, isolated recipes, and release workflows; remove the development Git pin. - Prepare authenticated native dependency builds and require their provenance in CI, signed release evidence, and publication workflows. - Delegate release runtime isolation to shared RRT APIs while preserving consumer-owned benchmark acquisition and approval records. - Expose the shared dependency-source transition command and document deliberate supplier adoption, pin-preserving updates, and release setup. - Refresh managed tool, GitHub Action, and Cargo dependency pins. Refs #333 --- .github/actions/setup-tools/action.yml | 2 +- .github/workflows/ci.yml | 20 ++- .github/workflows/dependabot-auto-merge.yml | 2 +- .github/workflows/prepare-release.yml | 16 +- .github/workflows/publish-crates.yml | 2 + .github/workflows/release-benchmarks.yml | 8 +- CONTRIBUTING.md | 17 ++- Cargo.lock | 12 +- dependency-sources.toml | 59 +++++++ docs/BENCHMARKING.md | 2 +- docs/RELEASING.md | 29 +++- justfile | 51 +++++-- pyproject.toml | 21 ++- tests/tooling/test_release_baseline.py | 3 +- tests/tooling/test_release_evidence.py | 14 +- tests/tooling/test_release_flow.py | 2 +- tests/tooling/test_release_publishing.py | 45 ++++-- tests/tooling/test_toolchain_integration.py | 161 ++++++++++++++++---- tooling/README.md | 92 ++++++++--- tooling/release-evidence.toml | 6 +- uv.lock | 119 ++++++++++++++- 21 files changed, 546 insertions(+), 137 deletions(-) diff --git a/.github/actions/setup-tools/action.yml b/.github/actions/setup-tools/action.yml index 4286f3a..e9c4c68 100644 --- a/.github/actions/setup-tools/action.yml +++ b/.github/actions/setup-tools/action.yml @@ -11,7 +11,7 @@ runs: using: composite steps: - name: Install declared uv - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 with: version-file: pyproject.toml enable-cache: ${{ inputs.cache }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20d5f81..1502aa1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,7 @@ env: jobs: build: runs-on: ${{ matrix.os }} + timeout-minutes: 60 strategy: fail-fast: false matrix: @@ -54,6 +55,9 @@ jobs: - name: Run CI checks run: just ci + - name: Verify native dependency provenance + run: just ci-dependencies + release-package: runs-on: ubuntu-latest timeout-minutes: 45 @@ -66,13 +70,15 @@ jobs: with: cache: "false" - name: Verify default and exact Cargo packages - run: just publish-check + run: | + just ci-dependencies + just publish-check release-evidence: needs: [build, release-package] if: github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'acgetchell/la-stack' runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 60 permissions: contents: read actions: read @@ -82,18 +88,18 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + - uses: ./.github/actions/setup-tools # zizmor: ignore[self-repository] actionlint 1.7.12 does not accept $/... with: - version-file: pyproject.toml - python-version-file: .python-version - enable-cache: false + cache: "false" + - name: Prepare native dependency provenance + run: just release-dependency-builds - name: Capture successful native validation identities id: evidence env: GH_TOKEN: ${{ github.token }} run: | uvx --python 3.15 \ - --from 'research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@cd8594f658b5bf3ade063a685834565cc62e0cb3' \ + --from 'research-repo-tools==0.2.8' \ python -I - <<'PY' import os import sys diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index cabfcca..a9e8c9d 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -22,7 +22,7 @@ jobs: permissions: contents: write pull-requests: write - uses: acgetchell/research-repo-tools/.github/workflows/dependabot-approve.yml@b972cbfd717319abaa68d9b1373711ad6f163294 # reviewed 2026-10-01 + uses: acgetchell/research-repo-tools/.github/workflows/dependabot-approve.yml@4a8f53f684dee2abb7c5b8a4beab40ef29212b4c # v0.2.7 with: repository: acgetchell/la-stack policy: >- diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 763179c..d122647 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -18,7 +18,7 @@ jobs: verify: if: github.repository == 'acgetchell/la-stack' runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 60 outputs: commit: ${{ steps.source.outputs.commit }} artifact-id: ${{ steps.handoff.outputs.artifact-id }} @@ -30,12 +30,12 @@ jobs: ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - - name: Install trusted package runner - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + - name: Set up declared verification tools + uses: ./.github/actions/setup-tools # zizmor: ignore[self-repository] actionlint 1.7.12 does not accept $/... with: - version: "0.13.7" - enable-cache: false - cache-python: false + cache: "false" + - name: Prepare native dependency provenance + run: just release-dependency-builds - name: Verify tagged source and freeze native evidence and notes id: source env: @@ -52,7 +52,7 @@ jobs: mkdir "$RUNNER_TEMP/handoff" cp tooling/release-evidence.toml "$RUNNER_TEMP/handoff/evidence.toml" runtime=(uv run --no-config --no-project --isolated --no-cache --python 3.15 - --with 'research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@cd8594f658b5bf3ade063a685834565cc62e0cb3' + --with 'research-repo-tools==0.2.8' python -I -c 'import sys; from research_repo_tools.cli import main; sys.exit(main())') "${runtime[@]}" --root "$GITHUB_WORKSPACE" release check "$RELEASE_TAG" "${runtime[@]}" --root "$GITHUB_WORKSPACE" release evidence verify \ @@ -97,7 +97,7 @@ jobs: # Run Python from the runner's script file, avoiding actionlint #651's stdin deadlock. shell: >- uv run --no-config --no-project --isolated --no-cache --python 3.15 - --with "research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@cd8594f658b5bf3ade063a685834565cc62e0cb3" + --with "research-repo-tools==0.2.8" python -I "{0}" run: | import os diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index 60b7556..fd5db2b 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -63,6 +63,7 @@ jobs: CANDIDATE_ACCEPTANCE: ${{ inputs.acceptance }} run: | set -euo pipefail + just release-dependency-builds just release-receive "$RUNNER_TEMP/accepted-candidate" --published just _release-notes-check "$RELEASE_TAG" @@ -127,6 +128,7 @@ jobs: CANDIDATE_ACCEPTANCE: ${{ inputs.acceptance }} run: | set -euo pipefail + just release-dependency-builds just release-receive "$RUNNER_TEMP/accepted-candidate" --published --expected "$RUNNER_TEMP/approved-acceptance/acceptance.json" just _release-notes-check "$RELEASE_TAG" uv run --locked --no-sync research-repo-tools release registry "$RELEASE_TAG" --expect absent diff --git a/.github/workflows/release-benchmarks.yml b/.github/workflows/release-benchmarks.yml index d7823f2..248af12 100644 --- a/.github/workflows/release-benchmarks.yml +++ b/.github/workflows/release-benchmarks.yml @@ -49,7 +49,7 @@ jobs: run: | set -euo pipefail uvx --python 3.15 \ - --from 'research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@cd8594f658b5bf3ade063a685834565cc62e0cb3' \ + --from 'research-repo-tools==0.2.8' \ python -I - <<'PY' import hashlib, json, os from pathlib import Path @@ -136,7 +136,9 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.tag }} CANDIDATE_TRANSFER: ${{ inputs.candidate }} - run: just release-receive "$RUNNER_TEMP/accepted-candidate" --incoming "$RUNNER_TEMP/incoming-candidate" + run: | + just release-dependency-builds + just release-receive "$RUNNER_TEMP/accepted-candidate" --incoming "$RUNNER_TEMP/incoming-candidate" - name: Upload verified inert archive id: upload @@ -193,7 +195,7 @@ jobs: run: | set -euo pipefail uvx --python 3.15 \ - --from 'research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@cd8594f658b5bf3ade063a685834565cc62e0cb3' \ + --from 'research-repo-tools==0.2.8' \ python -I - <<'PY' import hashlib, json, os from pathlib import Path diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9733e9b..56ecced 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,7 +53,7 @@ change files. Run bare `just` for the complete generated command list, including arguments and descriptions. The [command policy](docs/dev/docs.md#command-discovery-and-placement) owns recipe naming and documentation placement. -Setup, tool checks, updates, changelog, and opt-in CodeRabbit review use the published `research-repo-tools==0.2.6` package, +Setup, tool checks, updates, changelog, and opt-in CodeRabbit review use the published `research-repo-tools==0.2.8` package, locked in the `tooling` dependency group and included by `dev`. Its `python-tools` extra owns the pytest, Ruff and Ty versions. `toolchain.inherit-python-tools` checks those versions against the lock and @@ -61,10 +61,10 @@ selected executables. Keep repository lint policy local and omit duplicate version pins for these tools from `dev`. Normal setup and CI install it from PyPI through `uv sync --locked --group dev`; a sibling -checkout is unnecessary. To upgrade it deliberately, review the exact -`tooling` and project runtime requirements and refresh `uv.lock` together, then run the consumer -integration tests and `just ci`. See the [Tooling guide](tooling/README.md#changelog-and-release-tooling) -for the retained changelog policy and ownership boundary. +checkout is unnecessary. To upgrade it deliberately, use the published target's +adoption command and reconcile isolated pins as described in +[Shared capability adoption](tooling/README.md#shared-capability-adoption), then +run the consumer integration tests and `just ci`. The shared setup contract requires an existing uv and does not generate bootstrap installers. `.python-version` selects Python; `rust-toolchain.toml` selects Rust @@ -136,7 +136,12 @@ are retained under `target/security`, with secret findings redacted. These gates run separately from `just ci`; the hosted RustSec audit remains in place. CI runs `just ci` on Ubuntu, macOS, and Windows to keep platform coverage -aligned with the local comprehensive validation path. +aligned with the local comprehensive validation path. Each native job also runs +`just ci-dependencies` for release provenance: it prepares authenticated native +dependency builds and verifies installed bytes when tooling is published. +Verified development tooling remains eligible for ordinary checks; release +evidence still requires a published supplier. See the +[source-build adoption contract](tooling/README.md#shared-capability-adoption). ### CodeRabbit review diff --git a/Cargo.lock b/Cargo.lock index 573b211..772dd7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,7 +99,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.10", ] [[package]] @@ -125,7 +125,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.10", ] [[package]] @@ -1279,7 +1279,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.10", ] [[package]] @@ -1343,9 +1343,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.9" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79" dependencies = [ "proc-macro2", "quote", @@ -1513,7 +1513,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.10", "wasm-bindgen-shared", ] diff --git a/dependency-sources.toml b/dependency-sources.toml index 8cbd869..39f8bbe 100644 --- a/dependency-sources.toml +++ b/dependency-sources.toml @@ -10,3 +10,62 @@ release-owned = [ "." ] ecosystem = "cargo" directory = "." release-owned = [ ".", "benches/comparison" ] + +# Edit this selection, preview with `just dependency-source tooling`, then apply. +[sources.tooling] +project = "python" +package = "research-repo-tools" +version = "0.2.8" + +# Native payload pins follow research-repo-tools v0.2.7's actionlint policy. +[source-builds] +packages = [ "actionlint-py", "pyyaml", "ruamel-yaml-clib" ] +group = "build" + +[[source-builds.external]] +package = "actionlint-py" +platform = "linux-x86_64" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz" +sha256 = "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" +member = "actionlint" +wheel-member = "actionlint_py-1.7.12.25.data/scripts/actionlint" + +[[source-builds.external]] +package = "actionlint-py" +platform = "linux-arm64" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_arm64.tar.gz" +sha256 = "325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6" +member = "actionlint" +wheel-member = "actionlint_py-1.7.12.25.data/scripts/actionlint" + +[[source-builds.external]] +package = "actionlint-py" +platform = "darwin-x86_64" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_darwin_amd64.tar.gz" +sha256 = "5b44c3bc2255115c9b69e30efc0fecdf498fdb63c5d58e17084fd5f16324c644" +member = "actionlint" +wheel-member = "actionlint_py-1.7.12.25.data/scripts/actionlint" + +[[source-builds.external]] +package = "actionlint-py" +platform = "darwin-arm64" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_darwin_arm64.tar.gz" +sha256 = "aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f" +member = "actionlint" +wheel-member = "actionlint_py-1.7.12.25.data/scripts/actionlint" + +[[source-builds.external]] +package = "actionlint-py" +platform = "win32-x86_64" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_windows_amd64.zip" +sha256 = "6e7241b51e6817ea6a047693d8e6fed13b31819c9a0dd6c5a726e1592d22f6e9" +member = "actionlint.exe" +wheel-member = "actionlint_py-1.7.12.25.data/scripts/actionlint.exe" + +[[source-builds.external]] +package = "actionlint-py" +platform = "win32-arm64" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_windows_arm64.zip" +sha256 = "cadcf7ea4efe3a68728893813643cebe1185e5b1d4be5b96245f65c9a4d5ea41" +member = "actionlint.exe" +wheel-member = "actionlint_py-1.7.12.25.data/scripts/actionlint.exe" diff --git a/docs/BENCHMARKING.md b/docs/BENCHMARKING.md index 4466a23..8c4243c 100644 --- a/docs/BENCHMARKING.md +++ b/docs/BENCHMARKING.md @@ -215,7 +215,7 @@ diagnostics. Captured logs need no TTY. Counts advance only after phase validati partial output files do not count. ETA stays unknown for unseen work, and neither an estimate nor the nominal budget is a timeout. Scheduled progress reports trial, suite and phase state, with eight nominal seconds per case. -The current development pin fixes the gate-failure event defect recorded in the +The published v0.2.7 tooling fixes the gate-failure event defect recorded in the review. Preserve the command's nonzero exit and runtime record when diagnosing failure. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index fbb0846..2f5863d 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -111,11 +111,11 @@ constraints used by the installed verifier. See GitHub's ## Release command alignment The common Step 1/Step 2 flow follows the -[research-repo-tools v0.2.6 guide](https://github.com/acgetchell/research-repo-tools/blob/eb7e3eaf158024a045a6711898a8af199729131b/docs/RELEASING.md). +[research-repo-tools v0.2.8 guide](https://github.com/acgetchell/research-repo-tools/blob/v0.2.8/docs/RELEASING.md). Draft preparation uses the public capability from upstream -[PR #207](https://github.com/acgetchell/research-repo-tools/pull/207), rehearsed here at -`cd8594f658b5bf3ade063a685834565cc62e0cb3`, including receipt retrieval from -[PR #209](https://github.com/acgetchell/research-repo-tools/pull/209). Published-package and final native adoption +[PR #207](https://github.com/acgetchell/research-repo-tools/pull/207), supplied in +v0.2.7 along with receipt retrieval from +[PR #209](https://github.com/acgetchell/research-repo-tools/pull/209). Published-package release checks and final native adoption remain required by [#333](https://github.com/acgetchell/la-stack/issues/333). | Command | Common behavior and la-stack declaration | @@ -145,7 +145,17 @@ approval are the remaining registry and scientific-asset differences. Replace any development tooling pin with its supplying published release and reconcile the manifest, lock and isolated runtime pins as described in [Shared capability adoption](../tooling/README.md#shared-capability-adoption). -Before release acquisition, run `just release-dependencies`. This shared gate +Normal `just update` preserves the exact shared-tooling pin and development +source overrides. Use the target release's adoption command for a published +pin upgrade, or the shared explicit promotion flow for an eligible Git pin; +neither transition happens automatically when a supplying release appears. +Before release acquisition, run `just release-dependency-builds`, then +`just release-dependencies`. Preparation rebuilds the declared source-only/native +Python dependencies and authenticates the installed wheels on this machine. +Rerun preparation after manifest, lock, policy, interpreter, or builder changes; +receipts and signing keys stay outside the checkout and are never transferred +between machines. Fresh release jobs prepare their own dependency receipts. +This shared gate rejects Git sources even when their version label matches a published package; release evidence and publication repeat the check. Retain development results as rehearsal evidence and revalidate the final published-pin inputs. @@ -184,6 +194,13 @@ creating the release branch: just update ``` +After the final dependency changes, prepare and verify native provenance: + +```bash +just release-dependency-builds +just release-dependencies +``` + Review any tracked changes and land them separately before continuing with the release PR, then synchronize `main` again. This keeps dependency and tool upgrades independently reviewable; `just update-version` deliberately does not @@ -749,7 +766,7 @@ permission to create a replacement. Recover the exact original artifacts by independently recorded immutable IDs, verify their ZIP digests and producer run/attempts, and retain the original policy, selection, notes and receipt. Follow the supplying package's -[recovery](https://github.com/acgetchell/research-repo-tools/blob/cd8594f658b5bf3ade063a685834565cc62e0cb3/docs/PREPARING_DRAFTS.md#retry-and-recovery). +[recovery](https://github.com/acgetchell/research-repo-tools/blob/v0.2.8/docs/PREPARING_DRAFTS.md#retry-and-recovery). After independent provider/audit review, its `release assets prepare` CLI accepts `--recover-release-id REVIEWED_ID` with the original pending receipt and frozen inputs; recovery performs no POST. A captured ID cannot be replaced. This is an diff --git a/justfile b/justfile index 1ad12de..47b68d2 100644 --- a/justfile +++ b/justfile @@ -70,11 +70,15 @@ _ensure-yamllint: _ensure-uv # Isolate the supplier runtime from the observed candidate checkout. [positional-arguments] [private] -[script("uvx", "--python", "3.15", "--from", "research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@cd8594f658b5bf3ade063a685834565cc62e0cb3", "python", "-I")] +[script("uvx", "--no-config", "--isolated", "--managed-python", "--python", "3.15", "--from", "research-repo-tools==0.2.8", "python", "-I")] _release-candidate *args: import runpy from pathlib import Path - runpy.run_path(str(Path.cwd() / "tooling/release_candidate.py"), run_name="__main__") + from research_repo_tools.config import load + from research_repo_tools.release_runtime import isolated_release_runtime + root = Path.cwd().resolve() + with isolated_release_runtime(load(root=root)): + runpy.run_path(str(root / "tooling/release_candidate.py"), run_name="__main__") # Require the GitHub release title and body to match the generated notes. [private] @@ -263,6 +267,23 @@ check-fast: ci: python-sync tools-check _ci-policy _ci-execution @echo "🎯 CI checks complete!" +# Prepare native release provenance in CI; verified development tooling keeps ordinary checks available. +[script("uv", "run", "--locked", "--no-sync", "--no-python-downloads", "research-repo-tools", "toolchain", "run", "--", "python", "-I")] +ci-dependencies: python-sync + import sys + from pathlib import Path + from research_repo_tools.cli import main + from research_repo_tools.dependency_provenance import check_development_dependency + if check_development_dependency(Path.cwd()) is not None: + print("Verified development tooling pin; release provenance requires a published supplier.") + else: + from research_repo_tools.process import run_safe_command + run_safe_command("cargo", ["fetch", "--locked"], cwd=Path.cwd(), timeout=600) + for action in ("dependency-builds", "dependencies"): + status = main(["release", action]) + if status: + sys.exit(status) + # Validate CITATION.cff against the Citation File Format schema. citation-check: _ensure-uv uvx --from cffconvert==2.0.0 cffconvert --validate -i CITATION.cff @@ -308,6 +329,11 @@ coverage-ci: tools-check coverage-report report="coverage/cobertura.xml": python-sync uv run --locked research-repo-tools coverage report --report {{ quote(report) }} +# Preview a configured Python/Cargo source transition; pass --apply to publish manifests and locks. +[positional-arguments] +dependency-source name *args: + {{ _run }} uv run --locked --no-sync research-repo-tools deps source "$@" + # Documentation build checks for the default and exact-feature public APIs. doc-check: RUSTDOCFLAGS='-D warnings' {{ _run }} cargo doc --no-deps @@ -470,11 +496,11 @@ release-archive owner="" selection="reviewed": (_release-candidate "archive" own # Use explicit shared asset/recovery operations with an isolated trusted runtime. [positional-arguments] -[script("uvx", "--python", "3.15", "--from", "research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@cd8594f658b5bf3ade063a685834565cc62e0cb3", "python", "-I")] +[script("uvx", "--no-config", "--isolated", "--managed-python", "--python", "3.15", "--from", "research-repo-tools==0.2.8", "python", "-I")] release-assets +args: import sys from research_repo_tools.cli import main - sys.exit(main(["release", "assets", *sys.argv[1:]])) + sys.exit(main(["release", "--isolated-runtime", "assets", *sys.argv[1:]])) # Dispatch retained benchmark attachment using the original Step 1 owner selection. release-benchmarks tag: _ensure-gh _ensure-jq @@ -489,7 +515,12 @@ release-benchmarks tag: _ensure-gh _ensure-jq # Verify upstream tooling publication before release preparation or acquisition. release-dependencies: - uv run --locked --no-sync research-repo-tools release dependencies + {{ _run }} uv run --locked --no-sync research-repo-tools release dependencies + +# Rebuild declared registry sources and authenticate this machine's installed wheels. +release-dependency-builds: python-sync + {{ _run }} cargo fetch --locked + {{ _run }} uv run --locked --no-sync research-repo-tools release dependency-builds # Bind the original local acquisition to clean HEAD and signed native CI. release-evidence owner="" selection="reviewed": (_release-candidate "verify" owner "--selection" selection) @@ -502,17 +533,19 @@ release-gate tag: # Verify signed native/Cargo evidence; accept --expected PATH and --github-output PATH. [positional-arguments] -[script("uvx", "--python", "3.15", "--from", "research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@cd8594f658b5bf3ade063a685834565cc62e0cb3", "python", "-I")] +[script("uvx", "--no-config", "--isolated", "--managed-python", "--python", "3.15", "--from", "research-repo-tools==0.2.8", "python", "-I")] release-native-evidence *args: import sys import tempfile from pathlib import Path from research_repo_tools.cli import main + from research_repo_tools.config import load from research_repo_tools.process import run_git_command from research_repo_tools.release_metadata import read_package_info - root = Path.cwd() - commit = run_git_command(["--no-pager", "rev-parse", "HEAD"], cwd=root, timeout=30).stdout.strip() - with tempfile.TemporaryDirectory(prefix="la-stack-release-evidence-") as temporary: + from research_repo_tools.release_runtime import isolated_release_runtime + root = Path.cwd().resolve() + with isolated_release_runtime(load(root=root)), tempfile.TemporaryDirectory(prefix="la-stack-release-evidence-") as temporary: + commit = run_git_command(["--no-pager", "rev-parse", "HEAD"], cwd=root, timeout=30).stdout.strip() sys.exit(main(["release", "evidence", "verify", "tooling/release-evidence.toml", "--checkout", str(root), "--commit", commit, "--version", read_package_info(root).version, "--output", str(Path(temporary).resolve() / "selection.json"), *sys.argv[1:]])) # Print release notes from the root changelog or a completed minor archive. diff --git a/pyproject.toml b/pyproject.toml index dd52575..53186da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,12 +108,16 @@ override-dependencies = [ "tomli==2.5.0", ] -# Temporary downstream rehearsal pin; return to PyPI after the supplying release. -[tool.uv.sources] -research-repo-tools = { git = "https://github.com/acgetchell/research-repo-tools", rev = "cd8594f658b5bf3ade063a685834565cc62e0cb3" } - [dependency-groups] -tooling = [ "research-repo-tools[python-tools,semgrep]==0.2.6" ] +build = [ + "cython==3.3.1", + "hatchling==1.32.4", + "requests==2.34.2", + "semver==3.1.1", + "setuptools==84.2.1", + "wheel==0.48.0", +] +tooling = [ "research-repo-tools[python-tools,semgrep]==0.2.8" ] dev = [ { include-group = "tooling" }, "actionlint-py==1.7.12.25", @@ -131,7 +135,7 @@ inherit-python-tools = true [tool.research-repo-tools.toolchain.binaries] gitleaks = "8.30.1" -osv-scanner = "2.7.0" +osv-scanner = "2.7.1" [tool.research-repo-tools.toolchain.cargo] cargo-edit = "0.13.14" @@ -144,7 +148,7 @@ git-cliff = "2.14.2" rumdl = "0.3.2" sarif-fmt = "0.8.0" taplo-cli = "0.10.0" -typos-cli = "1.52.0" +typos-cli = "1.52.1" zizmor = "1.32.0" [tool.research-repo-tools.changelog] @@ -210,3 +214,6 @@ fixtures = "tests/semgrep" namespace = "la-stack." cwd = "." timeout = 120 + +[tool.uv.dependency-groups] +tooling = { requires-python = ">=3.15" } diff --git a/tests/tooling/test_release_baseline.py b/tests/tooling/test_release_baseline.py index 5c94229..bd5d9dd 100644 --- a/tests/tooling/test_release_baseline.py +++ b/tests/tooling/test_release_baseline.py @@ -369,7 +369,8 @@ def test_workflow_reuses_original_acquisition_and_isolates_write_credentials() - assert attach[2]["with"]["artifact-ids"] == "${{ needs.verify.outputs.artifact-id }}" assert attach[1]["env"]["ARTIFACT_DIGEST"] == "${{ needs.verify.outputs.artifact-digest }}" text = WORKFLOW.read_text(encoding="utf-8") - assert text.count(f"--from {shlex.quote(TOOLING_REQUIREMENT)}") == 2 + runtime_requirements = [shlex.split(line.removesuffix("\\").strip()) for line in text.splitlines() if line.lstrip().startswith("--from ")] + assert runtime_requirements == [["--from", TOOLING_REQUIREMENT]] * 2 for forbidden in ("bench-save-baseline", "measure-baseline", "cargo bench", "performance measure", "--clobber", "continue-on-error"): assert forbidden not in text assert "publish_release_asset(root, target.repository, target.tag, artifact, target=target)" in text diff --git a/tests/tooling/test_release_evidence.py b/tests/tooling/test_release_evidence.py index cc0faea..9869fd2 100644 --- a/tests/tooling/test_release_evidence.py +++ b/tests/tooling/test_release_evidence.py @@ -26,17 +26,17 @@ INVOCATION = f"https://github.com/{REPOSITORY}/actions/runs/101/attempts/2" POLICY = load_evidence_policy(REPO_ROOT / "tooling/release-evidence.toml") NATIVE_GATES = ( - ("build (ubuntu-latest)", "Run CI checks", 201), - ("build (macos-latest)", "Run CI checks", 202), - ("build (windows-latest)", "Run CI checks", 203), - ("release-package", "Verify default and exact Cargo packages", 204), + ("build (ubuntu-latest)", ("Run CI checks", "Verify native dependency provenance"), 201), + ("build (macos-latest)", ("Run CI checks", "Verify native dependency provenance"), 202), + ("build (windows-latest)", ("Run CI checks", "Verify native dependency provenance"), 203), + ("release-package", ("Verify default and exact Cargo packages",), 204), ) SIGNING_STEPS = ( "Capture successful native validation identities", "Sign original main validation evidence", "Upload signed validation evidence", ) -REQUIRED_STEPS = (*((index, 0) for index in range(4)), *((4, index) for index in range(3))) +REQUIRED_STEPS = (*((index, step) for index, (_, steps, _) in enumerate(NATIVE_GATES) for step in range(len(steps))), *((4, index) for index in range(3))) def install_clean_checkout(patch: pytest.MonkeyPatch, checkout: Path, commit: str) -> None: @@ -82,7 +82,7 @@ def __init__(self, root: Path) -> None: "status": "completed", "conclusion": "success", } - gates = [*((name, (step,), identifier) for name, step, identifier in NATIVE_GATES), ("release-evidence", SIGNING_STEPS, 205)] + gates = [*NATIVE_GATES, ("release-evidence", SIGNING_STEPS, 205)] self.jobs: list[dict[str, Any]] = [ { "id": identifier, @@ -114,7 +114,7 @@ def __init__(self, root: Path) -> None: "workflow_name": "CI", "ref": "refs/heads/main", "event": "push", - "required_steps": {name: [step] for name, step, _ in NATIVE_GATES}, + "required_steps": {name: list(steps) for name, steps, _ in NATIVE_GATES}, "runner_labels": {name: [name.removeprefix("build (").removesuffix(")")] for name, _, _ in NATIVE_GATES[:3]}, "artifact_prefix": "release-evidence", "guarantee": "signed-source", diff --git a/tests/tooling/test_release_flow.py b/tests/tooling/test_release_flow.py index 27c5e5f..9dc7ab1 100644 --- a/tests/tooling/test_release_flow.py +++ b/tests/tooling/test_release_flow.py @@ -245,7 +245,7 @@ def test_draft_workflow_keeps_the_writer_isolated_and_recovery_explicit() -> Non # Custom shells use .NET argument parsing; single quotes are not delimiters. command = shlex.split(step["shell"], posix=False) assert command[:9] == ["uv", "run", "--no-config", "--no-project", "--isolated", "--no-cache", "--python", "3.15", "--with"] - assert command[9].startswith('"research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@') + assert command[9] == '"research-repo-tools==0.2.8"' assert command[9].endswith('"') assert command[10:] == ["python", "-I", '"{0}"'] assert step["working-directory"] == "${{ runner.temp }}" diff --git a/tests/tooling/test_release_publishing.py b/tests/tooling/test_release_publishing.py index 26dbd16..247e339 100644 --- a/tests/tooling/test_release_publishing.py +++ b/tests/tooling/test_release_publishing.py @@ -78,7 +78,7 @@ def git(args: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: def published(root: Path, *, packages: tuple[str, ...] | None = None) -> tuple[dependency_provenance.DependencyIdentity, ...]: assert root == REPO_ROOT assert packages == ("research-repo-tools",) - return (dependency_provenance.DependencyIdentity("research-repo-tools", "0.2.6", "https://pypi.org/simple"),) + return (dependency_provenance.DependencyIdentity("research-repo-tools", "0.2.8", "https://pypi.org/simple"),) monkeypatch.setattr(dependency_provenance, "check_release_dependencies", published) install_clean_checkout(monkeypatch, REPO_ROOT, SHA) @@ -247,6 +247,10 @@ def run_evidence_fixture() -> int: with pytest.MonkeyPatch.context() as patch: provider.install(patch) patch.setattr(process, "run_git_command", provider.git) + # The uv launcher needs the real test environment; provenance observes + # only the dependency-free release fixture after process startup. + patch.delenv("UV_PROJECT", raising=False) + patch.delenv("UV_PROJECT_ENVIRONMENT", raising=False) with redirect_stderr(diagnostic): try: sys.argv[:] = [script, *arguments] @@ -267,15 +271,19 @@ def run_evidence_fixture() -> int: def evidence_runner(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Keep real workflow shell, Just, uv, Python, verifier and output writer.""" source = (REPO_ROOT / "justfile").read_text(encoding="utf-8") - runtime = ( - '[script("uvx", "--python", "3.15", "--from", "research-repo-tools @ ' - 'git+https://github.com/acgetchell/research-repo-tools@cd8594f658b5bf3ade063a685834565cc62e0cb3", "python", "-I")]' - ) + runtime = '[script("uvx", "--no-config", "--isolated", "--managed-python", "--python", "3.15", "--from", "research-repo-tools==0.2.8", "python", "-I")]' assert source.count(runtime) == 3 # Run the exact recipe body/argument transport with the locked test runtime. # The separate isolated-runtime smoke and pin test own uvx installation. - source = source.replace(runtime, '[script("uv", "run", "--locked", "--no-sync", "python", "fixture-bootstrap.py")]') + source = source.replace(runtime, '[script("uv", "run", "--locked", "--no-sync", "python", "-I", "fixture-bootstrap.py")]') (tmp_path / "justfile").write_text(source, encoding="utf-8", newline="\n") + uv_pin = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))["tool"]["uv"]["required-version"] + (tmp_path / "pyproject.toml").write_text( + f'[project]\nname = "release-fixture"\nversion = "0.0.0"\nrequires-python = ">=3.15"\ndependencies = []\n[tool.uv]\nrequired-version = "{uv_pin}"\n', + encoding="utf-8", + newline="\n", + ) + (tmp_path / ".python-version").write_bytes((REPO_ROOT / ".python-version").read_bytes()) (tmp_path / "Cargo.toml").write_text('[package]\nname = "la-stack"\nversion = "0.5.42"\n', encoding="utf-8") (tmp_path / "tooling").mkdir() (tmp_path / "tooling/release-evidence.toml").write_bytes((REPO_ROOT / "tooling/release-evidence.toml").read_bytes()) @@ -283,11 +291,12 @@ def evidence_runner(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv(name, raising=False) monkeypatch.setenv("FIXTURE_ROOT", str(tmp_path)) monkeypatch.setenv("FIXTURE_REPO_ROOT", str(REPO_ROOT)) + monkeypatch.setenv("FIXTURE_MODULE_ROOT", str(REPO_ROOT)) monkeypatch.setenv("RUNNER_TEMP", tmp_path.as_posix()) monkeypatch.setenv("UV_PROJECT", str(REPO_ROOT)) monkeypatch.setenv("UV_PROJECT_ENVIRONMENT", sys.prefix) (tmp_path / "fixture-bootstrap.py").write_text( - 'import os, sys\nsys.path.insert(0, os.environ["FIXTURE_REPO_ROOT"])\n' + 'import os, sys\nsys.path[:0] = [os.environ["FIXTURE_REPO_ROOT"], os.environ["FIXTURE_MODULE_ROOT"]]\n' "from tests.tooling.test_release_publishing import run_evidence_fixture\nsys.exit(run_evidence_fixture())\n", encoding="utf-8", newline="\n", @@ -369,8 +378,8 @@ def test_evidence_export_uses_real_just_and_installed_writer_without_stdout_capt def test_evidence_bootstrap_keeps_windows_import_paths_as_environment_data( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, import_root: str, newline: str ) -> None: - # Exercise literal Windows path data on every host. PYTHONPATH supplies the - # real module after the intentionally nonexistent path inserted by bootstrap. + # Exercise literal Windows path data on every host. The explicit harness + # root follows this nonexistent path because isolated Python ignores PYTHONPATH. monkeypatch.setenv("FIXTURE_REPO_ROOT", import_root) monkeypatch.setenv("PYTHONPATH", str(REPO_ROOT)) monkeypatch.setenv("FIXTURE_DIAGNOSTIC_NEWLINE", newline) @@ -462,7 +471,9 @@ def test_explicit_empty_identity_cannot_select_fresh_evidence(tmp_path: Path, mo assert output.read_bytes() == original -@pytest.mark.parametrize(("failure", "expected_exit"), [("none", 0), ("evidence", 1), ("candidate", 1), ("present", 1), ("unavailable", 2)]) +@pytest.mark.parametrize( + ("failure", "expected_exit"), [("none", 0), ("dependency-builds", 1), ("evidence", 1), ("candidate", 1), ("present", 1), ("unavailable", 2)] +) def test_actual_preupload_shell_requires_evidence_and_proven_absence(tmp_path: Path, failure: str, expected_exit: int) -> None: workflow = yaml.safe_load(WORKFLOW.read_bytes()) script = next( @@ -470,6 +481,8 @@ def test_actual_preupload_shell_requires_evidence_and_proven_absence(tmp_path: P ) marker = tmp_path / "upload-reached" gate = "just() { return 1; }" if failure == "evidence" else "just() { return 0; }" + if failure == "dependency-builds": + gate = 'just() { [[ "$1" != release-dependency-builds ]]; }' if failure == "candidate": gate = 'just() { [[ "$1" != release-receive ]]; }' registry_exit = expected_exit if failure in {"present", "unavailable"} else 0 @@ -525,10 +538,14 @@ def test_main_ci_seals_only_complete_native_and_cargo_validation() -> None: workflow = yaml.safe_load((REPO_ROOT / ".github/workflows/ci.yml").read_bytes()) jobs = workflow["jobs"] assert jobs["build"]["strategy"]["matrix"]["os"] == ["ubuntu-latest", "macos-latest", "windows-latest"] - assert jobs["build"]["steps"][-1] == {"name": "Run CI checks", "run": "just ci"} + assert jobs["build"]["timeout-minutes"] == 60 + assert jobs["build"]["steps"][-2:] == [ + {"name": "Run CI checks", "run": "just ci"}, + {"name": "Verify native dependency provenance", "run": "just ci-dependencies"}, + ] package = jobs["release-package"] assert package["steps"][1]["with"]["cache"] == "false" - assert package["steps"][-1] == {"name": "Verify default and exact Cargo packages", "run": "just publish-check"} + assert package["steps"][-1] == {"name": "Verify default and exact Cargo packages", "run": "just ci-dependencies\njust publish-check\n"} evidence = jobs["release-evidence"] assert evidence["needs"] == ["build", "release-package"] assert evidence["if"] == "github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'acgetchell/la-stack'" @@ -543,7 +560,7 @@ def test_main_ci_seals_only_complete_native_and_cargo_validation() -> None: "--python", "3.15", "--from", - "research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@cd8594f658b5bf3ade063a685834565cc62e0cb3", + "research-repo-tools==0.2.8", "python", "-I", "-", @@ -572,7 +589,7 @@ def test_main_ci_creates_no_release_evidence_for_development_pins(tmp_path: Path output.write_bytes(original) for key, value in {"GITHUB_OUTPUT": str(output), "GITHUB_SHA": SHA, "GITHUB_RUN_ID": "11", "GITHUB_RUN_ATTEMPT": "1"}.items(): monkeypatch.setenv(key, value) - pin = dependency_provenance.DependencyIdentity("research-repo-tools", "0.2.6", "git", "a" * 40) if source == "development" else None + pin = dependency_provenance.DependencyIdentity("research-repo-tools", "0.2.8", "git", "a" * 40) if source == "development" else None monkeypatch.setattr(dependency_provenance, "check_development_dependency", lambda _root: pin) calls: list[list[str]] = [] diff --git a/tests/tooling/test_toolchain_integration.py b/tests/tooling/test_toolchain_integration.py index d4b6bea..a05328c 100644 --- a/tests/tooling/test_toolchain_integration.py +++ b/tests/tooling/test_toolchain_integration.py @@ -2,6 +2,7 @@ import json import os +import runpy import shlex import shutil import sys @@ -15,7 +16,8 @@ from packaging.requirements import Requirement from research_repo_tools.cli import main from research_repo_tools.dependency_policy import load_policy -from research_repo_tools.dependency_provenance import check_development_dependency +from research_repo_tools.dependency_provenance import DependencyIdentity, check_development_dependency +from research_repo_tools.just_inspect import inspect_justfile from research_repo_tools.process import run_command from research_repo_tools.python_tools import TOOLS, versions as shared_tool_versions @@ -23,9 +25,8 @@ import subprocess REPO_ROOT = Path(__file__).resolve().parents[2] -TOOLING_REVISION = "cd8594f658b5bf3ade063a685834565cc62e0cb3" -TOOLING_REPOSITORY = "https://github.com/acgetchell/research-repo-tools" -TOOLING_REQUIREMENT = f"research-repo-tools @ git+{TOOLING_REPOSITORY}@{TOOLING_REVISION}" +TOOLING_VERSION = "0.2.8" +TOOLING_REQUIREMENT = f"research-repo-tools=={TOOLING_VERSION}" CHECKED = ["run", "--locked", "--no-sync", "--no-python-downloads", "research-repo-tools", "toolchain", "run", "--"] UPDATER = ["run", "--locked", "--only-group", "tooling", "--inexact", "research-repo-tools"] UV_UPDATE = ["run", "--no-config", "--no-sync", "--no-python-downloads", "research-repo-tools", "deps", "update-uv"] @@ -49,9 +50,9 @@ } -def test_tooling_commit_matches_manifest_lock_and_installation() -> None: - """Distinguish the reviewed upstream commit from its unchanged version label.""" - expected_version = "0.2.6" +def test_tooling_release_matches_manifest_lock_and_installation() -> None: + """Require the published supplier throughout the normal installed consumer.""" + expected_version = TOOLING_VERSION manifest = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) assert manifest["project"]["dependencies"] == [] assert manifest["tool"]["uv"]["package"] is False @@ -64,21 +65,19 @@ def test_tooling_commit_matches_manifest_lock_and_installation() -> None: lock = tomllib.loads((REPO_ROOT / "uv.lock").read_text(encoding="utf-8")) package = next(item for item in lock["package"] if item["name"] == "research-repo-tools") assert package["version"] == expected_version - assert manifest["tool"]["uv"]["sources"]["research-repo-tools"] == {"git": TOOLING_REPOSITORY, "rev": TOOLING_REVISION} - assert package["source"] == {"git": f"{TOOLING_REPOSITORY}?rev={TOOLING_REVISION}#{TOOLING_REVISION}"} + assert "research-repo-tools" not in manifest["tool"]["uv"].get("sources", {}) + assert package["source"] == {"registry": "https://pypi.org/simple"} + assert package["sdist"]["hash"].startswith("sha256:") + assert package["wheels"] + assert all(wheel["hash"].startswith("sha256:") for wheel in package["wheels"]) # Distribution.read_text decodes UTF-8 internally and has no encoding argument. - installed = json.loads( - # nosemgrep: la-stack.python.explicit-path-text-encoding-in-tests - distribution("research-repo-tools").read_text("direct_url.json") or "{}" - ) - assert installed["url"] == TOOLING_REPOSITORY - assert installed["vcs_info"] == {"vcs": "git", "commit_id": TOOLING_REVISION, "requested_revision": TOOLING_REVISION} + # nosemgrep: la-stack.python.explicit-path-text-encoding-in-tests + installed = distribution("research-repo-tools").read_text("direct_url.json") + assert installed is None locked = {item["name"]: item["version"] for item in lock["package"]} for name, expected in shared_tool_versions().items(): assert locked[name] == version(name) == expected - identity = check_development_dependency(REPO_ROOT) - assert identity is not None - assert (identity.name, identity.version, identity.revision) == ("research-repo-tools", expected_version, TOOLING_REVISION) + assert check_development_dependency(REPO_ROOT) is None def test_release_dependency_policy_covers_tooling_and_private_comparison_workspace() -> None: @@ -94,6 +93,78 @@ def test_release_dependency_policy_covers_tooling_and_private_comparison_workspa assert comparison["package"]["publish"] is False +def test_source_build_policy_covers_native_dependencies_and_pinned_build_tools() -> None: + policy = load_policy(REPO_ROOT) + assert policy is not None + builds = policy.source_builds + assert builds is not None + assert builds.packages == ("actionlint-py", "pyyaml", "ruamel-yaml-clib") + assert builds.group == "build" + manifest = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + tools = {Requirement(item).name for item in manifest["dependency-groups"][builds.group]} + assert tools == {"cython", "hatchling", "requests", "semver", "setuptools", "wheel"} + assert {"include-group": "build"} not in manifest["dependency-groups"]["dev"] + lock = tomllib.loads((REPO_ROOT / "uv.lock").read_text(encoding="utf-8")) + locked = {item["name"]: item for item in lock["package"]} + for item in manifest["dependency-groups"][builds.group]: + requirement = Requirement(item) + assert locked[requirement.name]["version"] in requirement.specifier + assert locked[requirement.name]["source"] == {"registry": "https://pypi.org/simple"} + assert {item.platform for item in builds.external} == {"darwin-arm64", "darwin-x86_64", "linux-arm64", "linux-x86_64", "win32-arm64", "win32-x86_64"} + for item in builds.external: + assert item.package == "actionlint-py" + assert item.wheel_member == f"actionlint_py-{locked['actionlint-py']['version']}.data/scripts/{item.member}" + assert item.member == ("actionlint.exe" if item.platform.startswith("win32-") else "actionlint") + (selection,) = policy.selections + assert selection.name == "tooling" + assert selection.source.package == "research-repo-tools" + + +@pytest.mark.parametrize("outcome", ["development", "published", "invalid-pin", "build-failure", "proof-failure"]) +def test_ci_dependency_recipe_preserves_development_pins_and_propagates_failures(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, outcome: str) -> None: + recipe = inspect_justfile(REPO_ROOT).recipes["ci-dependencies"] + script = tmp_path / "native provenance.py" + script.write_text("\n".join("".join(line) for line in recipe["body"]), encoding="utf-8", newline="\n") + calls: list[list[str]] = [] + + def source(root: Path) -> DependencyIdentity | None: + assert root == REPO_ROOT + if outcome == "invalid-pin": + message = "development source identity differs" + raise ValueError(message) + return DependencyIdentity("research-repo-tools", TOOLING_VERSION, "git", "a" * 40) if outcome == "development" else None + + def invoke(arguments: list[str]) -> int: + calls.append(arguments) + return 17 if (outcome, arguments[-1]) in {("build-failure", "dependency-builds"), ("proof-failure", "dependencies")} else 0 + + monkeypatch.chdir(REPO_ROOT) + monkeypatch.setattr("research_repo_tools.dependency_provenance.check_development_dependency", source) + monkeypatch.setattr("research_repo_tools.cli.main", invoke) + fetches: list[tuple[str, list[str]]] = [] + + def fetch(executable: str, arguments: list[str], *, cwd: Path, timeout: int) -> None: + assert cwd == REPO_ROOT + assert timeout == 600 + fetches.append((executable, arguments)) + + monkeypatch.setattr("research_repo_tools.process.run_safe_command", fetch) + if outcome == "invalid-pin": + with pytest.raises(ValueError, match="development source identity differs"): + runpy.run_path(str(script)) + elif outcome.endswith("-failure"): + with pytest.raises(SystemExit) as stopped: + runpy.run_path(str(script)) + assert stopped.value.code == 17 + else: + runpy.run_path(str(script)) + expected = [] if outcome in {"development", "invalid-pin"} else [["release", "dependency-builds"]] + if outcome in {"published", "proof-failure"}: + expected.append(["release", "dependencies"]) + assert calls == expected + assert fetches == ([] if outcome in {"development", "invalid-pin"} else [("cargo", ["fetch", "--locked"])]) + + @pytest.mark.parametrize( "command", [ @@ -106,6 +177,14 @@ def test_development_pin_cannot_enter_release_work(command: list[str], tmp_path: """The installed public gate rejects before reading plans or creating evidence.""" for name in ("pyproject.toml", "uv.lock", "dependency-sources.toml"): shutil.copyfile(REPO_ROOT / name, tmp_path / name) + # Inject an explicit development override into this negative fixture even + # though the real consumer now uses the published supplier. + manifest = tmp_path / "pyproject.toml" + source = manifest.read_text(encoding="utf-8") + manifest.write_text( + source + '\n[tool.uv.sources]\nresearch-repo-tools = { git = "https://github.com/acgetchell/research-repo-tools", rev = "' + "a" * 40 + '" }\n', + encoding="utf-8", + ) before = {path.name: path.read_bytes() for path in tmp_path.iterdir()} assert main(["--root", str(tmp_path), *command]) == 1 diagnostic = capsys.readouterr().err @@ -114,22 +193,31 @@ def test_development_pin_cannot_enter_release_work(command: list[str], tmp_path: assert {path.name: path.read_bytes() for path in tmp_path.iterdir()} == before -def test_isolated_release_runtime_uses_the_same_commit_outside_checkout(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_isolated_release_runtime_uses_the_same_release_outside_checkout(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Exercise the actual trusted runtime on each native CI platform.""" - expected = version("research-repo-tools") + expected = TOOLING_VERSION source = (REPO_ROOT / "justfile").read_text(encoding="utf-8") - assert source.count(f'[script("uvx", "--python", "3.15", "--from", "{TOOLING_REQUIREMENT}", "python", "-I")]') == 3 + assert ( + source.count(f'[script("uvx", "--no-config", "--isolated", "--managed-python", "--python", "3.15", "--from", "{TOOLING_REQUIREMENT}", "python", "-I")]') + == 3 + ) (tmp_path / "research_repo_tools.py").write_text('raise RuntimeError("untrusted checkout import")\n', encoding="utf-8") (tmp_path / "tooling").mkdir() + for name in ("pyproject.toml", "uv.lock", ".python-version", "rust-toolchain.toml"): + (tmp_path / name).write_bytes((REPO_ROOT / name).read_bytes()) (tmp_path / "justfile").write_bytes((REPO_ROOT / "justfile").read_bytes()) (tmp_path / "tooling/release_candidate.py").write_text( - "import json, sys, research_repo_tools\nfrom importlib.metadata import distribution\n" + "import json, os, sys, research_repo_tools\nfrom importlib.metadata import distribution\n" 'd = distribution("research-repo-tools")\n' - 'print(json.dumps([d.version, research_repo_tools.__file__, json.loads(d.read_text("direct_url.json")), sys.argv[1:]]))\n', + 'print(json.dumps([d.version, research_repo_tools.__file__, d.read_text("direct_url.json"), sys.argv[1:], os.environ["PATH"], sys.flags.isolated]))\n', encoding="utf-8", newline="\n", ) monkeypatch.setenv("PYTHONPATH", str(tmp_path)) + outside = tmp_path.parent / "external tools with spaces" + outside.mkdir(exist_ok=True) + alias = tmp_path / "tooling" / ".." + monkeypatch.setenv("PATH", os.pathsep.join((os.environ["PATH"], str(tmp_path / ".venv" / "bin"), str(alias / "bin"), ".", "", str(outside)))) result = run_command( "just", [ @@ -146,12 +234,16 @@ def test_isolated_release_runtime_uses_the_same_commit_outside_checkout(tmp_path timeout=180, cwd=tmp_path, ) - installed_version, location, provenance, arguments = json.loads(result.stdout) + installed_version, location, provenance, arguments, search_path, isolated = json.loads(result.stdout) assert installed_version == expected - assert provenance["vcs_info"]["commit_id"] == TOOLING_REVISION + assert provenance is None assert not Path(location).resolve().is_relative_to(REPO_ROOT) assert not Path(location).resolve().is_relative_to(tmp_path) assert arguments == ["receive", str(tmp_path / "output with spaces"), "--incoming", str(tmp_path / "input with spaces")] + assert isolated == 1 + paths = search_path.split(os.pathsep) + assert str(outside.resolve()) in paths + assert all(Path(entry).is_absolute() and not Path(entry).resolve().is_relative_to(tmp_path.resolve()) for entry in paths) @pytest.fixture @@ -198,6 +290,16 @@ def test_update_recipes_sequence_and_preserve_coupled_exclusions(consumer: Path, assert calls == WORKFLOWS[recipe] +@pytest.mark.parametrize("arguments", [[], ["--apply"]]) +def test_source_transition_recipe_previews_by_default_and_forwards_explicit_apply(consumer: Path, arguments: list[str]) -> None: + result = run_command( + "just", ["--justfile", str(consumer / "recipes.just"), "--working-directory", str(consumer), "dependency-source", "tooling", *arguments], cwd=consumer + ) + assert result.returncode == 0, result.stderr + calls = [json.loads(line) for line in (consumer / "calls.jsonl").read_text(encoding="utf-8").splitlines()] + assert calls == [[*CHECKED, "uv", "run", "--locked", "--no-sync", "research-repo-tools", "deps", "source", "tooling", *arguments]] + + @pytest.mark.parametrize("step", range(1, len(WORKFLOWS["update"]) + 1)) def test_update_failure_stops_later_steps(consumer: Path, monkeypatch: pytest.MonkeyPatch, step: int) -> None: monkeypatch.setenv("UPDATE_FAIL_STEP", str(step)) @@ -285,18 +387,14 @@ def test_python_update_preserves_shared_pin_and_tools_with_explicit_dev_sync(tmp registry = tmp_path / "wheels" registry.mkdir() original = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") - # This fixture tests registry updates against inert local wheels. The - # development Git source has separate identity coverage above and cannot - # resolve in the fixture's deliberately empty offline cache. - override = f'[tool.uv.sources]\nresearch-repo-tools = {{ git = "{TOOLING_REPOSITORY}", rev = "{TOOLING_REVISION}" }}\n' - assert original.count(override) == 1 - source = original.replace(override, "").replace("package = false", "package = false\ndefault-groups = []") + source = original.replace("package = false", "package = false\ndefault-groups = []") manifest = root / "pyproject.toml" manifest.write_text(source, encoding="utf-8") before = tomllib.loads(source) groups = before["dependency-groups"] shared = shared_tool_versions() for requirement in [ + *groups["build"], *groups["tooling"], *(entry for entry in groups["dev"] if isinstance(entry, str)), *(f"{tool}=={expected}" for tool, expected in shared.items()), @@ -323,6 +421,7 @@ def test_python_update_preserves_shared_pin_and_tools_with_explicit_dev_sync(tmp after = tomllib.loads(manifest.read_text(encoding="utf-8")) assert "yamllint==99.0.0" in after["dependency-groups"]["dev"] assert after["dependency-groups"]["tooling"] == groups["tooling"] + assert after["dependency-groups"]["build"] == groups["build"] assert after["tool"]["research-repo-tools"] == before["tool"]["research-repo-tools"] assert after["tool"]["uv"] == before["tool"]["uv"] run_command("uv", ["lock", "--upgrade"], cwd=root) diff --git a/tooling/README.md b/tooling/README.md index 1939148..2ef8821 100644 --- a/tooling/README.md +++ b/tooling/README.md @@ -44,7 +44,11 @@ exit alone also accepts a broken import or shell. When quiet mode hides diagnostics, retain the verifier's arguments, exit status and diagnostic in a fixture-owned result file. -The candidate and native evidence recipes use Just's isolated uvx/Python script interpreter. +The candidate and native evidence recipes use Just's isolated uvx/Python script interpreter +and the shared `isolated_release_runtime` context manager. Asset operations use +the shared `release --isolated-runtime` CLI. The supplier owns managed-tool +selection, checkout-path exclusion, and environment restoration; local recipes +only pass release arguments or invoke the candidate controller. MSYS applies shell-like argument expansion when started by a native Windows program, which can corrupt JSON passed as Bash positional arguments; see [MSYS2 #522](https://github.com/msys2/MSYS2-packages/issues/522). The integration @@ -66,27 +70,74 @@ Other platforms resolve Semgrep 1.184.0 or newer within the shared range. ## Shared capability adoption -This development branch tests upstream commit -`cd8594f658b5bf3ade063a685834565cc62e0cb3` through an exact uv Git source override. -The package metadata still says 0.2.6; integration checks verify the manifest, -lock and installed commit, including the isolated `uvx --python 3.15` runtimes. -Advance the full commit deliberately after upstream changes; do not float on -`main`. Before an actual release, replace the override and isolated runtime pins -with the supplying published version, resolve/sync the lock and revalidate all -affected evidence. The configured `release.published-dependencies` policy uses +The tooling group and isolated `uvx --python 3.15` runtimes use the published +`research-repo-tools==0.2.8` package. Integration checks verify the manifest, +registry lock artifacts and installed version, including the isolated runtimes. +For development rehearsal, an exact full-commit uv Git source override remains +supported; keep the isolated runtime pins aligned and do not float on `main`. +Before an actual release, replace any override and isolated Git pins with the +supplying published version, resolve/sync the lock and revalidate affected +evidence. The configured `release.published-dependencies` policy uses the shared implementation from [research-repo-tools#193](https://github.com/acgetchell/research-repo-tools/issues/193). `just release-dependencies` checks that transition before release work. It -deliberately rejects this development pin; ordinary checks and bounded -acquisition remain available. Release evidence and publication enforce the +rejects development pins; ordinary checks and bounded acquisition remain +available during rehearsal. Release evidence and publication enforce the same policy again, including the actual isolated runtime. +The source-built dependencies `actionlint-py`, `pyyaml`, and `ruamel-yaml-clib` +require native preparation with `just release-dependency-builds` before +`just release-dependencies`. The separate locked `build` group supplies their +build tools without adding those tools to `dev`. `dependency-sources.toml` +also pins actionlint's downloaded executable archives for each supported +platform; update those hashes and wheel member paths with `actionlint-py`. +The shared builder verifies the published sources and build tools, builds and +installs native wheels, then verifies the complete installed environment. + +Receipts and wheels remain outside the checkout in +`~/.research-repo-tools/source-builds`. Each machine prepares its own receipts; +never copy their signing keys into CI artifacts or caches. Changed manifests, +locks, source policy, interpreter, or builder invalidate them. Rebuild before +the next release check after such changes. These receipts establish observed +source/build provenance, not reproducible builds or publisher-signed wheels. +See the supplier's [source-build contract](https://github.com/acgetchell/research-repo-tools/blob/v0.2.8/README.md#source-built-python-dependencies). + +Native CI runs `just ci-dependencies` after its ordinary checks and includes +the provenance step in signed release evidence. A verified exact development +tooling pin skips this release-only preparation; invalid pins and failed builds +or verification still fail. Release jobs with fresh project environments prepare +their own receipts before acquisition/acceptance verification. Local `just ci` +retains its ordinary validation scope. + Normal `just update` uses the supplier's exact Git-source support from [research-repo-tools#201](https://github.com/acgetchell/research-repo-tools/pull/201), which fixes [#200](https://github.com/acgetchell/research-repo-tools/issues/200). -The updater preserves the included tooling group, immutable source commit and -inherited tool versions while updating unrelated dependencies. Keep the source -mapping throughout maintenance; release operations still require publication. +The updater preserves the included tooling group's exact supplier pin, any +immutable source commit and inherited tool versions while updating unrelated +dependencies. Upgrading a published supplier uses the target release's adopter +outside the old project environment. For the current release: + +```bash +uvx --no-config --isolated --managed-python --python 3.15 --from research-repo-tools==0.2.8 research-repo-tools toolchain adopt --dry-run +uvx --no-config --isolated --managed-python --python 3.15 --from research-repo-tools==0.2.8 research-repo-tools toolchain adopt --apply +``` + +The adopter reconciles requirements, lock and environment. Align the declared +source in `dependency-sources.toml`, isolated recipe/workflow pins and consumer +identity tests with that target, then run the normal checks. `just update` does +not perform this supplier migration, including when run by v0.2.8. +For an exact Git development pin, v0.2.8 also supports explicit attested +[promotion](https://github.com/acgetchell/research-repo-tools/blob/v0.2.8/docs/UPDATING_DEPENDENCIES.md#promoting-an-exact-development-pin); +ordinary updates continue to preserve the pin. + +For a deliberate source transition, edit `[sources.tooling]` in +`dependency-sources.toml`, then run `just dependency-source tooling` to preview +the shared resolver's manifest/lock changes. Add `--apply` only to apply that +selection. A published source uses `version`; a development source uses +`github`, a full `commit`, and `directory` instead. The helper supports both +Python and Cargo selections. After applying, sync the environment and reconcile +isolated runtime pins and identity tests; the helper does not rewrite workflows +or automatically promote a development pin when a release appears. The root `dependency-sources.toml` declares both dependency graphs for the shared source-policy validation introduced by @@ -97,7 +148,7 @@ come from their registries for release. The file is retained in source exports and both benchmark harness declarations, so policy changes invalidate evidence. The capability inventory below uses the pinned CLI and -its [exact-commit contracts](https://github.com/acgetchell/research-repo-tools/blob/cd8594f658b5bf3ade063a685834565cc62e0cb3/README.md#common-workflows). +its [v0.2.8 contracts](https://github.com/acgetchell/research-repo-tools/blob/v0.2.8/README.md#common-workflows). The shared updater installs and verifies candidate Cargo tools before publishing new pins; no local updater implementation is needed. Native Rust algorithms, assertions, benchmark selection and release decisions @@ -205,7 +256,7 @@ The full declaration now uses inclusive `since = "v0.5.2"` suite bounds. The normally installed 0.2.6 interface was checked against the consumer's peer comparison contract. Astra recommends retaining its cross-phase different-ID restriction and migrating to paired LU suites with current-phase peer ratios. -The current development pin fixes the gate-status defect tracked in +The published v0.2.7 supplier fixes the gate-status defect tracked in [research-repo-tools#191](https://github.com/acgetchell/research-repo-tools/issues/191); the preserved schema-2 and schema-5 regressions pass against it. No permanent baseline exclusion remains; supported missing cases or failed @@ -330,11 +381,10 @@ requires final metadata; ordinary CI retains a private nonfinal metadata check. There are no `docs-version-check` or `release-package` recipe aliases. `tests/tooling/test_toolchain_integration.py` owns the package-identity check: -manifest source, exact Git lock and installed commit for this rehearsal. -Dependency updates and release tests derive their requirement from the -manifest. The shared package pin advances deliberately with its lockfile; -no sibling checkout supplies runtime code. A published release will replace the -temporary source override before consumer publication. +manifest requirement, registry lock and installed published version, including +the isolated runtime. Negative fixtures retain the development-source rejection +at release boundaries. The shared package pin advances deliberately with its +lockfile and isolated requirements; no sibling checkout supplies runtime code. Setup, managed execution, dependency updates, Markdown checks, Semgrep, security scans and opt-in CodeRabbit review also use shared commands. diff --git a/tooling/release-evidence.toml b/tooling/release-evidence.toml index 09b7b38..6d43ef7 100644 --- a/tooling/release-evidence.toml +++ b/tooling/release-evidence.toml @@ -9,17 +9,17 @@ guarantee = "signed-source" [[jobs]] name = "build (ubuntu-latest)" -steps = [ "Run CI checks" ] +steps = [ "Run CI checks", "Verify native dependency provenance" ] runner-labels = [ "ubuntu-latest" ] [[jobs]] name = "build (macos-latest)" -steps = [ "Run CI checks" ] +steps = [ "Run CI checks", "Verify native dependency provenance" ] runner-labels = [ "macos-latest" ] [[jobs]] name = "build (windows-latest)" -steps = [ "Run CI checks" ] +steps = [ "Run CI checks", "Verify native dependency provenance" ] runner-labels = [ "windows-latest" ] [[jobs]] diff --git a/uv.lock b/uv.lock index fb3c20b..3747882 100644 --- a/uv.lock +++ b/uv.lock @@ -262,6 +262,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, ] +[[package]] +name = "cython" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/ca/18e324972e5cac914816fdd7bb03e19a455b90039c277a538f7781b50c85/cython-3.3.1.tar.gz", hash = "sha256:e0e45989e821bef6aa9ae15c7d583ab5e055f7406c4b3624e49e06d24e90375a", size = 3743354, upload-time = "2026-11-01T20:12:04.773Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/37/231f00ec8f6a56519aea9cef921ae9c5d4c39edd8b5557d0de9048211004/cython-3.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:a1bfd7511fe8d83f5e681876df22878efac5cd5b2c174f272e6bf018c8fd4a91", size = 3179367, upload-time = "2026-11-01T20:12:59.906Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b1/a0ed5165b268fe2d52929e08e9e89278b6f2eeee1c49c9976ba64ef194b7/cython-3.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bce8f37f50add0432ac802cfc34e5bd16395f2918ad1eaeb50b26d02c9001014", size = 3331494, upload-time = "2026-11-01T20:13:01.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/e4/4ef74af67d86067258187a24764df2d9d9bf9adb0b78712862dea051eaff/cython-3.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fcb6c86dddaedd1da5d77ba2469c1a4bea52442e02d8f0653ff78667fbaaa8d9", size = 3456176, upload-time = "2026-11-01T20:13:03.833Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0e/0458355eb10a33315ede98d91a40b5848f3302cf4e9b44c70211a592ae3a/cython-3.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:45dd533a87c9629d02d77c9a5b48cf399907a99e4bb556aebafd2ac4b68580d2", size = 2927687, upload-time = "2026-11-01T20:13:05.712Z" }, + { url = "https://files.pythonhosted.org/packages/d7/4e/548df758dbbea481ab054e062933252753b8db86757894bbb1fa84bd9292/cython-3.3.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c4e4132b03a6c6c11541f7498441224f55a9e101c615d49494983d2b10dd7669", size = 3066885, upload-time = "2026-11-01T20:13:07.719Z" }, + { url = "https://files.pythonhosted.org/packages/87/55/61840ec209f861887ca547c75c401a493be6c034c1d9ad8f9b628b607054/cython-3.3.1-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:226d791808ce95f20ab1b3589082be604b5b5b31e670bf773ef9c312d2f0a8c2", size = 3361449, upload-time = "2026-11-01T20:13:09.398Z" }, + { url = "https://files.pythonhosted.org/packages/96/b1/16e743c3fbfa6424d9c94430934b5bde3a696e7b72c1476e284fdcd1210f/cython-3.3.1-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4d1f9d6fad7a8d9750b641a941cd841ec43740a375cb2fdb934329a17fa88b36", size = 3044439, upload-time = "2026-11-01T20:13:11.239Z" }, + { url = "https://files.pythonhosted.org/packages/59/4d/5e19712c074564e696731df56242c7626001b5695d09798115d788cbd771/cython-3.3.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:73f6f40f9308e77cbdb3259155d793ac189ead6687b9a432f1b4503170cb9a13", size = 3157847, upload-time = "2026-11-01T20:13:12.87Z" }, + { url = "https://files.pythonhosted.org/packages/39/16/d6517f078e69f0e5b224b0f1109851408f240af70982d1ef61c53f3a2df9/cython-3.3.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:801730e5714a40dbab8a373af36110de8ff161ec056c855a9627cf05fbc8a6d1", size = 3049217, upload-time = "2026-11-01T20:13:14.506Z" }, + { url = "https://files.pythonhosted.org/packages/78/5c/539b55fa787a427b0dd0e2ba805c04acb108fa65c69ff76e901d44e4a2d2/cython-3.3.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:a2bdb50e9fe8974447b68edc5b0201573b06d2c5683f472a5fc08e1792051277", size = 3376191, upload-time = "2026-11-01T20:13:16.383Z" }, + { url = "https://files.pythonhosted.org/packages/8d/dc/ca2ea10c22499e00d366a9252177ec9039b8d786c85fd28260d41ec2dda5/cython-3.3.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5febfc8c52aced0cf9360e6f2304428a5fa0e66fd9aa7338d5c41f0ce26dd104", size = 3267528, upload-time = "2026-11-01T20:13:18.074Z" }, + { url = "https://files.pythonhosted.org/packages/66/89/b2fdb92a7ccedff602f362bdf3fe38d67016de8c6655bc60bffce98b923a/cython-3.3.1-cp39-abi3-win32.whl", hash = "sha256:b0265a0e186af3c38339c561507e98e2e3a0d377cdb2b0427f980138ceddd4af", size = 2590014, upload-time = "2026-11-01T20:13:19.925Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d8/3d620697d89110b58937b1297dfdafbbfec4415b11b845b46ec9a04b52b5/cython-3.3.1-cp39-abi3-win_arm64.whl", hash = "sha256:8b4391d39260efedeff2e3914e958b9ed75cb2b40f10b62bb0e9ca002658563a", size = 2693762, upload-time = "2026-11-01T20:13:21.506Z" }, + { url = "https://files.pythonhosted.org/packages/9d/55/088578d75bc8dbcbc535af04b1d596ff227ca756dd74f9d3c1c526f955d0/cython-3.3.1-py3-none-any.whl", hash = "sha256:61abc7feedd20dac6c4ff7ab3a30e17136a067b242ab9253e2b4a6d9a72c2b61", size = 1352000, upload-time = "2026-11-01T20:12:01.888Z" }, +] + [[package]] name = "exceptiongroup" version = "1.2.2" @@ -327,6 +349,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "hatchling" +version = "1.32.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pathspec" }, + { name = "pluggy" }, + { name = "tomli" }, + { name = "tomlkit" }, + { name = "trove-classifiers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/97/b5312f01a8c6daf729a9d272dd442e0c546dbcc630495788786c4b567ed0/hatchling-1.32.4.tar.gz", hash = "sha256:c4468f73144c054d2aab4ef0f0378c43b9878bf07f8ffd6b79690e970d375f07", size = 58065, upload-time = "2026-09-20T22:48:45.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/80/91f51f439c05d4ec4623c22928ce16a938d6d793bf709477830823497859/hatchling-1.32.4-py3-none-any.whl", hash = "sha256:08ecf7548fb48205e7f213d70c71e67b8271b7242093dc3f1da578b42c734a2c", size = 78748, upload-time = "2026-09-20T22:48:44.19Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -415,6 +454,14 @@ version = "0.4.6" source = { virtual = "." } [package.dev-dependencies] +build = [ + { name = "cython" }, + { name = "hatchling" }, + { name = "requests" }, + { name = "semver" }, + { name = "setuptools" }, + { name = "wheel" }, +] dev = [ { name = "actionlint-py" }, { name = "pytest-xdist" }, @@ -427,18 +474,30 @@ tooling = [ { name = "research-repo-tools", extra = ["python-tools", "semgrep"] }, ] +[package.group-requires-python] +dev = ">=3.15" +tooling = ">=3.15" + [package.metadata] [package.metadata.requires-dev] +build = [ + { name = "cython", specifier = "==3.3.1" }, + { name = "hatchling", specifier = "==1.32.4" }, + { name = "requests", specifier = "==2.34.2" }, + { name = "semver", specifier = "==3.1.1" }, + { name = "setuptools", specifier = "==84.2.1" }, + { name = "wheel", specifier = "==0.48.0" }, +] dev = [ { name = "actionlint-py", specifier = "==1.7.12.25" }, { name = "pytest-xdist", specifier = "==3.8.0" }, - { name = "research-repo-tools", extras = ["python-tools", "semgrep"], git = "https://github.com/acgetchell/research-repo-tools?rev=cd8594f658b5bf3ade063a685834565cc62e0cb3" }, + { name = "research-repo-tools", extras = ["python-tools", "semgrep"], marker = "python_full_version >= '3.15'", specifier = "==0.2.8" }, { name = "shellcheck-py", specifier = "==0.11.0.1" }, { name = "shfmt-py", specifier = "==4.2.0" }, { name = "yamllint", specifier = "==1.38.0" }, ] -tooling = [{ name = "research-repo-tools", extras = ["python-tools", "semgrep"], git = "https://github.com/acgetchell/research-repo-tools?rev=cd8594f658b5bf3ade063a685834565cc62e0cb3" }] +tooling = [{ name = "research-repo-tools", extras = ["python-tools", "semgrep"], marker = "python_full_version >= '3.15'", specifier = "==0.2.8" }] [[package]] name = "markdown-it-py" @@ -899,13 +958,17 @@ wheels = [ [[package]] name = "research-repo-tools" -version = "0.2.6" -source = { git = "https://github.com/acgetchell/research-repo-tools?rev=cd8594f658b5bf3ade063a685834565cc62e0cb3#cd8594f658b5bf3ade063a685834565cc62e0cb3" } +version = "0.2.8" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, { name = "pyyaml" }, { name = "rust-just" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/4a/24/a7dc5d7586af01a68ff21d3c108b383903579d2dcf3b7eb2de2457b15782/research_repo_tools-0.2.8.tar.gz", hash = "sha256:b727c6bb619c31419f6408c4fc2ba8fdfeeff7cfcd969d5aeacf1a3ef825073d", size = 1422462, upload-time = "2026-11-10T07:56:25.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/d6/e6aaf3e593cf4faafdad459cad71b1ed5215f9e149ddea2730e447309ab6/research_repo_tools-0.2.8-py3-none-any.whl", hash = "sha256:845d257cc829e60bc83acdb6b43dcec0da4bd32224de1d7da29e6b8e53456f2d", size = 597227, upload-time = "2026-11-10T07:56:23.721Z" }, +] [package.optional-dependencies] python-tools = [ @@ -1130,6 +1193,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/9b/71a620f72bc6203286a4a6c031d53186863acf7d1af4f27c697ef176cbdf/semgrep-1.184.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-win_amd64.whl", hash = "sha256:3c86b2961b4663d1338fb965de038c7869571661971b78e18e9346ab57e1e20a", size = 59524393, upload-time = "2026-11-05T19:17:56.788Z" }, ] +[[package]] +name = "semver" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/50/2932d6434608c7d954d0d4f4b71b697260e3b40c802425fc84c1eed12440/semver-3.1.1.tar.gz", hash = "sha256:149b26cabada6f63212f97e94505d21578cb1d280cf714827f328d5e70765fea", size = 292174, upload-time = "2026-11-09T11:38:46.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/1d/793b054cbd0a2e0b4694dacc22bad066d6635ce85dd6cdd1f98d95169e9b/semver-3.1.1-py3-none-any.whl", hash = "sha256:c00cf7dac88a352bc6a4a997d6ac0bf95ba835c589bc0c54f7b76f9e2a45123a", size = 18905, upload-time = "2026-11-09T11:38:44.556Z" }, +] + +[[package]] +name = "setuptools" +version = "84.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/b3/c19426f783614d625699a4b247faf628ea9ab1db228674491767b417f9e1/setuptools-84.2.1.tar.gz", hash = "sha256:3ff8b3b5492a0aa66960f4419365a86b84e013070d4313e4d4a6894f5d64d6bc", size = 1230289, upload-time = "2026-10-20T19:01:37.742Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/55/6dc6d6bb953bdfc52519a0059592c52b6e3b8ad51cf8256dffceb55b0610/setuptools-84.2.1-py3-none-any.whl", hash = "sha256:ca140b45d5325a9478f880e351484f62fef15f336462e449ca0c03c873d589c2", size = 876263, upload-time = "2026-10-20T19:01:35.678Z" }, +] + [[package]] name = "shellcheck-py" version = "0.11.0.1" @@ -1216,6 +1297,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765, upload-time = "2026-10-07T12:23:36.875Z" }, ] +[[package]] +name = "tomlkit" +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/72/fc01563df95c189a9f65850096bdc205bc2fe37da6c740cd64afaf18d07e/tomlkit-0.15.2.tar.gz", hash = "sha256:83e32755ba1d20d9340bd069ee97e9f6232ba2346b63ffcba425de01f7e327a6", size = 190522, upload-time = "2026-10-16T04:07:08.166Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/46/7d84138a60a8bd796825b2d38ebe92d0d16501ca57d501b7c5d677401bbc/tomlkit-0.15.2-py3-none-any.whl", hash = "sha256:05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437", size = 52225, upload-time = "2026-10-16T04:07:09.216Z" }, +] + +[[package]] +name = "trove-classifiers" +version = "2026.9.21.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/93/af436dfaa845cab5d96f0adbc1e4f3730532d37fa249e4eb796fb1d7fc82/trove_classifiers-2026.9.21.13.tar.gz", hash = "sha256:0a9ebc8d4e2f3e8a22848c5258033035bec17a3012ac3fea16dbaa764489eb71", size = 17085, upload-time = "2026-09-21T13:29:07.301Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/81/0da8afb52a71d0a4f2bd3152357b1a441e393b286374802b9d3addab4ab5/trove_classifiers-2026.9.21.13-py3-none-any.whl", hash = "sha256:8b1ff4f9c191b1040b71c37f1e445ab99732911e3cd91de52838453a854d7a17", size = 14232, upload-time = "2026-09-21T13:29:06.123Z" }, +] + [[package]] name = "ty" version = "0.0.84" @@ -1296,6 +1395,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/09/78/533ef890536e5ba0fd4f7df37482b5800ecaaceae9afc30978a1a7f88ff1/wcmatch-8.5.2-py3-none-any.whl", hash = "sha256:17d3ad3758f9d0b5b4dedc770b65420d4dac62e680229c287bf24c9db856a478", size = 39397, upload-time = "2024-05-15T12:51:06.2Z" }, ] +[[package]] +name = "wheel" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/20/50ed6bdf27dec98b568a8ae25dc599f35baa3d9709f9e83fd1edb56b9a90/wheel-0.48.0.tar.gz", hash = "sha256:94800765601e9171bf5d58d066e640662842bcedcbab982b2c90787a2c987322", size = 66471, upload-time = "2026-08-11T22:02:27.327Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/29/69cfbb602cd91690c55d38ba9fe53e6a7e76a6fa647bf38f19c138d25449/wheel-0.48.0-py3-none-any.whl", hash = "sha256:3217dcc807155e45db462d7ef2431f5ddda0d7273b700d05a67b271ceb1287ab", size = 33320, upload-time = "2026-08-11T22:02:26.1Z" }, +] + [[package]] name = "wrapt" version = "2.5.2" From 2ce6ebc11787b684c7aaf22e9a40debe4c35d5e4 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Tue, 10 Nov 2026 07:37:16 -0800 Subject: [PATCH 2/9] fix: support verified tooling pins and cold native CI - Derive isolated supplier requirements from the manifest and use shared provenance checks for exact Git pins as well as published releases. - Keep registry artifact checks and release-only rejection of Git sources, including fixtures that start from either dependency mode. - Allow 120 minutes for cold native tool setup, the complete CI suite, and dependency provenance verification on hosted runners. Refs #333 --- .github/workflows/ci.yml | 4 +- tests/tooling/test_release_flow.py | 3 +- tests/tooling/test_release_publishing.py | 7 +- tests/tooling/test_toolchain_integration.py | 105 ++++++++++++++++---- tooling/README.md | 4 + 5 files changed, 96 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1502aa1..6e37198 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,9 @@ env: jobs: build: runs-on: ${{ matrix.os }} - timeout-minutes: 60 + # Cold managed-tool compilation takes about 40 minutes on macOS/Windows; + # leave time for the complete CI suite and native provenance verification. + timeout-minutes: 120 strategy: fail-fast: false matrix: diff --git a/tests/tooling/test_release_flow.py b/tests/tooling/test_release_flow.py index 9dc7ab1..e15664b 100644 --- a/tests/tooling/test_release_flow.py +++ b/tests/tooling/test_release_flow.py @@ -17,6 +17,7 @@ from research_repo_tools.process import run_command from tests.tooling.test_release_evidence import COMMIT, POLICY, REPOSITORY, EvidenceProvider +from tests.tooling.test_toolchain_integration import TOOLING_REQUIREMENT ROOT = Path(__file__).resolve().parents[2] TAG = "v0.5.42" @@ -245,7 +246,7 @@ def test_draft_workflow_keeps_the_writer_isolated_and_recovery_explicit() -> Non # Custom shells use .NET argument parsing; single quotes are not delimiters. command = shlex.split(step["shell"], posix=False) assert command[:9] == ["uv", "run", "--no-config", "--no-project", "--isolated", "--no-cache", "--python", "3.15", "--with"] - assert command[9] == '"research-repo-tools==0.2.8"' + assert command[9] == f'"{TOOLING_REQUIREMENT}"' assert command[9].endswith('"') assert command[10:] == ["python", "-I", '"{0}"'] assert step["working-directory"] == "${{ runner.temp }}" diff --git a/tests/tooling/test_release_publishing.py b/tests/tooling/test_release_publishing.py index 247e339..0f20393 100644 --- a/tests/tooling/test_release_publishing.py +++ b/tests/tooling/test_release_publishing.py @@ -20,6 +20,7 @@ from research_repo_tools.process import run_command from tests.tooling.test_release_evidence import PIN_JSON, EvidenceProvider, install_clean_checkout +from tests.tooling.test_toolchain_integration import TOOLING_REQUIREMENT REPO_ROOT = Path(__file__).resolve().parents[2] WORKFLOW = REPO_ROOT / ".github/workflows/publish-crates.yml" @@ -271,7 +272,7 @@ def run_evidence_fixture() -> int: def evidence_runner(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Keep real workflow shell, Just, uv, Python, verifier and output writer.""" source = (REPO_ROOT / "justfile").read_text(encoding="utf-8") - runtime = '[script("uvx", "--no-config", "--isolated", "--managed-python", "--python", "3.15", "--from", "research-repo-tools==0.2.8", "python", "-I")]' + runtime = f'[script("uvx", "--no-config", "--isolated", "--managed-python", "--python", "3.15", "--from", "{TOOLING_REQUIREMENT}", "python", "-I")]' assert source.count(runtime) == 3 # Run the exact recipe body/argument transport with the locked test runtime. # The separate isolated-runtime smoke and pin test own uvx installation. @@ -538,7 +539,7 @@ def test_main_ci_seals_only_complete_native_and_cargo_validation() -> None: workflow = yaml.safe_load((REPO_ROOT / ".github/workflows/ci.yml").read_bytes()) jobs = workflow["jobs"] assert jobs["build"]["strategy"]["matrix"]["os"] == ["ubuntu-latest", "macos-latest", "windows-latest"] - assert jobs["build"]["timeout-minutes"] == 60 + assert jobs["build"]["timeout-minutes"] == 120 assert jobs["build"]["steps"][-2:] == [ {"name": "Run CI checks", "run": "just ci"}, {"name": "Verify native dependency provenance", "run": "just ci-dependencies"}, @@ -560,7 +561,7 @@ def test_main_ci_seals_only_complete_native_and_cargo_validation() -> None: "--python", "3.15", "--from", - "research-repo-tools==0.2.8", + TOOLING_REQUIREMENT, "python", "-I", "-", diff --git a/tests/tooling/test_toolchain_integration.py b/tests/tooling/test_toolchain_integration.py index a05328c..98559a8 100644 --- a/tests/tooling/test_toolchain_integration.py +++ b/tests/tooling/test_toolchain_integration.py @@ -20,13 +20,33 @@ from research_repo_tools.just_inspect import inspect_justfile from research_repo_tools.process import run_command from research_repo_tools.python_tools import TOOLS, versions as shared_tool_versions +from research_repo_tools.toml_source import remove_value, set_value if TYPE_CHECKING: import subprocess REPO_ROOT = Path(__file__).resolve().parents[2] -TOOLING_VERSION = "0.2.8" -TOOLING_REQUIREMENT = f"research-repo-tools=={TOOLING_VERSION}" + + +def tooling_requirement(root: Path) -> str: + """Select the declared supplier after RRT verifies any development source.""" + document = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) + (raw,) = document["dependency-groups"]["tooling"] + requirement = Requirement(raw) + (pin,) = requirement.specifier + assert requirement.name == "research-repo-tools" + assert requirement.url is None + assert requirement.marker is None + assert pin.operator == "==" + assert "*" not in pin.version + development = check_development_dependency(root, document=document) + if development is not None: + return f"{requirement.name} @ git+{development.source}@{development.revision}" + return f"{requirement.name}{requirement.specifier}" + + +TOOLING_VERSION = version("research-repo-tools") +TOOLING_REQUIREMENT = tooling_requirement(REPO_ROOT) CHECKED = ["run", "--locked", "--no-sync", "--no-python-downloads", "research-repo-tools", "toolchain", "run", "--"] UPDATER = ["run", "--locked", "--only-group", "tooling", "--inexact", "research-repo-tools"] UV_UPDATE = ["run", "--no-config", "--no-sync", "--no-python-downloads", "research-repo-tools", "deps", "update-uv"] @@ -50,8 +70,8 @@ } -def test_tooling_release_matches_manifest_lock_and_installation() -> None: - """Require the published supplier throughout the normal installed consumer.""" +def test_tooling_selection_matches_manifest_lock_and_installation() -> None: + """Require the declared registry release or verified immutable Git supplier.""" expected_version = TOOLING_VERSION manifest = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) assert manifest["project"]["dependencies"] == [] @@ -61,23 +81,53 @@ def test_tooling_release_matches_manifest_lock_and_installation() -> None: assert {"include-group": "tooling"} in manifest["dependency-groups"]["dev"] assert manifest["tool"]["research-repo-tools"]["toolchain"]["inherit-python-tools"] is True assert not {Requirement(item).name for item in manifest["dependency-groups"]["dev"] if isinstance(item, str)} & TOOLS - assert version("research-repo-tools") == expected_version lock = tomllib.loads((REPO_ROOT / "uv.lock").read_text(encoding="utf-8")) package = next(item for item in lock["package"] if item["name"] == "research-repo-tools") assert package["version"] == expected_version - assert "research-repo-tools" not in manifest["tool"]["uv"].get("sources", {}) - assert package["source"] == {"registry": "https://pypi.org/simple"} - assert package["sdist"]["hash"].startswith("sha256:") - assert package["wheels"] - assert all(wheel["hash"].startswith("sha256:") for wheel in package["wheels"]) - # Distribution.read_text decodes UTF-8 internally and has no encoding argument. - # nosemgrep: la-stack.python.explicit-path-text-encoding-in-tests - installed = distribution("research-repo-tools").read_text("direct_url.json") - assert installed is None + development = check_development_dependency(REPO_ROOT) + if development is None: + assert "research-repo-tools" not in manifest["tool"]["uv"].get("sources", {}) + assert package["source"] == {"registry": "https://pypi.org/simple"} + assert package["sdist"]["hash"].startswith("sha256:") + assert package["wheels"] + assert all(wheel["hash"].startswith("sha256:") for wheel in package["wheels"]) + # Distribution.read_text decodes UTF-8 internally and has no encoding argument. + # nosemgrep: la-stack.python.explicit-path-text-encoding-in-tests + installed = distribution("research-repo-tools").read_text("direct_url.json") + assert installed is None + else: + # The shared checker binds declaration, lock and both installed runtimes. + assert development.version == expected_version + assert development.revision is not None locked = {item["name"]: item["version"] for item in lock["package"]} for name, expected in shared_tool_versions().items(): assert locked[name] == version(name) == expected - assert check_development_dependency(REPO_ROOT) is None + + +@pytest.mark.parametrize("source", ["registry", "git", "invalid"]) +def test_tooling_requirement_preserves_source_selection_and_verification(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, source: str) -> None: + (tmp_path / "pyproject.toml").write_text( + '[dependency-groups]\ntooling = ["research-repo-tools[python-tools,semgrep]==0.2.8"]\n', encoding="utf-8", newline="\n" + ) + calls: list[Path] = [] + + def verify(root: Path, *, document: dict[str, object]) -> DependencyIdentity | None: + calls.append(root) + assert document == {"dependency-groups": {"tooling": ["research-repo-tools[python-tools,semgrep]==0.2.8"]}} + if source == "invalid": + message = "installed Git provenance differs" + raise ValueError(message) + return DependencyIdentity("research-repo-tools", "0.2.8", "https://github.com/acgetchell/research-repo-tools", "a" * 40) if source == "git" else None + + monkeypatch.setattr(sys.modules[__name__], "check_development_dependency", verify) + if source == "invalid": + with pytest.raises(ValueError, match="installed Git provenance differs"): + tooling_requirement(tmp_path) + else: + git_requirement = "research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@" + "a" * 40 + expected = git_requirement if source == "git" else "research-repo-tools==0.2.8" + assert tooling_requirement(tmp_path) == expected + assert calls == [tmp_path] def test_release_dependency_policy_covers_tooling_and_private_comparison_workspace() -> None: @@ -181,10 +231,8 @@ def test_development_pin_cannot_enter_release_work(command: list[str], tmp_path: # though the real consumer now uses the published supplier. manifest = tmp_path / "pyproject.toml" source = manifest.read_text(encoding="utf-8") - manifest.write_text( - source + '\n[tool.uv.sources]\nresearch-repo-tools = { git = "https://github.com/acgetchell/research-repo-tools", rev = "' + "a" * 40 + '" }\n', - encoding="utf-8", - ) + override = '{ git = "https://github.com/acgetchell/research-repo-tools", rev = "' + "a" * 40 + '" }' + manifest.write_text(set_value(source, "tool.uv.sources", "research-repo-tools", override), encoding="utf-8") before = {path.name: path.read_bytes() for path in tmp_path.iterdir()} assert main(["--root", str(tmp_path), *command]) == 1 diagnostic = capsys.readouterr().err @@ -193,7 +241,7 @@ def test_development_pin_cannot_enter_release_work(command: list[str], tmp_path: assert {path.name: path.read_bytes() for path in tmp_path.iterdir()} == before -def test_isolated_release_runtime_uses_the_same_release_outside_checkout(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_isolated_release_runtime_uses_the_same_selection_outside_checkout(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Exercise the actual trusted runtime on each native CI platform.""" expected = TOOLING_VERSION source = (REPO_ROOT / "justfile").read_text(encoding="utf-8") @@ -207,7 +255,9 @@ def test_isolated_release_runtime_uses_the_same_release_outside_checkout(tmp_pat (tmp_path / name).write_bytes((REPO_ROOT / name).read_bytes()) (tmp_path / "justfile").write_bytes((REPO_ROOT / "justfile").read_bytes()) (tmp_path / "tooling/release_candidate.py").write_text( - "import json, os, sys, research_repo_tools\nfrom importlib.metadata import distribution\n" + "import json, os, sys, research_repo_tools\nfrom importlib.metadata import distribution\nfrom pathlib import Path\n" + "from research_repo_tools.dependency_provenance import check_development_dependency\n" + "check_development_dependency(Path.cwd())\n" 'd = distribution("research-repo-tools")\n' 'print(json.dumps([d.version, research_repo_tools.__file__, d.read_text("direct_url.json"), sys.argv[1:], os.environ["PATH"], sys.flags.isolated]))\n', encoding="utf-8", @@ -236,7 +286,14 @@ def test_isolated_release_runtime_uses_the_same_release_outside_checkout(tmp_pat ) installed_version, location, provenance, arguments, search_path, isolated = json.loads(result.stdout) assert installed_version == expected - assert provenance is None + development = check_development_dependency(REPO_ROOT) + if development is None: + assert provenance is None + else: + assert json.loads(provenance) == { + "url": development.source, + "vcs_info": {"vcs": "git", "commit_id": development.revision, "requested_revision": development.revision}, + } assert not Path(location).resolve().is_relative_to(REPO_ROOT) assert not Path(location).resolve().is_relative_to(tmp_path) assert arguments == ["receive", str(tmp_path / "output with spaces"), "--incoming", str(tmp_path / "input with spaces")] @@ -387,6 +444,10 @@ def test_python_update_preserves_shared_pin_and_tools_with_explicit_dev_sync(tmp registry = tmp_path / "wheels" registry.mkdir() original = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") + # This offline updater fixture supplies registry wheels; actual Git identity + # is checked above against the real installed supplier and isolated runtime. + if "research-repo-tools" in tomllib.loads(original)["tool"]["uv"].get("sources", {}): + original = remove_value(original, "tool.uv.sources", "research-repo-tools") source = original.replace("package = false", "package = false\ndefault-groups = []") manifest = root / "pyproject.toml" manifest.write_text(source, encoding="utf-8") diff --git a/tooling/README.md b/tooling/README.md index 2ef8821..4476521 100644 --- a/tooling/README.md +++ b/tooling/README.md @@ -75,6 +75,10 @@ The tooling group and isolated `uvx --python 3.15` runtimes use the published registry lock artifacts and installed version, including the isolated runtimes. For development rehearsal, an exact full-commit uv Git source override remains supported; keep the isolated runtime pins aligned and do not float on `main`. +Consumer identity tests derive the selected requirement from the manifest and +use RRT to verify Git declarations against the lock and installed provenance. +Both registry and verified Git selections can run full `just ci`; only the +published selection is eligible for release evidence and publication. Before an actual release, replace any override and isolated Git pins with the supplying published version, resolve/sync the lock and revalidate affected evidence. The configured `release.published-dependencies` policy uses From 1548f590109547f6d8ff51dfbddcf4eab4b0f9ac Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Tue, 10 Nov 2026 08:00:35 -0800 Subject: [PATCH 3/9] test: enforce dependency preparation order - Record Cargo fetching and shared provenance commands in one sequence so the CI recipe regression detects builds running before their inputs arrive. --- tests/tooling/test_toolchain_integration.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/tooling/test_toolchain_integration.py b/tests/tooling/test_toolchain_integration.py index 98559a8..fc60b49 100644 --- a/tests/tooling/test_toolchain_integration.py +++ b/tests/tooling/test_toolchain_integration.py @@ -191,12 +191,11 @@ def invoke(arguments: list[str]) -> int: monkeypatch.chdir(REPO_ROOT) monkeypatch.setattr("research_repo_tools.dependency_provenance.check_development_dependency", source) monkeypatch.setattr("research_repo_tools.cli.main", invoke) - fetches: list[tuple[str, list[str]]] = [] def fetch(executable: str, arguments: list[str], *, cwd: Path, timeout: int) -> None: assert cwd == REPO_ROOT assert timeout == 600 - fetches.append((executable, arguments)) + calls.append([executable, *arguments]) monkeypatch.setattr("research_repo_tools.process.run_safe_command", fetch) if outcome == "invalid-pin": @@ -208,11 +207,10 @@ def fetch(executable: str, arguments: list[str], *, cwd: Path, timeout: int) -> assert stopped.value.code == 17 else: runpy.run_path(str(script)) - expected = [] if outcome in {"development", "invalid-pin"} else [["release", "dependency-builds"]] + expected = [] if outcome in {"development", "invalid-pin"} else [["cargo", "fetch", "--locked"], ["release", "dependency-builds"]] if outcome in {"published", "proof-failure"}: expected.append(["release", "dependencies"]) assert calls == expected - assert fetches == ([] if outcome in {"development", "invalid-pin"} else [("cargo", ["fetch", "--locked"])]) @pytest.mark.parametrize( From 82d98dfa31342c54a401830759816230cdf0f71f Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Tue, 10 Nov 2026 10:56:23 -0800 Subject: [PATCH 4/9] ci: leave time for cold Windows provenance Allow 150 minutes for the complete native validation job. The cold Windows run used 51 minutes for setup and 62 minutes for CI, then reached the former two-hour limit while verifying Cargo dependency provenance. Update the existing workflow contract assertion for the measured budget. All native checks and release evidence requirements remain enforced. --- .github/workflows/ci.yml | 6 +++--- tests/tooling/test_release_publishing.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e37198..557424f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,9 +28,9 @@ env: jobs: build: runs-on: ${{ matrix.os }} - # Cold managed-tool compilation takes about 40 minutes on macOS/Windows; - # leave time for the complete CI suite and native provenance verification. - timeout-minutes: 120 + # Cold Windows setup took 51 minutes and CI took 62 minutes; + # leave time for native provenance verification and cache upload too. + timeout-minutes: 150 strategy: fail-fast: false matrix: diff --git a/tests/tooling/test_release_publishing.py b/tests/tooling/test_release_publishing.py index 0f20393..1041860 100644 --- a/tests/tooling/test_release_publishing.py +++ b/tests/tooling/test_release_publishing.py @@ -539,7 +539,7 @@ def test_main_ci_seals_only_complete_native_and_cargo_validation() -> None: workflow = yaml.safe_load((REPO_ROOT / ".github/workflows/ci.yml").read_bytes()) jobs = workflow["jobs"] assert jobs["build"]["strategy"]["matrix"]["os"] == ["ubuntu-latest", "macos-latest", "windows-latest"] - assert jobs["build"]["timeout-minutes"] == 120 + assert jobs["build"]["timeout-minutes"] == 150 assert jobs["build"]["steps"][-2:] == [ {"name": "Run CI checks", "run": "just ci"}, {"name": "Verify native dependency provenance", "run": "just ci-dependencies"}, From 16df76f28f38d13dfc146c357e2f162582481cca Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Tue, 10 Nov 2026 11:44:35 -0800 Subject: [PATCH 5/9] ci: split native validation into independent jobs - Prepare managed compilation caches and policy checks on each native OS, then run Rust, Python, candidate acceptance, and provenance independently. - Preserve required build check names and verify the actual native stage identities, required steps, and OS labels in signed release evidence. - Keep local CI complete and prove the hosted Python selections partition the full suite; reject failed, skipped, cancelled, and missing stages. - Validate with just ci (524 Python tests), ci-dependencies (263 published dependencies), focused workflow/evidence tests, Windows-targeted types, Markdown/spelling/docs checks, and local CodeRabbit with zero findings. --- .github/workflows/ci.yml | 73 +++++++++++++++++++--- CONTRIBUTING.md | 16 +++-- docs/RELEASING.md | 6 +- docs/dev/release-flow-integration.md | 43 ++++++++++++- justfile | 17 ++++- pyproject.toml | 1 + tests/tooling/test_performance_workflow.py | 12 +++- tests/tooling/test_release_evidence.py | 23 ++++--- tests/tooling/test_release_publishing.py | 38 +++++++++-- tests/tooling/test_validation_graph.py | 29 ++++++++- tooling/README.md | 5 +- tooling/release-evidence.toml | 72 +++++++++++++++++++-- 12 files changed, 293 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 557424f..a19ed4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,11 +26,10 @@ env: RUST_BACKTRACE: 1 jobs: - build: + prepare: runs-on: ${{ matrix.os }} - # Cold Windows setup took 51 minutes and CI took 62 minutes; - # leave time for native provenance verification and cache upload too. - timeout-minutes: 150 + # Populate managed compilation caches once before the native jobs fan out. + timeout-minutes: 90 strategy: fail-fast: false matrix: @@ -54,11 +53,69 @@ jobs: - name: Set up declared tools uses: ./.github/actions/setup-tools # zizmor: ignore[self-repository] actionlint 1.7.12 does not accept $/... - - name: Run CI checks - run: just ci + - name: Run CI policy checks + run: just ci-policy - - name: Verify native dependency provenance - run: just ci-dependencies + native: + name: native-${{ matrix.stage }} (${{ matrix.os }}) + needs: prepare + runs-on: ${{ matrix.os }} + # Fresh installations verify the shared compilation cache. A cache miss + # may still rebuild tools; it must never remove a validation stage. + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + stage: + - rust + - python + - candidate + - dependencies + env: + LA_STACK_TEST_WORKERS: ${{ matrix.stage == 'candidate' && '1' || '4' }} + steps: + - name: Disable Git autocrlf on Windows + if: matrix.os == 'windows-latest' + shell: pwsh + run: | + git config --global core.autocrlf false + git config --global core.eol lf + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up declared tools + uses: ./.github/actions/setup-tools # zizmor: ignore[self-repository] actionlint 1.7.12 does not accept $/... + - name: Run native validation + shell: bash + env: + CI_STAGE: ${{ matrix.stage }} + run: just "ci-$CI_STAGE" + + build: + # Preserve required-check names while requiring every native stage. + needs: [prepare, native] + if: ${{ !cancelled() }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require complete native validation + env: + PREPARE_RESULT: ${{ needs.prepare.result }} + NATIVE_RESULT: ${{ needs.native.result }} + run: | + test "$PREPARE_RESULT" = success + test "$NATIVE_RESULT" = success release-package: runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 56ecced..8629368 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -308,15 +308,23 @@ because nextest does not execute them. The test, example, and benchmark buckets retain their execution or compile-contract roles because ordinary compilation does not execute Clippy lints. -Native CI retains the same `build (OS)` and `Run CI checks` identities. Ordinary -setup caches only managed Cargo compilation by OS, architecture and exact tool -declarations; exact-version tool installations are freshly verified. GitHub's +Hosted CI prepares each native OS and runs `just ci-policy`, then starts separate +`just ci-rust`, `just ci-python`, `just ci-candidate`, and `just ci-dependencies` +jobs. The two Python jobs partition the complete suite: candidate capture and +acceptance has a dedicated runner, and every other test runs in `ci-python`. +The existing `build (OS)` required-check names are aggregate gates: each requires +every preparation and native job to succeed. Release evidence records the actual +native jobs, their validation steps, and their OS labels. + +Preparation populates the managed compilation cache before the native jobs start. +Ordinary setup caches only managed Cargo compilation by OS, architecture and exact +tool declarations; exact-version tool installations are freshly verified. GitHub's cache ref scopes separate PR entries from protected main. Release packaging and publication keep caches disabled. The shared local build cache defaults to the tooling home's `cargo-build//`; an absolute `RESEARCH_REPO_TOOLS_CARGO_BUILD_HOME` selects separate compilation storage. This cache does not replace project builds or validation. See the -[integration record](docs/dev/release-flow-integration.md) for graph ownership, +[integration record](docs/dev/release-flow-integration.md#hosted-ci-stages) for graph ownership, baseline timings and remaining native/published-package evidence. `la-stack` intentionally has no notebook validation bucket: the repository has diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 2f5863d..20fe9e3 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -498,8 +498,10 @@ and checks eligible evidence automatically; pending results stop it with a retry diagnostic. Use GitHub's Actions page or the [manual inspection commands](#manual-context-and-inspection) when troubleshooting. -Require the complete run: all three native `build` jobs, default/exact Cargo -packaging in `release-package`, and signed `release-evidence`. A development +Require the complete run: preparation and all four native validation stages on +each OS, the three `build` aggregate gates, default/exact Cargo packaging in +`release-package`, and signed `release-evidence`. The evidence policy checks +the actual native jobs and runner labels, not just the aggregate gates. A development supplier pin skips signing and cannot provide eligible release evidence. Inspect the independent SARIF checks for this exact commit too: diff --git a/docs/dev/release-flow-integration.md b/docs/dev/release-flow-integration.md index d3dff7d..703d8f4 100644 --- a/docs/dev/release-flow-integration.md +++ b/docs/dev/release-flow-integration.md @@ -1,12 +1,15 @@ # Release and validation integration -Implementation record for [#326](https://github.com/acgetchell/la-stack/issues/326), +Historical implementation record for [#326](https://github.com/acgetchell/la-stack/issues/326), [#330](https://github.com/acgetchell/la-stack/issues/330) and [#338](https://github.com/acgetchell/la-stack/issues/338). The starting source is `7c3a3625220773369c089e64e568a979abc0bb7f`. The authorized supplying revision is research-repo-tools `cd8594f658b5bf3ade063a685834565cc62e0cb3` (PRs #207 and #209). Its installed metadata remains 0.2.6; the Git commit, manifest, lock and isolated runtime assertions distinguish this development rehearsal from publication. +The [hosted CI stages](#hosted-ci-stages) section records the subsequent published +v0.2.8 integration and workflow split in PR #343; earlier timings retain their +original source and supplier identities. ## Workstream readiness @@ -86,6 +89,44 @@ Release packaging, preparation, evidence signing, benchmark attachment and Cargo publication keep caches disabled. No compiler flags, scientific inputs or benchmark directories change. +## Hosted CI stages + +PR #343 separates hosted validation while retaining the complete local `just ci` +graph. Each preparation job installs the declared tools through RRT and completes +the same policy validators. Its successful completion saves managed compilation +outputs. After all three preparation jobs succeed, native jobs start independently +and install verified tools using that compilation cache. Installation homes, +project outputs and validation results are never shared between these jobs. +A cache miss may rebuild tools; it cannot skip checks or satisfy provenance. + +| Hosted owner, on each native OS | Canonical command and complete responsibility | +| --- | --- | +| `prepare (OS)` | RRT setup, then `just ci-policy`: every `_ci-policy` validator and shared setup check listed above | +| `native-rust (OS)` | `just ci-rust`: the complete sequential `_ci-rust` lane, including compilation, tests, docs, examples and benchmark validation | +| `native-python (OS)` | `just ci-python`: every Python test without the `release_candidate` marker, using four workers | +| `native-candidate (OS)` | `just ci-candidate`: the complete candidate capture/acceptance regression, using one worker on a dedicated runner | +| `native-dependencies (OS)` | `just ci-dependencies`: locked Cargo fetch, native build receipts and dependency-provenance verification | +| `build (OS)` | Required-check name preserved on a lightweight Linux gate; rejects any failed, skipped, cancelled or absent preparation/native result | +| `release-package` | Independent uncached dependency verification and default/exact Cargo package validation | +| `release-evidence` | Protected main-push signer; requires all aggregate gates and packaging, then verifies every actual native job, required step and OS label | + +`tests/tooling/test_validation_graph.py` collects the real pytest suite under both +hosted selections and requires a disjoint, complete partition. Local `just ci` +still selects the full Python suite once. Gate regressions execute the workflow's +actual shell with success, failure, skipped, cancelled and missing results. +Release-evidence regressions reject incomplete steps in every native stage. +Scientific fixtures, assertions and benchmark sampling policy are unchanged. + +The preceding monolithic run at `1548f59`, +[42858660611](https://github.com/acgetchell/la-stack/actions/runs/42858660611), +passed Linux, macOS and uncached packaging. Windows spent about 51 minutes in +setup and 62 minutes in `just ci`, including 465 passing Python tests, before +its two-hour limit interrupted Cargo provenance verification. Python tests took +3,632.39 seconds; candidate acceptance alone took 1,783.31 seconds plus its fixture +setup. These observations motivate resource separation; they do not establish a +speedup. Native timing and cache-hit evidence for the split workflow must be +reconciled on its final source revision. + ## Timing evidence and limits Baseline native data comes from successful main-push diff --git a/justfile b/justfile index 47b68d2..8a3dbc8 100644 --- a/justfile +++ b/justfile @@ -267,6 +267,9 @@ check-fast: ci: python-sync tools-check _ci-policy _ci-execution @echo "🎯 CI checks complete!" +# Exercise candidate acceptance separately on a dedicated native CI runner. +ci-candidate: (test-python "-m" "release_candidate") + # Prepare native release provenance in CI; verified development tooling keeps ordinary checks available. [script("uv", "run", "--locked", "--no-sync", "--no-python-downloads", "research-repo-tools", "toolchain", "run", "--", "python", "-I")] ci-dependencies: python-sync @@ -284,6 +287,15 @@ ci-dependencies: python-sync if status: sys.exit(status) +# Complete shared policy checks before hosted CI starts independent native jobs. +ci-policy: python-sync tools-check _ci-policy + +# Run every Python consumer test outside the separate candidate-acceptance job. +ci-python: (test-python "-m" "not release_candidate") + +# Keep Cargo compilation, tests, docs, examples, and benchmark checks on one runner. +ci-rust: python-sync tools-check _ci-rust + # Validate CITATION.cff against the Citation File Format schema. citation-check: _ensure-uv uvx --from cffconvert==2.0.0 cffconvert --validate -i CITATION.cff @@ -670,8 +682,9 @@ test-integration-compile: tools-check {{ _run }} cargo nextest run --all-features --tests --no-run # Run Python consumer integration and benchmark-policy tests. -test-python: tools-check python-sync - {{ semgrep_stack_limit }} {{ _run }} uv run --locked --no-sync pytest -q {{ _pytest_parallel }} --durations=20 +[positional-arguments] +test-python *args: tools-check python-sync + {{ semgrep_stack_limit }} {{ _run }} uv run --locked --no-sync pytest -q {{ _pytest_parallel }} --durations=20 "$@" # Run all runnable Rust tests and both doctest configurations. test-rust: test-rust-ci test-doc test-doc-exact diff --git a/pyproject.toml b/pyproject.toml index 53186da..aff092b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ testpaths = [ "tests/tooling" ] python_files = [ "test_*.py", "*_test.py" ] python_classes = [ "Test*" ] python_functions = [ "test_*" ] +markers = [ "release_candidate: complete candidate capture and acceptance on a dedicated CI runner" ] [tool.rumdl] line-length = 160 diff --git a/tests/tooling/test_performance_workflow.py b/tests/tooling/test_performance_workflow.py index 2689c39..f277ee4 100644 --- a/tests/tooling/test_performance_workflow.py +++ b/tests/tooling/test_performance_workflow.py @@ -231,7 +231,15 @@ def test_routine_ci_validates_benchmarks_without_acquiring_timings() -> None: build = ci["jobs"]["build"] platforms = build["strategy"]["matrix"]["os"] assert set(platforms) == {"ubuntu-latest", "macos-latest", "windows-latest"} - assert {"name": "Run CI checks", "run": "just ci"} in build["steps"] + assert build["needs"] == ["prepare", "native"] + native = ci["jobs"]["native"] + assert "rust" in native["strategy"]["matrix"]["stage"] + assert { + "name": "Run native validation", + "shell": "bash", + "env": {"CI_STAGE": "${{ matrix.stage }}"}, + "run": 'just "ci-$CI_STAGE"', + } in native["steps"] ruleset = json.loads((ROOT / ".github/settings/main-ruleset.json").read_bytes()) required = next(rule["parameters"]["required_status_checks"] for rule in ruleset["rules"] if rule["type"] == "required_status_checks") contexts = {check["context"] for check in required} @@ -245,6 +253,7 @@ def test_routine_ci_validates_benchmarks_without_acquiring_timings() -> None: assert any("--no-run --features bench,exact --bench exact" in line for line in compile_commands) assert [line for line in bench_commands if line not in compile_commands] == ['--locked --features bench,exact --bench exact -- "$@"'] recipes = inspect_justfile(ROOT).recipes + assert "_ci-rust" in {dep["recipe"] for dep in recipes["ci-rust"]["dependencies"]} assert {"test-bench-exact", "bench-compile", "test-rust-ci"} <= {dep["recipe"] for dep in recipes["_ci-rust"]["dependencies"]} smoke = recipes["test-bench-exact"]["dependencies"] assert [(dep["recipe"], dep["arguments"]) for dep in smoke] == [("bench-exact", ["--test"])] @@ -719,6 +728,7 @@ def identities(root: Path) -> list[str]: return cast("list[str]", json.loads((root / "fixture-identities.json").read_text(encoding="utf-8"))) +@pytest.mark.release_candidate def test_local_candidate_capture_and_final_acceptance_reuse_original_bytes( complete_run: tuple[Path, Evidence], tmp_path: Path, diff --git a/tests/tooling/test_release_evidence.py b/tests/tooling/test_release_evidence.py index 9869fd2..e6f9eaf 100644 --- a/tests/tooling/test_release_evidence.py +++ b/tests/tooling/test_release_evidence.py @@ -26,17 +26,26 @@ INVOCATION = f"https://github.com/{REPOSITORY}/actions/runs/101/attempts/2" POLICY = load_evidence_policy(REPO_ROOT / "tooling/release-evidence.toml") NATIVE_GATES = ( - ("build (ubuntu-latest)", ("Run CI checks", "Verify native dependency provenance"), 201), - ("build (macos-latest)", ("Run CI checks", "Verify native dependency provenance"), 202), - ("build (windows-latest)", ("Run CI checks", "Verify native dependency provenance"), 203), + ("prepare (ubuntu-latest)", ("Set up declared tools", "Run CI policy checks"), 201), + ("prepare (macos-latest)", ("Set up declared tools", "Run CI policy checks"), 202), + ("prepare (windows-latest)", ("Set up declared tools", "Run CI policy checks"), 203), ("release-package", ("Verify default and exact Cargo packages",), 204), + *( + (f"native-{stage} ({platform})", ("Set up declared tools", "Run native validation"), 206 + platform_index * 4 + stage_index) + for platform_index, platform in enumerate(("ubuntu-latest", "macos-latest", "windows-latest")) + for stage_index, stage in enumerate(("rust", "python", "candidate", "dependencies")) + ), ) +NATIVE_RUNNERS = {name: [name.rsplit(" (", 1)[1].removesuffix(")")] for name, _, _ in NATIVE_GATES if name != "release-package"} SIGNING_STEPS = ( "Capture successful native validation identities", "Sign original main validation evidence", "Upload signed validation evidence", ) -REQUIRED_STEPS = (*((index, step) for index, (_, steps, _) in enumerate(NATIVE_GATES) for step in range(len(steps))), *((4, index) for index in range(3))) +REQUIRED_STEPS = ( + *((index, step) for index, (_, steps, _) in enumerate(NATIVE_GATES) for step in range(len(steps))), + *((len(NATIVE_GATES), index) for index in range(3)), +) def install_clean_checkout(patch: pytest.MonkeyPatch, checkout: Path, commit: str) -> None: @@ -93,7 +102,7 @@ def __init__(self, root: Path) -> None: "head_sha": COMMIT, "status": "completed", "conclusion": "success", - "labels": [name.removeprefix("build (").removesuffix(")")] if name.startswith("build (") else ["ubuntu-latest"], + "labels": NATIVE_RUNNERS.get(name, ["ubuntu-latest"]), "steps": [{"number": number, "name": step, "status": "completed", "conclusion": "success"} for number, step in enumerate(steps, 1)], } for name, steps, identifier in gates @@ -115,7 +124,7 @@ def __init__(self, root: Path) -> None: "ref": "refs/heads/main", "event": "push", "required_steps": {name: list(steps) for name, steps, _ in NATIVE_GATES}, - "runner_labels": {name: [name.removeprefix("build (").removesuffix(")")] for name, _, _ in NATIVE_GATES[:3]}, + "runner_labels": deepcopy(NATIVE_RUNNERS), "artifact_prefix": "release-evidence", "guarantee": "signed-source", "signing_job": {"release-evidence": list(SIGNING_STEPS)}, @@ -535,7 +544,7 @@ def test_pinned_digest_cannot_be_replaced_by_another_valid_digest(provider: Evid (("run", "run_attempt"), 1), (("run", "repository_id"), 12), (("package", "version"), "0.5.41"), - (("jobs", "build (ubuntu-latest)"), 999), + (("jobs", "prepare (ubuntu-latest)"), 999), ], ) def test_signed_manifest_must_equal_current_package_and_native_identities(provider: EvidenceProvider, path: tuple[str, ...], value: Any) -> None: diff --git a/tests/tooling/test_release_publishing.py b/tests/tooling/test_release_publishing.py index 1041860..fb47e87 100644 --- a/tests/tooling/test_release_publishing.py +++ b/tests/tooling/test_release_publishing.py @@ -538,12 +538,27 @@ def test_actual_approval_artifact_guard_rejects_drift(tmp_path: Path, failure: s def test_main_ci_seals_only_complete_native_and_cargo_validation() -> None: workflow = yaml.safe_load((REPO_ROOT / ".github/workflows/ci.yml").read_bytes()) jobs = workflow["jobs"] - assert jobs["build"]["strategy"]["matrix"]["os"] == ["ubuntu-latest", "macos-latest", "windows-latest"] - assert jobs["build"]["timeout-minutes"] == 150 - assert jobs["build"]["steps"][-2:] == [ - {"name": "Run CI checks", "run": "just ci"}, - {"name": "Verify native dependency provenance", "run": "just ci-dependencies"}, - ] + platforms = ["ubuntu-latest", "macos-latest", "windows-latest"] + for job in ("prepare", "native", "build"): + assert jobs[job]["strategy"]["matrix"]["os"] == platforms + assert jobs[job]["strategy"]["fail-fast"] is False + assert jobs["prepare"]["runs-on"] == jobs["native"]["runs-on"] == "${{ matrix.os }}" + assert jobs["prepare"]["timeout-minutes"] == 90 + assert jobs["prepare"]["steps"][-1] == {"name": "Run CI policy checks", "run": "just ci-policy"} + assert jobs["native"]["needs"] == "prepare" + assert jobs["native"]["name"] == "native-${{ matrix.stage }} (${{ matrix.os }})" + assert jobs["native"]["strategy"]["matrix"]["stage"] == ["rust", "python", "candidate", "dependencies"] + assert jobs["native"]["timeout-minutes"] == 120 + assert jobs["native"]["steps"][-1] == { + "name": "Run native validation", + "shell": "bash", + "env": {"CI_STAGE": "${{ matrix.stage }}"}, + "run": 'just "ci-$CI_STAGE"', + } + assert jobs["build"]["needs"] == ["prepare", "native"] + assert jobs["build"]["if"] == "${{ !cancelled() }}" + assert jobs["build"]["runs-on"] == "ubuntu-latest" + assert jobs["build"]["timeout-minutes"] == 5 package = jobs["release-package"] assert package["steps"][1]["with"]["cache"] == "false" assert package["steps"][-1] == {"name": "Verify default and exact Cargo packages", "run": "just ci-dependencies\njust publish-check\n"} @@ -578,6 +593,17 @@ def test_main_ci_seals_only_complete_native_and_cargo_validation() -> None: } +@pytest.mark.parametrize("stage", ["prepare", "native"]) +@pytest.mark.parametrize("result", ["success", "failure", "skipped", "cancelled", ""]) +def test_main_ci_required_checks_reject_incomplete_native_stages(stage: str, result: str) -> None: + workflow = yaml.safe_load((REPO_ROOT / ".github/workflows/ci.yml").read_bytes()) + step = workflow["jobs"]["build"]["steps"][0] + assert step["env"] == {"PREPARE_RESULT": "${{ needs.prepare.result }}", "NATIVE_RESULT": "${{ needs.native.result }}"} + environment = {**os.environ, "PREPARE_RESULT": "success", "NATIVE_RESULT": "success", f"{stage.upper()}_RESULT": result} + completed = run_command("bash", ["--noprofile", "--norc", "-euc", step["run"]], env=environment, check=False) + assert completed.returncode == (0 if result == "success" else 1), completed.stderr + + @pytest.mark.parametrize("source", ["development", "published", "rejected"]) def test_main_ci_creates_no_release_evidence_for_development_pins(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, source: str) -> None: """Run the actual workflow adapter; only a successful release gate enables signing.""" diff --git a/tests/tooling/test_validation_graph.py b/tests/tooling/test_validation_graph.py index 28a809d..57364df 100644 --- a/tests/tooling/test_validation_graph.py +++ b/tests/tooling/test_validation_graph.py @@ -85,8 +85,8 @@ def graph(tmp_path: Path) -> tuple[Path, set[str]]: overrides = [] for name in sorted(selected): recipe = recipes[name] - parameters = " *args" if name in {"bench-exact", "zizmor"} else "" - assert not recipe["parameters"] or name in {"bench-exact", "zizmor"} + parameters = " *args" if name in {"bench-exact", "zizmor", "test-python"} else "" + assert not recipe["parameters"] or name in {"bench-exact", "zizmor", "test-python"} dependencies = " ".join( "(" + item["recipe"] + " " + " ".join(json.dumps(arg) for arg in item["arguments"]) + ")" if item["arguments"] else item["recipe"] for item in recipe["dependencies"] @@ -135,7 +135,7 @@ def test_managed_cache_contains_compilation_only_and_release_jobs_disable_it() - for filename in ("ci.yml", "release-benchmarks.yml", "publish-crates.yml", "prepare-release.yml"): workflow = yaml.safe_load((ROOT / ".github/workflows" / filename).read_bytes()) for name, job in workflow["jobs"].items(): - if filename == "ci.yml" and name == "build": + if filename == "ci.yml" and name in {"prepare", "native", "build"}: continue for step in job["steps"]: if step.get("uses") == "./.github/actions/setup-tools": @@ -143,6 +143,29 @@ def test_managed_cache_contains_compilation_only_and_release_jobs_disable_it() - assert not step.get("uses", "").startswith("actions/cache@") +def test_hosted_python_jobs_partition_the_complete_collection() -> None: + """Exercise pytest's actual marker selection without executing the slow suite.""" + recipes = inspect_justfile(ROOT).recipes + assert "_ci-policy" in {item["recipe"] for item in recipes["ci-policy"]["dependencies"]} + assert "_ci-rust" in {item["recipe"] for item in recipes["ci-rust"]["dependencies"]} + selections: dict[str, set[str]] = {} + for name in ("test-python", "ci-python", "ci-candidate"): + arguments = [] if name == "test-python" else recipes[name]["dependencies"][0]["arguments"] + if name != "test-python": + assert [item["recipe"] for item in recipes[name]["dependencies"]] == ["test-python"] + result = run_command( + "uv", + ["run", "--locked", "--no-sync", "pytest", "--collect-only", "-q", "--color=no", "-n", "0", *arguments], + cwd=ROOT, + timeout=90, + ) + selections[name] = {line for line in result.stdout.splitlines() if line.startswith("tests/") and "::" in line} + assert selections[name], result.stdout + assert selections["ci-candidate"] == {"tests/tooling/test_performance_workflow.py::test_local_candidate_capture_and_final_acceptance_reuse_original_bytes"} + assert selections["ci-python"].isdisjoint(selections["ci-candidate"]) + assert selections["ci-python"] | selections["ci-candidate"] == selections["test-python"] + + @pytest.mark.parametrize("authenticated", [False, True], ids=["rate-limited-anonymous", "authenticated-setup"]) def test_setup_token_authenticates_installed_binary_release_lookup(monkeypatch: pytest.MonkeyPatch, authenticated: bool) -> None: """Exercise the real client's credential boundary using the action's mapping.""" diff --git a/tooling/README.md b/tooling/README.md index 4476521..3fcee11 100644 --- a/tooling/README.md +++ b/tooling/README.md @@ -106,8 +106,9 @@ the next release check after such changes. These receipts establish observed source/build provenance, not reproducible builds or publisher-signed wheels. See the supplier's [source-build contract](https://github.com/acgetchell/research-repo-tools/blob/v0.2.8/README.md#source-built-python-dependencies). -Native CI runs `just ci-dependencies` after its ordinary checks and includes -the provenance step in signed release evidence. A verified exact development +Native CI runs `just ci-dependencies` in its own job alongside the Rust, Python, +and candidate-acceptance jobs after shared policy checks. Signed release evidence +requires every native stage. A verified exact development tooling pin skips this release-only preparation; invalid pins and failed builds or verification still fail. Release jobs with fresh project environments prepare their own receipts before acquisition/acceptance verification. Local `just ci` diff --git a/tooling/release-evidence.toml b/tooling/release-evidence.toml index 6d43ef7..75a3ccc 100644 --- a/tooling/release-evidence.toml +++ b/tooling/release-evidence.toml @@ -8,24 +8,84 @@ artifact-prefix = "release-evidence" guarantee = "signed-source" [[jobs]] -name = "build (ubuntu-latest)" -steps = [ "Run CI checks", "Verify native dependency provenance" ] +name = "prepare (ubuntu-latest)" +steps = [ "Set up declared tools", "Run CI policy checks" ] runner-labels = [ "ubuntu-latest" ] [[jobs]] -name = "build (macos-latest)" -steps = [ "Run CI checks", "Verify native dependency provenance" ] +name = "prepare (macos-latest)" +steps = [ "Set up declared tools", "Run CI policy checks" ] runner-labels = [ "macos-latest" ] [[jobs]] -name = "build (windows-latest)" -steps = [ "Run CI checks", "Verify native dependency provenance" ] +name = "prepare (windows-latest)" +steps = [ "Set up declared tools", "Run CI policy checks" ] runner-labels = [ "windows-latest" ] [[jobs]] name = "release-package" steps = [ "Verify default and exact Cargo packages" ] +[[jobs]] +name = "native-rust (ubuntu-latest)" +steps = [ "Set up declared tools", "Run native validation" ] +runner-labels = [ "ubuntu-latest" ] + +[[jobs]] +name = "native-python (ubuntu-latest)" +steps = [ "Set up declared tools", "Run native validation" ] +runner-labels = [ "ubuntu-latest" ] + +[[jobs]] +name = "native-candidate (ubuntu-latest)" +steps = [ "Set up declared tools", "Run native validation" ] +runner-labels = [ "ubuntu-latest" ] + +[[jobs]] +name = "native-dependencies (ubuntu-latest)" +steps = [ "Set up declared tools", "Run native validation" ] +runner-labels = [ "ubuntu-latest" ] + +[[jobs]] +name = "native-rust (macos-latest)" +steps = [ "Set up declared tools", "Run native validation" ] +runner-labels = [ "macos-latest" ] + +[[jobs]] +name = "native-python (macos-latest)" +steps = [ "Set up declared tools", "Run native validation" ] +runner-labels = [ "macos-latest" ] + +[[jobs]] +name = "native-candidate (macos-latest)" +steps = [ "Set up declared tools", "Run native validation" ] +runner-labels = [ "macos-latest" ] + +[[jobs]] +name = "native-dependencies (macos-latest)" +steps = [ "Set up declared tools", "Run native validation" ] +runner-labels = [ "macos-latest" ] + +[[jobs]] +name = "native-rust (windows-latest)" +steps = [ "Set up declared tools", "Run native validation" ] +runner-labels = [ "windows-latest" ] + +[[jobs]] +name = "native-python (windows-latest)" +steps = [ "Set up declared tools", "Run native validation" ] +runner-labels = [ "windows-latest" ] + +[[jobs]] +name = "native-candidate (windows-latest)" +steps = [ "Set up declared tools", "Run native validation" ] +runner-labels = [ "windows-latest" ] + +[[jobs]] +name = "native-dependencies (windows-latest)" +steps = [ "Set up declared tools", "Run native validation" ] +runner-labels = [ "windows-latest" ] + [signing-job] name = "release-evidence" steps = [ "Capture successful native validation identities", "Sign original main validation evidence", "Upload signed validation evidence" ] From 6ca9bd90777ed99183bcd9e08bc7d0adb1a114cf Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Tue, 10 Nov 2026 12:25:46 -0800 Subject: [PATCH 6/9] ci: require aggregate gates after cancellation - Run lightweight required-check gates even when the workflow is cancelled, so incomplete native stages fail instead of producing a skipped check. - Retain the bounded five-minute gate and every native validation requirement. - Validate with 80 publishing/graph regressions, just check lint-config, and local CodeRabbit review with zero findings. --- .github/workflows/ci.yml | 3 ++- tests/tooling/test_release_publishing.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a19ed4c..e341dd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,7 +98,8 @@ jobs: build: # Preserve required-check names while requiring every native stage. needs: [prepare, native] - if: ${{ !cancelled() }} + # A skipped required check can permit merging, including after cancellation. + if: ${{ always() }} strategy: fail-fast: false matrix: diff --git a/tests/tooling/test_release_publishing.py b/tests/tooling/test_release_publishing.py index fb47e87..a10ad14 100644 --- a/tests/tooling/test_release_publishing.py +++ b/tests/tooling/test_release_publishing.py @@ -556,7 +556,8 @@ def test_main_ci_seals_only_complete_native_and_cargo_validation() -> None: "run": 'just "ci-$CI_STAGE"', } assert jobs["build"]["needs"] == ["prepare", "native"] - assert jobs["build"]["if"] == "${{ !cancelled() }}" + # Required checks must execute and reject cancellation, rather than skip. + assert jobs["build"]["if"] == "${{ always() }}" assert jobs["build"]["runs-on"] == "ubuntu-latest" assert jobs["build"]["timeout-minutes"] == 5 package = jobs["release-package"] From 6a8ed2a83dfcbe8dfee0b6166248bff89b6fbf1c Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Tue, 10 Nov 2026 12:31:21 -0800 Subject: [PATCH 7/9] docs: clarify native CI stages and gate checks - Identify dependency provenance as the dependencies matrix stage. - Clarify the documented aggregate-gate regression inputs. - Validate with Markdown/spelling and documentation checks, plus local CodeRabbit review with zero findings. --- docs/dev/release-flow-integration.md | 2 +- tooling/README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/dev/release-flow-integration.md b/docs/dev/release-flow-integration.md index 703d8f4..b25c73b 100644 --- a/docs/dev/release-flow-integration.md +++ b/docs/dev/release-flow-integration.md @@ -113,7 +113,7 @@ A cache miss may rebuild tools; it cannot skip checks or satisfy provenance. `tests/tooling/test_validation_graph.py` collects the real pytest suite under both hosted selections and requires a disjoint, complete partition. Local `just ci` still selects the full Python suite once. Gate regressions execute the workflow's -actual shell with success, failure, skipped, cancelled and missing results. +actual shell for success, failure, skipped, cancelled and missing results. Release-evidence regressions reject incomplete steps in every native stage. Scientific fixtures, assertions and benchmark sampling policy are unchanged. diff --git a/tooling/README.md b/tooling/README.md index 3fcee11..252e5cb 100644 --- a/tooling/README.md +++ b/tooling/README.md @@ -106,8 +106,8 @@ the next release check after such changes. These receipts establish observed source/build provenance, not reproducible builds or publisher-signed wheels. See the supplier's [source-build contract](https://github.com/acgetchell/research-repo-tools/blob/v0.2.8/README.md#source-built-python-dependencies). -Native CI runs `just ci-dependencies` in its own job alongside the Rust, Python, -and candidate-acceptance jobs after shared policy checks. Signed release evidence +Native CI runs `just ci-dependencies` in the `dependencies` stage of the `native` +job alongside the Rust, Python, and candidate-acceptance stages after shared policy checks. Signed release evidence requires every native stage. A verified exact development tooling pin skips this release-only preparation; invalid pins and failed builds or verification still fail. Release jobs with fresh project environments prepare From 06c92413a1a4acceac4142a05dc002baa781109f Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Tue, 10 Nov 2026 13:42:45 -0800 Subject: [PATCH 8/9] docs: align CI descriptions with native stages - Replace stale combined-job descriptions in the contributor, benchmark, and release guides with the current stage and aggregate-gate ownership. - Preserve complete native validation and signed-evidence requirements. - Validate Markdown, spelling, and documentation builds; local CodeRabbit reviewed all three changed files with zero findings. --- CONTRIBUTING.md | 6 +++--- docs/BENCHMARKING.md | 4 ++-- docs/RELEASING.md | 5 +++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8629368..5449d59 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -135,9 +135,9 @@ network access, and Gitleaks requires a complete Git checkout. JSON/SARIF report are retained under `target/security`, with secret findings redacted. These gates run separately from `just ci`; the hosted RustSec audit remains in place. -CI runs `just ci` on Ubuntu, macOS, and Windows to keep platform coverage -aligned with the local comprehensive validation path. Each native job also runs -`just ci-dependencies` for release provenance: it prepares authenticated native +CI runs the [staged validation graph](#validation-workflow) on Ubuntu, macOS, and +Windows, covering the complete local `just ci` path. The dedicated +`just ci-dependencies` stage prepares authenticated native dependency builds and verifies installed bytes when tooling is published. Verified development tooling remains eligible for ordinary checks; release evidence still requires a published supplier. See the diff --git a/docs/BENCHMARKING.md b/docs/BENCHMARKING.md index 8c4243c..eaa7ac5 100644 --- a/docs/BENCHMARKING.md +++ b/docs/BENCHMARKING.md @@ -68,7 +68,7 @@ evidence being validated. ## CI validation and manual timings -The native `build` jobs run `just ci` on Linux, macOS, and Windows for PRs +The `native-rust` jobs run `just ci-rust` on Linux, macOS, and Windows for PRs and pushes to `main`. They cover the correctness checks previously exercised by the automatic benchmark job. @@ -80,7 +80,7 @@ by the automatic benchmark job. | Execute the full exact harness and its fixture checks | `test-bench-exact` runs every exact case in Criterion's `--test` mode without timing samples. | | Save or download a main baseline, sample a PR, and report cross-runner regressions | The manual workflow collects one exploratory run. Accepted release evidence is attached through [Releasing](RELEASING.md#6-attach-accepted-benchmarks-and-review-the-draft). | -The three native build status checks still cover benchmark validation. +The three `build` aggregate status checks require these native jobs to pass. The optional [Benchmarks workflow](https://github.com/acgetchell/la-stack/actions/workflows/benchmarks.yml) runs only when a maintainer selects a reviewed branch or tag: diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 20fe9e3..41600cc 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -44,8 +44,9 @@ commands. `tooling/release-evidence.toml` declares the repository, package, workflow, native jobs/steps and signer policy. The guarantee is `signed-source`; it does not promise promotion of an identical Cargo archive. Protected `main` pushes produce signed evidence only with published tooling, -after all three native `just ci` jobs and the default/exact Cargo packaging job -succeed. A development Git pin passes ordinary validation but cannot produce +after preparation and all four native validation stages succeed on each OS, +the three `build` aggregate gates pass, and default/exact Cargo packaging +succeeds. A development Git pin passes ordinary validation but cannot produce release evidence. CI verifies its development identity and skips signing and uploading the release-evidence artifact; ordinary validation still runs. The `release-evidence` job signs their exact commit, run, attempt, job IDs and From 1d755cd58dc5128194874e04b3e08faeddd65702 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Tue, 10 Nov 2026 14:33:24 -0800 Subject: [PATCH 9/9] fix: preserve release transfer and benchmark fixture invariants - Canonicalize checkout paths before enforcing external owner separation and retain release identity and digest assertions in DraftTransfer construction. - Keep validated polynomial inputs immutable and represent interval benchmark families with an exhaustive enum while preserving scenarios and timed kernels. - Clarify Rust construction, rounding, and bound contracts and add a runnable polynomial normalization example without changing public APIs or numerics. - Validate with just check, full just ci (1,143 Rust and 532 Python tests), default/exact doctests, rendered docs, and Windows-targeted Python typing. Local CodeRabbit reviewed all 14 changed files with zero findings. --- benches/common/interval_solve.rs | 54 +++++++++++++++++------- benches/common/polynomial.rs | 9 +++- benches/interval_solve.rs | 7 +-- benches/polynomial.rs | 2 +- src/exact.rs | 4 +- src/lu.rs | 3 ++ src/matrix.rs | 5 ++- src/polynomial.rs | 20 +++++++++ src/rational.rs | 2 + tests/interval_solve.rs | 6 +-- tests/polynomial_bench_inputs.rs | 7 +-- tests/tooling/test_candidate_transfer.py | 15 +++++++ tests/tooling/test_release_context.py | 18 ++++++++ tooling/release_candidate.py | 15 ++++--- 14 files changed, 134 insertions(+), 33 deletions(-) diff --git a/benches/common/interval_solve.rs b/benches/common/interval_solve.rs index 9da993b..ff80abb 100644 --- a/benches/common/interval_solve.rs +++ b/benches/common/interval_solve.rs @@ -25,25 +25,50 @@ pub(crate) struct Input { pub(crate) expected: Outcome, } -pub(crate) const FAMILIES: [&str; 5] = [ - "dense", - "uncertain_dense", - "near_singular", - "singular", - "range_exhausted", -]; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Family { + Dense, + UncertainDense, + NearSingular, + Singular, + RangeExhausted, +} + +impl Family { + pub(crate) const ALL: [Self; 5] = [ + Self::Dense, + Self::UncertainDense, + Self::NearSingular, + Self::Singular, + Self::RangeExhausted, + ]; + + pub(crate) const fn name(self) -> &'static str { + match self { + Self::Dense => "dense", + Self::UncertainDense => "uncertain_dense", + Self::NearSingular => "near_singular", + Self::Singular => "singular", + Self::RangeExhausted => "range_exhausted", + } + } +} fn point(value: f64) -> Interval { Interval::point(value).or_abort("finite benchmark coefficient") } -pub(crate) fn input(family: &str, index: usize) -> Input { - assert!((1..=6).contains(&D) && index < CASES); +pub(crate) fn input(family: Family, index: usize) -> Input { + assert!( + (1..=6).contains(&D) && index < CASES, + "{} requires dimension 1..=6 and a corpus index below {CASES}", + family.name(), + ); let known_solution = from_fn(|i| if i.is_multiple_of(2) { 1.0 } else { -1.0 }); let mut centers = [[0.0; D]; D]; let mut radii = [[0.0; D]; D]; let expected = match family { - "dense" | "uncertain_dense" => { + Family::Dense | Family::UncertainDense => { for (i, row) in centers.iter_mut().enumerate() { for (j, value) in row.iter_mut().enumerate() { *value = if (i + 1) % D == j { @@ -55,14 +80,14 @@ pub(crate) fn input(family: &str, index: usize) -> Input { - 4, ) / 8.0 }; - if family == "uncertain_dense" { + if family == Family::UncertainDense { radii[i][j] = 2.0_f64.powi(-30); } } } Outcome::Enclosed } - "near_singular" => { + Family::NearSingular => { for (i, row) in centers.iter_mut().enumerate() { row[i] = 1.0; } @@ -82,18 +107,17 @@ pub(crate) fn input(family: &str, index: usize) -> Input { Outcome::Inconclusive } } - "singular" | "range_exhausted" => { + Family::Singular | Family::RangeExhausted => { for (i, row) in centers.iter_mut().enumerate().take(D - 1) { row[i] = 1.0; } - if family == "singular" { + if family == Family::Singular { Outcome::Inconclusive } else { centers[D - 1][D - 1] = f64::from_bits(1); Outcome::RangeExhausted } } - _ => panic!("unknown interval solve family"), }; // These small dyadic sums are exact in binary64; the range fixture replaces // its last RHS and deliberately has no finite enclosure. diff --git a/benches/common/polynomial.rs b/benches/common/polynomial.rs index 09c74b8..13d1892 100644 --- a/benches/common/polynomial.rs +++ b/benches/common/polynomial.rs @@ -19,7 +19,14 @@ pub(crate) const BUDGET: PolynomialDeterminantBudget = PolynomialDeterminantBudg /// A fixture checked against independent permutation enumeration before timing. pub(crate) struct ValidatedPolynomialInput { - pub(crate) matrix: [[SparsePolynomial; N]; N], + matrix: [[SparsePolynomial; N]; N], +} + +impl ValidatedPolynomialInput { + /// Borrow the matrix whose determinant has been independently checked. + pub(crate) const fn matrix(&self) -> &[[SparsePolynomial; N]; N] { + &self.matrix + } } /// All unperturbed points are at the origin. A coordinate is `t^(3^variable)`, diff --git a/benches/interval_solve.rs b/benches/interval_solve.rs index 7fafd34..4f2ee89 100644 --- a/benches/interval_solve.rs +++ b/benches/interval_solve.rs @@ -16,7 +16,7 @@ mod interval_solve; fn solve_benchmarks(criterion: &mut Criterion) { let mut group = criterion.benchmark_group(format!("interval_solve_d{D}")); - for family in interval_solve::FAMILIES { + for family in interval_solve::Family::ALL { let inputs: Vec<_> = (0..interval_solve::CASES) .map(|index| interval_solve::input::(family, index)) .collect(); @@ -27,11 +27,12 @@ fn solve_benchmarks(criterion: &mut Criterion) { .iter() .filter(|input| input.expected == interval_solve::Outcome::Enclosed) .count(); + let name = family.name(); eprintln!( - "interval_solve_d{D}/{family}: {successes}/{} certified", + "interval_solve_d{D}/{name}: {successes}/{} certified", inputs.len() ); - group.bench_function(family, |b| { + group.bench_function(name, |b| { b.iter(|| { for input in &inputs { let _ = black_box(black_box(&input.matrix).solve(black_box(&input.rhs))); diff --git a/benches/polynomial.rs b/benches/polynomial.rs index bd54a5c..699bcab 100644 --- a/benches/polynomial.rs +++ b/benches/polynomial.rs @@ -20,7 +20,7 @@ fn register(criterion: &mut Criterion) { let mut group = criterion.benchmark_group(format!("polynomial/coincident_lifted/order{N}")); group.bench_function("determinant", |bencher| { bencher.iter(|| { - let determinant = polynomial_determinant(black_box(&input.matrix), black_box(BUDGET)) + let determinant = polynomial_determinant(black_box(input.matrix()), black_box(BUDGET)) .or_abort("validated fixture fits the consumer budget"); black_box(determinant.leading_term()); black_box(determinant) diff --git a/src/exact.rs b/src/exact.rs index f865dfc..54d8de7 100644 --- a/src/exact.rs +++ b/src/exact.rs @@ -1587,7 +1587,9 @@ impl Matrix { /// [`det_exact`](Self::det_exact), then rounds the exact value to a finite /// binary64 value using IEEE 754 round-to-nearest, ties-to-even. Unlike /// [`det_exact_f64`](Self::det_exact_f64), this method is intentionally lossy - /// and may round non-dyadic or underflowing nonzero exact determinants. + /// and may round dyadic determinants that exceed binary64 precision or + /// underflow its range. Determinants of finite binary64 entries are always + /// dyadic, but need not themselves be representable in binary64. /// /// # Examples /// ``` diff --git a/src/lu.rs b/src/lu.rs index 9e08f3c..e24092d 100644 --- a/src/lu.rs +++ b/src/lu.rs @@ -16,6 +16,9 @@ use crate::{ArithmeticOperation, FactorizationKind, LaError, Tolerance}; /// LU decomposition (PA = LU) with partial pivoting. /// +/// Construct this through [`Matrix::lu`], then reuse the factors for +/// [`solve`](Self::solve) or [`det`](Self::det) without repeating elimination. +/// /// `Lu<0>` represents the empty factorization. Its determinant is the empty /// product `1.0`, and solving against [`Vector<0>`] returns [`Vector<0>`]. /// Numerical solves and determinants remain subject to binary64 rounding and diff --git a/src/matrix.rs b/src/matrix.rs index 40cd00c..bc05c6c 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -35,7 +35,10 @@ impl DeterminantWithErrorBound { /// Return the certified absolute error bound. /// /// The exact determinant lies in - /// `[determinant - bound, determinant + bound]`. + /// `[determinant - bound, determinant + bound]` in real arithmetic. + /// Computing those endpoints with ordinary `f64` subtraction/addition may + /// round inward or overflow; use outward-rounded arithmetic when a + /// floating-point enclosure is required. #[inline] #[must_use] pub const fn absolute_error_bound(self) -> f64 { diff --git a/src/polynomial.rs b/src/polynomial.rs index 560f81f..ba30996 100644 --- a/src/polynomial.rs +++ b/src/polynomial.rs @@ -52,6 +52,26 @@ impl SparsePolynomial { /// removal, so even `0/0` is rejected. The iterator is consumed only up to /// the first invalid coefficient; this constructor has no work budget. /// + /// # Examples + /// ``` + /// use la_stack::prelude::*; + /// + /// # fn main() -> Result<(), LaError> { + /// let polynomial = SparsePolynomial::try_from_terms([ + /// (3, BigRational::new_raw(2.into(), 4.into())), + /// (1, BigRational::from_integer(2.into())), + /// (1, BigRational::from_integer((-2).into())), + /// ])?; + /// // Equal exponents cancel, and the remaining coefficient is reduced. + /// assert_eq!(polynomial.terms().len(), 1); + /// assert_eq!( + /// polynomial.leading_term(), + /// Some((3, &BigRational::new(1.into(), 2.into()))), + /// ); + /// # Ok(()) + /// # } + /// ``` + /// /// # Errors /// Returns [`LaError::NonFinite`] with input origin and a /// [`NonFiniteLocation::PolynomialTerm`] for a zero denominator. diff --git a/src/rational.rs b/src/rational.rs index c10ea14..89dbcae 100644 --- a/src/rational.rs +++ b/src/rational.rs @@ -114,6 +114,8 @@ impl RationalMatrix { /// Try to create an exact matrix by evaluating a function at every cell. /// /// The function is evaluated once per cell in row-major order. + /// All cells are generated before validation and canonicalization, as + /// in [`try_from_rows`](Self::try_from_rows). /// /// # Examples /// ``` diff --git a/tests/interval_solve.rs b/tests/interval_solve.rs index 51f6fbb..5dc5ea8 100644 --- a/tests/interval_solve.rs +++ b/tests/interval_solve.rs @@ -228,7 +228,7 @@ mod solve_fixtures; mod bench_utils; fn benchmark_fixtures() { - for family in solve_fixtures::FAMILIES { + for family in solve_fixtures::Family::ALL { for index in 0..solve_fixtures::CASES { let input = solve_fixtures::input::(family, index); solve_fixtures::validate(&input); @@ -241,11 +241,11 @@ fn benchmark_fixtures() { let rows = input.matrix.into_rows().map(|r| r.map(midpoint)); let rhs = input.rhs.map(midpoint); let exact_solution = exact_solve(rows, rhs); - if family == "singular" { + if family == solve_fixtures::Family::Singular { assert!(exact_solution.is_none()); } else { let exact_solution = exact_solution.unwrap(); - if family == "range_exhausted" { + if family == solve_fixtures::Family::RangeExhausted { assert!(exact_solution[D - 1].abs() > exact(f64::MAX)); } else { assert_eq!(exact_solution, input.known_solution.map(exact)); diff --git a/tests/polynomial_bench_inputs.rs b/tests/polynomial_bench_inputs.rs index fcc1d93..7d11f65 100644 --- a/tests/polynomial_bench_inputs.rs +++ b/tests/polynomial_bench_inputs.rs @@ -18,11 +18,12 @@ macro_rules! lifted_test { paste! { #[test] fn []() { - let mut input = coincident_lifted::<$n>(); + let input = coincident_lifted::<$n>(); + let mut matrix = input.matrix().clone(); // Repeating a complete polynomial row must cancel every term. - let (first, rest) = input.matrix.split_at_mut(1); + let (first, rest) = matrix.split_at_mut(1); rest[0].clone_from(&first[0]); - assert!(polynomial_determinant(&input.matrix, BUDGET).unwrap().is_zero()); + assert!(polynomial_determinant(&matrix, BUDGET).unwrap().is_zero()); } } }; diff --git a/tests/tooling/test_candidate_transfer.py b/tests/tooling/test_candidate_transfer.py index 55bc932..584e8cb 100644 --- a/tests/tooling/test_candidate_transfer.py +++ b/tests/tooling/test_candidate_transfer.py @@ -177,6 +177,21 @@ def test_transfer_variants_keep_wire_schema(transfer: dict[str, Any]) -> None: assert json.loads(candidate.serialize_transfer(publication)) == data +@pytest.mark.parametrize("field", ["acquisition", "publication"]) +def test_transfer_replacement_preserves_digest_invariants(transfer: dict[str, Any], field: str) -> None: + draft = candidate.require_draft_transfer(candidate.parse_transfer(json.dumps(transfer["data"]).encode())) + with pytest.raises(ValueError, match="full SHA-256 assertions"): + replace(draft, **{field: "not-a-digest"}) + + +@pytest.mark.parametrize("field", ["repository", "expected_title"]) +def test_transfer_replacement_preserves_release_identity(transfer: dict[str, Any], field: str) -> None: + draft = candidate.require_draft_transfer(candidate.parse_transfer(json.dumps(transfer["data"]).encode())) + target = replace(draft.target, **{field: "acgetchell/other" if field == "repository" else "Other release"}) + with pytest.raises(ValueError, match="this repository and its exact release title"): + replace(draft, target=target) + + @pytest.mark.parametrize("key", ["GITHUB_ACTOR", "GITHUB_TRIGGERING_ACTOR", "GITHUB_EVENT_NAME", "GITHUB_WORKFLOW_REF", "GITHUB_SHA", "GITHUB_REF"]) def test_dispatch_authentication_precedes_provider_access(transfer: dict[str, Any], tmp_path: Path, monkeypatch: pytest.MonkeyPatch, key: str) -> None: monkeypatch.setenv(key, "untrusted") diff --git a/tests/tooling/test_release_context.py b/tests/tooling/test_release_context.py index 2f68306..9627abf 100644 --- a/tests/tooling/test_release_context.py +++ b/tests/tooling/test_release_context.py @@ -25,6 +25,24 @@ SHA = "a" * 40 +@pytest.mark.parametrize("selection", ["checkout", "inside", "parent"]) +def test_owner_directory_rejects_overlap_through_checkout_alias(tmp_path: Path, selection: str) -> None: + root = tmp_path / "checkout" + (root / "nested").mkdir(parents=True) + alias = root / "nested" / ".." + selected = {"checkout": root, "inside": root / "owner", "parent": tmp_path}[selection] + with pytest.raises(ValueError, match="separate from the checkout"): + candidate.owner_directory(alias, "", selected) + + +def test_owner_directory_accepts_canonical_external_selection(tmp_path: Path) -> None: + root = tmp_path / "checkout" + (root / "nested").mkdir(parents=True) + owner = tmp_path / "external owner" + owner.mkdir() + assert candidate.owner_directory(root / "nested" / "..", "", owner / ".." / owner.name) == owner.resolve() + + @pytest.fixture def owner_record(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Path]: root, owner = tmp_path / "checkout", tmp_path / "original owner" diff --git a/tooling/release_candidate.py b/tooling/release_candidate.py index 1278daf..7caf8c4 100644 --- a/tooling/release_candidate.py +++ b/tooling/release_candidate.py @@ -73,6 +73,7 @@ def selection_directory(root: Path) -> Path: def owner_directory(root: Path, checkpoint: str, selected: Path | None = None) -> Path: """Keep the operator's immutable record outside candidate-controlled files.""" + root = root.resolve() identity = hashlib.sha256(str((root / checkpoint).resolve()).encode("utf-8")).hexdigest() directory = (selected if selected is not None else Path.home() / ".local/state/la-stack/acquisitions" / release_tag(root) / identity).expanduser().resolve() if directory.is_relative_to(root) or root.is_relative_to(directory): @@ -389,6 +390,14 @@ class DraftTransfer: publication: str assets: OriginalAssets + def __post_init__(self) -> None: + """Preserve release identity and digest assertions on every construction.""" + if self.target.repository != REPOSITORY or self.target.expected_title != self.target.tag: + message = "candidate transfer must select this repository and its exact release title" + raise ValueError(message) + transfer_digest(self.acquisition) + transfer_digest(self.publication) + @dataclass(frozen=True, slots=True) class PublicationTransfer: @@ -440,10 +449,6 @@ def parse_transfer(payload: bytes) -> Transfer: message = "invalid candidate transfer schema" raise ValueError(message) target = parse_release_target(json.dumps(data["target"]).encode("utf-8")) - if target.repository != REPOSITORY or target.expected_title != target.tag: - message = "candidate transfer must select this repository and its exact release title" - raise ValueError(message) - raw_assets = data["assets"] if not isinstance(raw_assets, dict) or set(raw_assets) not in (set(ROLES), {*ROLES, "attached"}): message = "candidate transfer requires four original assets and an optional attached archive" @@ -454,7 +459,7 @@ def parse_transfer(payload: bytes) -> Transfer: acceptance=transfer_asset(raw_assets["acceptance"]), archive=transfer_asset(raw_assets["archive"]), ) - original = DraftTransfer(target, transfer_digest(data["acquisition"]), transfer_digest(data["publication"]), assets) + original = DraftTransfer(target, data["acquisition"], data["publication"], assets) if "attached" in raw_assets: return PublicationTransfer(original, transfer_asset(raw_assets["attached"])) return original