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..e341dd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,8 +26,10 @@ env: RUST_BACKTRACE: 1 jobs: - build: + prepare: runs-on: ${{ matrix.os }} + # Populate managed compilation caches once before the native jobs fan out. + timeout-minutes: 90 strategy: fail-fast: false matrix: @@ -51,8 +53,70 @@ 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 + + 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] + # A skipped required check can permit merging, including after cancellation. + if: ${{ always() }} + 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 @@ -66,13 +130,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 +148,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..5449d59 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 @@ -135,8 +135,13 @@ 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. +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 +[source-build adoption contract](tooling/README.md#shared-capability-adoption). ### CodeRabbit review @@ -303,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/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/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/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..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: @@ -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..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 @@ -111,11 +112,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 +146,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 +195,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 @@ -481,8 +499,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: @@ -749,7 +769,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/docs/dev/release-flow-integration.md b/docs/dev/release-flow-integration.md index d3dff7d..b25c73b 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 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. + +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 1ad12de..8a3dbc8 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,35 @@ 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 + 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) + +# 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 @@ -308,6 +341,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 +508,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 +527,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 +545,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. @@ -637,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 dd52575..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 @@ -108,12 +109,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 +136,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 +149,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 +215,6 @@ fixtures = "tests/semgrep" namespace = "la-stack." cwd = "." timeout = 120 + +[tool.uv.dependency-groups] +tooling = { requires-python = ">=3.15" } 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_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_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_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/tests/tooling/test_release_evidence.py b/tests/tooling/test_release_evidence.py index cc0faea..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", 201), - ("build (macos-latest)", "Run CI checks", 202), - ("build (windows-latest)", "Run CI checks", 203), - ("release-package", "Verify default and exact Cargo packages", 204), + ("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, 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))), + *((len(NATIVE_GATES), index) for index in range(3)), +) def install_clean_checkout(patch: pytest.MonkeyPatch, checkout: Path, commit: str) -> None: @@ -82,7 +91,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, @@ -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 @@ -114,8 +123,8 @@ 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}, - "runner_labels": {name: [name.removeprefix("build (").removesuffix(")")] for name, _, _ in NATIVE_GATES[:3]}, + "required_steps": {name: list(steps) for name, steps, _ in NATIVE_GATES}, + "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_flow.py b/tests/tooling/test_release_flow.py index 27c5e5f..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].startswith('"research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@') + 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 26dbd16..a10ad14 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" @@ -78,7 +79,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 +248,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 +272,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 = 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. - 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 +292,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 +379,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 +472,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 +482,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 @@ -524,11 +538,31 @@ 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"]["steps"][-1] == {"name": "Run CI checks", "run": "just ci"} + 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"] + # 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"] 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 +577,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", + TOOLING_REQUIREMENT, "python", "-I", "-", @@ -560,6 +594,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.""" @@ -572,7 +617,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..fc60b49 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,17 +16,37 @@ 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 +from research_repo_tools.toml_source import remove_value, set_value if TYPE_CHECKING: 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}" + + +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"] @@ -49,9 +70,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_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"] == [] assert manifest["tool"]["uv"]["package"] is False @@ -60,25 +81,53 @@ def test_tooling_commit_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 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}"} - # Distribution.read_text decodes UTF-8 internally and has no encoding argument. - installed = json.loads( + 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 - 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} + 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 - 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) + + +@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: @@ -94,6 +143,76 @@ 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) + + def fetch(executable: str, arguments: list[str], *, cwd: Path, timeout: int) -> None: + assert cwd == REPO_ROOT + assert timeout == 600 + calls.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 [["cargo", "fetch", "--locked"], ["release", "dependency-builds"]] + if outcome in {"published", "proof-failure"}: + expected.append(["release", "dependencies"]) + assert calls == expected + + @pytest.mark.parametrize( "command", [ @@ -106,6 +225,12 @@ 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") + 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 @@ -114,22 +239,33 @@ 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_selection_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\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__, 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 +282,23 @@ 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 + 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")] + 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 +345,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 +442,18 @@ 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 = []") + # 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") 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 +480,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/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 1939148..252e5cb 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,79 @@ 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`. +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 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` 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 +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 +153,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 +261,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 +386,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..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" ] +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" ] +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" ] +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" ] 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 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"