diff --git a/.github/actions-allowlist.json b/.github/actions-allowlist.json new file mode 100644 index 0000000..83f41fe --- /dev/null +++ b/.github/actions-allowlist.json @@ -0,0 +1,21 @@ +{ + "github_owned_allowed": false, + "verified_allowed": false, + "patterns_allowed": [ + "acgetchell/research-repo-tools/.github/workflows/dependabot-approve.yml@*", + "actions-rust-lang/setup-rust-toolchain@*", + "actions/attest@*", + "actions/cache@*", + "actions/checkout@*", + "actions/download-artifact@*", + "actions/github-script@*", + "actions/upload-artifact@*", + "astral-sh/setup-uv@*", + "codecov/codecov-action@*", + "github/codeql-action/analyze@*", + "github/codeql-action/init@*", + "github/codeql-action/upload-sarif@*", + "rust-lang/crates-io-auth-action@*", + "taiki-e/cache-cargo-install-action@*" + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1235bf..62b7546 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,3 +53,58 @@ jobs: - name: Run CI checks run: just ci + + release-package: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up declared tools without caches + uses: ./.github/actions/setup-tools # zizmor: ignore[self-repository] actionlint 1.7.12 does not accept $/... + with: + cache: "false" + - name: Verify default and exact Cargo packages + run: just release-package + + 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 + permissions: + contents: read + actions: read + attestations: write + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version-file: pyproject.toml + python-version-file: .python-version + enable-cache: false + - name: Capture successful native validation identities + env: + GH_TOKEN: ${{ github.token }} + run: uv run --locked python -m tooling.release_evidence seal --output target/release-evidence/validation.json + - name: Sign original main validation evidence + id: validation + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: target/release-evidence/validation.json + create-storage-record: false + - name: Retain the signature bundle + env: + VALIDATION_BUNDLE: ${{ steps.validation.outputs.bundle-path }} + run: cp "$VALIDATION_BUNDLE" target/release-evidence/validation.sigstore.json + - name: Upload signed validation evidence + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-evidence-${{ github.run_id }}-${{ github.run_attempt }} + path: target/release-evidence/ + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0a15869..ffce6f4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -39,6 +39,10 @@ jobs: with: languages: ${{ matrix.language }} queries: security-extended + # Deliberate violations are exercised by just semgrep-test. + config: | + paths-ignore: + - tests/semgrep/** - name: Initialize CodeQL (Rust) if: matrix.language == 'rust' @@ -47,6 +51,10 @@ jobs: languages: ${{ matrix.language }} build-mode: none queries: security-extended + # Keep ordinary tests in scope; exclude only rule fixtures. + config: | + paths-ignore: + - tests/semgrep/** - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 3330dbb..125abf9 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -36,7 +36,6 @@ jobs: ".github/actions/setup-tools/action.yml", ".github/workflows/audit.yml", ".github/workflows/benchmarks.yml", - ".github/workflows/ci.yml", ".github/workflows/codecov.yml", ".github/workflows/codeql.yml", ".github/workflows/dependabot-auto-merge.yml", diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index 75a0b28..053a724 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -27,6 +27,9 @@ jobs: permissions: contents: read checks: read + actions: read + outputs: + ci-evidence: ${{ steps.evidence.outputs.identity }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -45,8 +48,14 @@ jobs: RELEASE_TAG: ${{ github.event.release.tag_name }} run: just release-gate "$RELEASE_TAG" - - name: Verify Cargo packaging without uploading - run: just release-package + - name: Verify original signed main CI evidence + id: evidence + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + identity=$(just --quiet release-evidence) + printf 'identity=%s\n' "$identity" >> "$GITHUB_OUTPUT" publish: needs: verify @@ -56,6 +65,7 @@ jobs: permissions: contents: read checks: read + actions: read id-token: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -73,16 +83,19 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.event.release.tag_name }} + CI_EVIDENCE: ${{ needs.verify.outputs.ci-evidence }} run: | set -euo pipefail just release-gate "$RELEASE_TAG" + test -n "$CI_EVIDENCE" + just release-evidence "$CI_EVIDENCE" uv run --locked --no-sync research-repo-tools release registry "$RELEASE_TAG" --expect absent - name: Obtain a short-lived crates.io token id: auth uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 - # The verify job built default/exact packages without upload credentials. + # Original main CI built default/exact packages without upload credentials. # Repackage the same clean, revalidated commit without running build scripts. - name: Package and upload verified source without building env: diff --git a/README.md b/README.md index 741d173..f377e69 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,6 @@ [![Downloads](https://badgen.net/crates/d/la-stack)](https://crates.io/crates/la-stack) [![License](https://badgen.net/github/license/acgetchell/la-stack)](https://github.com/acgetchell/la-stack/blob/main/LICENSE) [![Docs.rs](https://docs.rs/la-stack/badge.svg)](https://docs.rs/la-stack) - [![CI](https://github.com/acgetchell/la-stack/actions/workflows/ci.yml/badge.svg)](https://github.com/acgetchell/la-stack/actions/workflows/ci.yml) [![CodeQL][codeql-badge]][codeql-workflow] [![zizmor][zizmor-badge]][zizmor-workflow] @@ -63,8 +62,9 @@ while keeping the API intentionally small and explicit. encode lengths and angles and support simplex/facet volume calculations; see [Gram matrices and geometric measures][refs-gram]. Each independent dot product is checked once; - rounding has no certified error bound, and positive definiteness or affine - independence must still be established by factorization or the caller. + rounding has no certified error bound. Positive definiteness or affine + independence requires independent proof from the caller; successful + floating-point factorization is not a certificate. - `Interval` and `IntervalMatrix` for outward-rounded, proof-bearing determinant filters through D=7 - `Ldlt` for no-pivot factorization intended for exactly diff --git a/SECURITY.md b/SECURITY.md index 8bb68ef..dbc178c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -118,11 +118,11 @@ assignment in that file. Other assignments and paths remain checked. ### Python tool dependencies -Semgrep 1.180.0 accepts `pyjwt[crypto]>=2.15.0,<3`, so the older security override +Semgrep 1.182.0 accepts `pyjwt[crypto]>=2.15.0,<3`, so the older security override is retired. `tooling/semgrep/uv.lock` retains patched PyJWT 2.15.1 and its crypto dependencies. Validate native Semgrep scans when changing the scanner or lock. -Semgrep 1.180.0 pins MCP 1.29.0, which is affected by +Semgrep 1.182.0 pins MCP 1.29.0, which is affected by [GHSA-84m7-p3x7-pcfv](https://github.com/advisories/GHSA-84m7-p3x7-pcfv). The isolated environment overrides that pin with `mcp>=1.30.0,<2`, retaining the patched 1.x API. Remove the override when a released Semgrep dependency diff --git a/benches/linear_form.rs b/benches/linear_form.rs index 2b04fe5..3285be0 100644 --- a/benches/linear_form.rs +++ b/benches/linear_form.rs @@ -2,6 +2,7 @@ //! Criterion coverage for certified dot products and affine differences. +use core::array::from_fn; use std::hint::black_box; use criterion::Criterion; @@ -50,13 +51,13 @@ fn prepared_benchmarks(criterion: &mut Criterion) { ( "sparse", [2.0; D], - core::array::from_fn(|i| if i == 0 { 1.0 } else { 0.0 }), + from_fn(|i| if i == 0 { 1.0 } else { 0.0 }), [0.0; D], Some(true), ), ( "cancellation", - core::array::from_fn(|i| { + from_fn(|i| { if i == 0 { 1.0 } else if i == 1 { @@ -131,18 +132,14 @@ fn batch_benchmarks(criterion: &mut Criterion) { ("origin", [0.0; D]), ( "translated", - core::array::from_fn(|i| { - f64::from(u32::try_from(i + 1).or_abort("small dimension")) / 8.0 - }), + from_fn(|i| f64::from(u32::try_from(i + 1).or_abort("small dimension")) / 8.0), ), ] { let axis_rows = [2048.0; D]; - let positive_rows: [[f64; D]; D] = core::array::from_fn(|i| { - core::array::from_fn(|j| shared_rows[j] + if i == j { 1.0 } else { 0.0 }) - }); - let negative_rows: [[f64; D]; D] = core::array::from_fn(|i| { - core::array::from_fn(|j| shared_rows[j] - if i == j { 1.0 } else { 0.0 }) - }); + let positive_rows: [[f64; D]; D] = + from_fn(|i| from_fn(|j| shared_rows[j] + if i == j { 1.0 } else { 0.0 })); + let negative_rows: [[f64; D]; D] = + from_fn(|i| from_fn(|j| shared_rows[j] - if i == j { 1.0 } else { 0.0 })); let axis = Vector::try_new(axis_rows).or_abort("batch axis"); let shared = Vector::try_new(shared_rows).or_abort("batch shared"); let positive = positive_rows.map(|row| Vector::try_new(row).or_abort("positive vertex")); diff --git a/docs/RELEASING.md b/docs/RELEASING.md index e3fa5fe..ed4f91b 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -21,6 +21,52 @@ midnight alone requires no update: the `declared` date policy allows preparation and tagging on different UTC days when citation and changelog dates agree. Repeated `performance-release` runs can produce different measurements. +## Contents + +- [Reusable CI evidence](#reusable-ci-evidence) +- [Prepare the environment](#prepare-the-environment) +- [Step 1: Prepare the release PR](#step-1-prepare-the-release-pr) +- [Step 2: Publish after the PR is merged](#step-2-publish-after-the-pr-is-merged) +- [Configure or verify Trusted Publishing](#configure-or-verify-trusted-publishing) + +## Reusable CI evidence + +The publishing process uses the installed `research-repo-tools==0.2.3` +validation helpers. `tooling/release_evidence.py` supplies the Cargo-specific +policy; it does not use the shared package's wheel/sdist release commands. +Every successful protected `main` push produces signed evidence after all three +native `just ci` jobs and the default/exact Cargo packaging job succeed. +The `release-evidence` job signs their exact commit, run, attempt, job IDs and +package identity, then retains the manifest and Sigstore bundle in an immutable +Actions artifact for 30 days. + +`just release-evidence` verifies that evidence for the current checkout. It +requires one unambiguous successful `push` run on `main` from this repository's +active CI workflow, every required job and step from that same attempt, and a +nonexpired artifact retrieved by immutable ID. The shared downloader checks its +authoritative ZIP size and SHA-256 digest and exact file inventory. GitHub CLI +then verifies the signed manifest's workflow, source commit, `main` ref, hosted +runner, push event, run and attempt. The manifest must match the live job and +package identities. Pending matching runs receive a bounded wait; missing, +failed, ambiguous or expired evidence stops publication. PR, manual dispatch, +fork, local and partial-rerun results cannot replace it. + +The publishing workflow reuses these completed checks, captures the run/attempt +and artifact ID/digest, and revalidates that same identity after deployment +approval. It also retains the independent release metadata, exact-commit SARIF, +durable Criterion asset, immutable GitHub release and registry-absence gates. +Release benchmarks remain a separate scientific measurement workflow; CI +evidence does not replace their full sampling or durable archive. + +This reuses validation of the source. Cargo still packages the clean release +commit when uploading, so it does not promise byte-for-byte promotion of the +dry-run `.crate` archive. The shared tooling's Python distribution promotion +process has a different artifact contract. + +Use a current GitHub CLI with `gh attestation verify` and the signer/source +constraints used by the adapter. See GitHub's +[attestation verification options](https://cli.github.com/manual/gh_attestation_verify). + ## Prepare the environment Set the target tag once: @@ -189,14 +235,16 @@ inputs, named metric/statistic/axis views, output ownership and methodology. ```bash just ci -cargo publish --locked --allow-dirty --dry-run +cargo publish --locked --package la-stack --registry crates-io --allow-dirty --dry-run +cargo publish --locked --package la-stack --registry crates-io --allow-dirty --dry-run --features exact ``` `just ci` includes the lockfile, citation, documentation, test, and benchmark compile checks. -The preparation dry-run permits the uncommitted release artifacts. After review -and merge, `just release-package` verifies the clean release source with both -default and `exact` features and explicitly selects only `la-stack`. +The preparation dry-runs permit the uncommitted release artifacts. After review +and merge, main CI runs `just release-package` on the clean release source with +both default and `exact` features and explicitly selects only `la-stack`. +Those hosted results become the reusable packaging evidence. ### 7. Review, stage, and commit the release artifacts @@ -273,7 +321,7 @@ git push origin "$TAG" ### 4. Verify the release commit ```bash -just release-package +just release-evidence ``` Require successful native `CI` builds on Linux, macOS and Windows, plus Clippy @@ -283,9 +331,12 @@ all-feature tests, interval and linear-form regressions, examples and benchmark compilation. The publishing policy in `pyproject.toml` binds all five check names to the GitHub Actions App. CodeRabbit and human review remain protected-PR requirements; CodeRabbit does not run on the merged `main` commit. Missing, -pending, cancelled or failed native checks block publication. If a bot merge did -not trigger push CI, dispatch the existing CI/security workflows on `main` and -verify their `headSha` equals the release commit. +pending, cancelled or failed native checks block publication. The evidence +command also checks the original main CI packaging and signing jobs. If no +eligible push run exists, stop and correct the event-delivery problem before +selecting the release commit. A manually dispatched CI run cannot produce +release evidence. A failed or expired main-push run requires a complete rerun +of that original run, with fresh evidence from the new attempt. ### 5. Create the draft GitHub release @@ -361,20 +412,22 @@ gh run list --workflow publish-crates.yml --limit 5 This command authorizes GitHub Release publication. The shared gate checks the clean checkout, stable tag/package metadata, remote tag commit, protected default branch ancestry, successful exact-commit checks and required baseline asset. -The local recipe also requires the release title and body to match the tag and -generated notes. Human authentication is essential: publishing with a workflow's +The local recipe also verifies signed main CI evidence and requires the release +title and body to match the tag and generated notes. Human authentication is essential: publishing with a workflow's `GITHUB_TOKEN` does not trigger the separate `release: published` workflow. The publishing workflow checks out `github.sha`, rechecks the published event's -repository, release ID, tag/ref/commit and evidence, then runs `just release-package`. +repository, release ID, tag/ref/commit and evidence, then verifies the original +signed main CI evidence without repeating the native checks or packaging builds. It also reads the captured release ID to require GitHub's immutable state. After it succeeds, approve the `crates-io` environment deployment in GitHub. -The upload job rechecks the gate and requires proven absence of the exact registry -version after approval, then obtains a short-lived OIDC token. Only that job has -`id-token: write`; it has no repository write permission or dependency caches. +The upload job rechecks the same selected run, attempt and artifact ID/digest, +the release gate, and proven absence of the exact registry version after approval, +then obtains a short-lived OIDC token. Within the publishing workflow, only that +job has `id-token: write`; it has no repository write permission or dependency caches. Uploads for `la-stack` are serialized without cancellation. -The verification job builds the default and `exact` packages without upload +The original main CI job builds the default and `exact` packages without upload credentials. After approval, Cargo repackages the same clean, revalidated commit with `--locked --no-verify`, so build scripts do not run with the crates.io token. The dry-run archive is not transferred between jobs, and byte-identical archives @@ -432,6 +485,16 @@ regression checks and hosted evidence requirements. ### Recovering a failed run +For reusable CI evidence, rerun **all** jobs in the original main-push CI run. +Failed-jobs-only reruns omit producers or validators from the new attempt and +are rejected. A complete rerun creates a new signed artifact; it never changes +the earlier artifact. If CI was rerun while deployment approval was pending, +the pinned old attempt is stale: restart the publishing workflow's verification +and obtain approval for the newly selected evidence. Do not bypass expiration +or substitute manual-dispatch results. Local builds are useful diagnostics but +cannot renew hosted attestations. + +The following recovery rules apply separately to `Release Benchmarks`. If preflight or the producer failed, rerun all jobs with `gh run rerun ` or dispatch again. The producer requires a preflight from the current attempt; rerunning failed benchmark jobs alone stops before tool setup or measurement @@ -507,8 +570,9 @@ gates still apply after deployment approval. Add only the pinned `rust-lang/crates-io-auth-action` entry from `.github/settings/actions-selected.json` to the live selected-action allowlist, preserving newer entries. This reviewed v1.0.5 pin comes from the shared consumer template; update it through a reviewed -dependency PR and reconcile the allowlist together. The publishing workflow is -excluded from Dependabot automatic approval so these changes require maintainer +dependency PR and reconcile the allowlist together. Both the publishing workflow +and the CI workflow that signs reusable evidence are excluded from Dependabot +automatic approval so these changes require maintainer review. Do not add a long-lived `CARGO_REGISTRY_TOKEN` secret. diff --git a/docs/code_organization.md b/docs/code_organization.md index 027bcbc..cd82764 100644 --- a/docs/code_organization.md +++ b/docs/code_organization.md @@ -97,6 +97,9 @@ The [Cargo manifest](../Cargo.toml) owns feature and dependency declarations. `just test-python` or the aggregate validation workflows. - `examples/` contains complete runnable workflows. README mirrors and public guide doctests live in `src/lib.rs`. +- `tooling/examples.toml` owns the Cargo example package, feature overrides and + time budgets. The shared runner discovers every binary example from metadata; + executable numerical assertions stay in `examples/`. - `examples/vector_angles.rs` demonstrates fixed-vector and borrowed-slice angles, independent magnitude scaling, and subnormal separations. @@ -137,6 +140,10 @@ wrappers. `tooling/tests/test_review_integration.py` owns consumer wiring checks with local stubs; the [contributor review workflow](../CONTRIBUTING.md#coderabbit-review) owns prerequisites and invocation policy. The [justfile](../justfile) owns executable development workflows. +Shared complete-inventory Python and file runners own discovery and bounded +execution; repository lint policy and archive exclusions stay in the recipes +and native tool configuration. The [capability inventory](../tooling/README.md#shared-capability-adoption) +records the v0.2.3 adoption boundary. `osv-scanner.toml` owns temporary advisory-specific dependency exceptions, and `.gitleaks.toml` owns narrow secret-scan false-positive exceptions. Their rationale @@ -150,6 +157,11 @@ alongside actual recipe sequencing and native managed execution. The `.github/actions/setup-tools/action.yml` composite synchronizes the locked PyPI package and exports verified paths for CI. Release callers disable its caches. +`.github/actions-allowlist.json` owns approved action identities for local +validation and updates. Historical payloads under `.github/settings/` describe +live GitHub settings separately; consult the +[rollout guide](dev/MANAGING_CHANGES.md#dependabot-approval-rollout) before applying them. + Release metadata, version checks, Markdown line checks, and Semgrep fixture validation also belong to the shared CLI. Consumer policy stays in `pyproject.toml`; `tooling/tests/test_maintenance_integration.py` verifies the @@ -201,7 +213,7 @@ freshness checks and tagged evidence links. Static named configurations under `tooling/plots/{non-exact,retained,scratch}/` preserve eight metrics, mean/median and linear/log choices with explicit input ownership. -Both production Python adapters and the root `scripts/` directory are retired. +The former performance Python adapters and the root `scripts/` directory are retired. The installed CLI owns orchestration, validation and publication transactions. Working-tree measurements now include nonignored untracked regular files. `tooling/tests/test_performance_workflow.py` exercises actual recipes and @@ -224,8 +236,12 @@ outputs remain outside production scans. `.github/actions/prepare-release-benchmarks/action.yml` groups tool installation, input validation, and inventory under the release workflow's shared setup timeout. -`.github/workflows/publish-crates.yml` owns the reviewed-release event, native -Cargo dry-run, deployment approval and OIDC upload. The publishing declaration +`.github/workflows/ci.yml` owns native checks, default/exact Cargo dry-runs and +signed main-push validation evidence. `tooling/release_evidence.py` supplies the +Cargo-specific policy around shared run/job validation and artifact retrieval; +`tooling/tests/test_release_evidence.py` owns its provider-boundary regressions. +`.github/workflows/publish-crates.yml` owns the reviewed-release event, reuse of +that exact CI attempt and artifact, deployment approval and OIDC upload. The publishing declaration in `pyproject.toml` selects `la-stack`, native checks and the Criterion asset; shared release gates own generic identity, ancestry and registry checks. `tooling/tests/test_release_publishing.py` owns installed-package consumer checks diff --git a/docs/dev/MANAGING_CHANGES.md b/docs/dev/MANAGING_CHANGES.md index cf94065..e5c3964 100644 --- a/docs/dev/MANAGING_CHANGES.md +++ b/docs/dev/MANAGING_CHANGES.md @@ -120,9 +120,9 @@ tasks related without inventing a blocking dependency. ## Dependabot approval rollout -The caller in `.github/workflows/dependabot-auto-merge.yml` adopts the same -reviewed research-repo-tools revision as markov-chain-monte-carlo: -[`cbb2ea6`](https://github.com/acgetchell/research-repo-tools/commit/cbb2ea6dee8866b3f0547bca935aef48fdd71707). +The caller in `.github/workflows/dependabot-auto-merge.yml` pins the reviewed +research-repo-tools approval workflow at +[`b972cbf`](https://github.com/acgetchell/research-repo-tools/commit/b972cbfd717319abaa68d9b1373711ad6f163294). It replaces the owner-issued CodeRabbit request that left bot PRs without an approving review (#258). A successful CodeRabbit status on a skipped review does not supply approval; the provider's underlying failure to process owner commands @@ -135,7 +135,7 @@ deduplication belong to the shared workflow. It never checks out PR code or uses personal tokens. `.github/dependabot.yml` continues to own dependency eligibility. See the [shared approval contract][shared-approval]. -[shared-approval]: https://github.com/acgetchell/research-repo-tools/blob/cbb2ea6dee8866b3f0547bca935aef48fdd71707/docs/AUTOMATING_DEPENDABOT.md +[shared-approval]: https://github.com/acgetchell/research-repo-tools/blob/b972cbfd717319abaa68d9b1373711ad6f163294/docs/AUTOMATING_DEPENDABOT.md The settings payloads under `.github/settings/` capture the October 1, 2026 rollout. They enable Actions approvals with read-only default permissions, add @@ -167,8 +167,11 @@ required checks and threads pass, and continued manual review for ineligible updates. Record PR, head, approval, workflow-run, and merge links. Installing the Python package does not deploy or configure this GitHub workflow. -Merges using `GITHUB_TOKEN` may not trigger push workflows. Dispatch `ci.yml` -against `main` and verify the run's `headSha` equals the merged commit before -claiming default-branch validation. Remove `CODERABBIT_REVIEW_TOKEN` from Actions +Merges using `GITHUB_TOKEN` may not trigger push workflows. A manual `ci.yml` +dispatch against `main` can diagnose the merged commit, but cannot produce +release-eligible signed evidence. Before selecting a release commit, require a +successful main-push run as described in [Releasing](../RELEASING.md#reusable-ci-evidence). +The CI signer and crates.io publishing workflows require manual dependency review. +Remove `CODERABBIT_REVIEW_TOKEN` from Actions and Dependabot secrets only after the old caller has been replaced and no other workflow consumes it. Local opt-in CodeRabbit CLI review remains separate. diff --git a/docs/performance/README.md b/docs/performance/README.md index 385da13..70d502e 100644 --- a/docs/performance/README.md +++ b/docs/performance/README.md @@ -1,9 +1,11 @@ # Legacy Local Benchmark Summaries This directory preserves historical local measurement summaries across -`just clean`. The last published report and README chart used these inputs. -New measurements use shared complete-run evidence under `docs/performance-runs/`; -the [Benchmarking guide](../BENCHMARKING.md) owns the current workflow. +`just clean`. The retained v0.4.6 report and README chart used these inputs. +The current v0.5.0-versus-v0.4.6 report and chart use shared complete-run evidence +under [`docs/performance-runs/`](../performance-runs/README.md). New measurements +use that complete-run format; the [Benchmarking guide](../BENCHMARKING.md) +owns the current workflow. ## Contents diff --git a/justfile b/justfile index 21665cd..7c503cd 100644 --- a/justfile +++ b/justfile @@ -8,6 +8,9 @@ set shell := ["bash", "-euo", "pipefail", "-c"] _run := "uv run --locked --no-sync --no-python-downloads research-repo-tools toolchain run --" +# One active-document selection for formatting and checks; retain measured history. +_markdown_inputs := "--include '*.md' --exclude CHANGELOG.md --exclude 'docs/archive/**' --exclude 'docs/archives/changelog/**' --exclude docs/performance.md --exclude 'docs/performance-runs/**'" + # Coverage (cargo-llvm-cov) # # Common cargo-llvm-cov arguments for all coverage runs. @@ -59,19 +62,7 @@ _release-notes-check tag: # GitHub Actions workflow validation action-lint: _ensure-actionlint - #!/usr/bin/env bash - set -euo pipefail - files=() - while IFS= read -r -d '' file; do - if [ -f "$file" ]; then - files+=("$file") - fi - done < <(git ls-files -co --exclude-standard -z -- '.github/workflows/*.yml' '.github/workflows/*.yaml') - if [ "${#files[@]}" -gt 0 ]; then - printf '%s\0' "${files[@]}" | xargs -0 uv run --locked actionlint - else - echo "No workflow files found to lint." - fi + uv run --locked research-repo-tools files run --include '.github/workflows/*.yml' --include '.github/workflows/*.yaml' -- actionlint # Audit the repository's Python and Rust lockfiles with the managed OSV scanner. audit: @@ -228,7 +219,7 @@ check-fast: {{ _run }} cargo check # Run the complete local CI validation, tests, examples, and benchmark compilation. -ci: action-lint zizmor markdown-check spell-check docs-version-check changelog-check toml-parse-check toml-fmt-check toml-lint yaml-fmt-check yaml-lint citation-check validate-json justfile-fmt-check python-format-check python-lint python-fixture-lint python-typecheck test-python cargo-lock-check fmt-check clippy-all-targets doc-check semgrep semgrep-test unused-deps shell-check test-rust-ci test-doc test-doc-exact bench-compile examples +ci: action-lint workflow-allowlist-check zizmor markdown-check spell-check docs-version-check changelog-check toml-parse-check toml-fmt-check toml-lint yaml-fmt-check yaml-lint citation-check validate-json justfile-fmt-check python-check test-python cargo-lock-check fmt-check clippy-all-targets doc-check semgrep semgrep-test unused-deps shell-check test-rust-ci test-doc test-doc-exact bench-compile examples @echo "๐ŸŽฏ CI checks complete!" # Validate CITATION.cff against the Citation File Format schema. @@ -272,6 +263,10 @@ coverage-ci: tools-check mkdir -p coverage {{ _run }} cargo llvm-cov nextest {{ _coverage_base_args }} --cobertura --output-path coverage/cobertura.xml -P coverage +# Summarize an existing Cobertura report without rerunning coverage. +coverage-report report="coverage/cobertura.xml": python-sync + uv run --locked research-repo-tools coverage report --report {{ quote(report) }} + # Documentation build checks for the default and exact-feature public APIs. doc-check: RUSTDOCFLAGS='-D warnings' {{ _run }} cargo doc --no-deps @@ -283,24 +278,7 @@ docs-version-check: python-sync # Build and run all library examples, including exact arithmetic. examples: - #!/usr/bin/env bash - set -euo pipefail - {{ _run }} cargo build --features exact --examples - - exe_suffix="" - if [[ "${OS:-}" == "Windows_NT" ]]; then - exe_suffix=".exe" - fi - - target_dir="${CARGO_TARGET_DIR:-target}" - - shopt -s nullglob - for example_path in examples/*.rs; do - [[ -f "${example_path}" ]] || continue - example="${example_path##*/}" - example="${example%.rs}" - "${target_dir}/debug/examples/${example}${exe_suffix}" - done + {{ _run }} research-repo-tools validation cargo-examples tooling/examples.toml # Fix (mutating): apply formatters/auto-fixes fix: toml-fix fmt python-fix shell-fix markdown-fix yaml-fix @@ -315,7 +293,7 @@ fmt-check: {{ _run }} cargo fmt --all -- --check # Check workflow syntax and security findings. -github-actions-check: action-lint zizmor +github-actions-check: action-lint workflow-allowlist-check zizmor @echo "โœ… GitHub Actions checks complete!" # File validation @@ -338,31 +316,8 @@ lint-docs: markdown-ci docs-version-check changelog-check # Check active Markdown formatting, local links, and line lengths. markdown-check: tools-check _ensure-uv - #!/usr/bin/env bash - set -euo pipefail - files=() - raw_files=() - while IFS= read -r -d '' file; do - case "$file" in - CHANGELOG.md|docs/archive/*|docs/archives/changelog/*|docs/performance.md|docs/performance-runs/*) continue ;; - esac - if [ -f "$file" ]; then - files+=("$file") - # Shared README publication retains full immutable evidence URLs. - # rumdl still checks its prose, syntax, links, and rendered line length. - if [ "$file" != README.md ]; then - raw_files+=("$file") - fi - fi - done < <(git ls-files -co --exclude-standard -z -- '*.md') - if [ "${#files[@]}" -gt 0 ]; then - printf '%s\0' "${files[@]}" | xargs -0 -n100 {{ _run }} rumdl check - if [ "${#raw_files[@]}" -gt 0 ]; then - uv run --locked --group dev research-repo-tools docs check-lines "${raw_files[@]}" - fi - else - echo "No markdown files found to check." - fi + {{ _run }} research-repo-tools files run {{ _markdown_inputs }} -- rumdl check + uv run --locked research-repo-tools files run {{ _markdown_inputs }} --exclude README.md -- research-repo-tools docs check-lines # Check active Markdown and spelling without changing files. markdown-ci: markdown-check spell-check @@ -370,23 +325,7 @@ markdown-ci: markdown-check spell-check # Format active Markdown files; preserve generated and archived records. markdown-fix: tools-check - #!/usr/bin/env bash - set -euo pipefail - files=() - while IFS= read -r -d '' file; do - case "$file" in - CHANGELOG.md|docs/archive/*|docs/archives/changelog/*|docs/performance.md|docs/performance-runs/*) continue ;; - esac - if [ -f "$file" ]; then - files+=("$file") - fi - done < <(git ls-files -co --exclude-standard -z -- '*.md') - if [ "${#files[@]}" -gt 0 ]; then - echo "๐Ÿ“ rumdl check --fix (${#files[@]} files)" - printf '%s\0' "${files[@]}" | xargs -0 -n100 {{ _run }} rumdl check --fix - else - echo "No markdown files found to format." - fi + {{ _run }} research-repo-tools files run {{ _markdown_inputs }} -- rumdl check --fix # Render the retained release report offline, with independent eligibility checks. performance-doc: python-sync @@ -426,16 +365,16 @@ plot-vs-linalg current_tag current_revision baseline_tag baseline_revision view= {{ _run }} uv run --locked research-repo-tools performance publish {{ quote("tooling/plots/" + input + "/" + view + ".toml") }} --artifacts-only --current-release {{ quote(current_tag) }} --current-revision {{ quote(current_revision) }} --baseline-release {{ quote(baseline_tag) }} --baseline-revision {{ quote(baseline_revision) }} # Check Python formatting, lint, fixtures, and types. -python-check: python-format-check python-lint python-fixture-lint python-typecheck +python-check: python-sync + uv run --locked research-repo-tools python check # Check Python tooling and run its consumer integration tests. -python-ci: python-format-check python-lint python-fixture-lint python-typecheck test-python +python-ci: python-check test-python @echo "โœ… Python checks complete!" # Apply Python lint fixes and formatting. python-fix: python-sync - uv run --locked ruff check tooling/tests/ --fix - uv run --locked ruff format tooling/tests/ tests/semgrep/scripts/ + uv run --locked research-repo-tools python fix # Lint deliberate Python fixtures with their declared exceptions. python-fixture-lint: python-sync @@ -443,11 +382,11 @@ python-fixture-lint: python-sync # Check Python formatting, including static-analysis fixtures. python-format-check: python-sync - uv run --locked ruff format --check tooling/tests/ tests/semgrep/scripts/ + uv run --locked research-repo-tools files run --include '*.py' --include '*.pyi' -- ruff format --check --no-force-exclude --no-respect-gitignore # Lint Python support tools and their tests. python-lint: python-sync - uv run --locked ruff check tooling/tests/ + uv run --locked research-repo-tools files run --include '*.py' --include '*.pyi' -- ruff check --no-fix --no-fix-only --no-force-exclude --no-respect-gitignore # Synchronize the locked Python development environment. python-sync: _ensure-uv @@ -455,7 +394,11 @@ python-sync: _ensure-uv # Check all Python support and fixture types; fail on every diagnostic. python-typecheck: python-sync - uv run --locked ty check tooling/tests/ tests/semgrep/scripts/ --error all + uv run --locked research-repo-tools python typecheck + +# Verify signed default/exact packaging and native CI for HEAD; optionally pin the evidence identity. +release-evidence identity="": + uv run --locked --no-sync python -m tooling.release_evidence verify --identity {{ quote(identity) }} # Require the release event, reviewed commit, native checks, assets, and notes. release-gate tag: @@ -473,7 +416,7 @@ release-package: {{ _run }} cargo publish --locked --package la-stack --registry crates-io --dry-run --features exact # Approve the benchmark-equipped draft as the authenticated maintainer. -release-publish tag: (_release-notes-check tag) +release-publish tag: (_release-notes-check tag) release-evidence uv run --locked --no-sync research-repo-tools release publish {{ quote(tag) }} --approve # Verify the published GitHub release, required assets, and exact crates.io version. @@ -518,56 +461,16 @@ setup-tools: _ensure-gh _ensure-jq # Check shell script lint and formatting. shell-check: _ensure-shellcheck _ensure-shfmt - #!/usr/bin/env bash - set -euo pipefail - files=() - while IFS= read -r -d '' file; do - if [ -f "$file" ]; then - files+=("$file") - fi - done < <(git ls-files -co --exclude-standard -z -- '*.sh') - if [ "${#files[@]}" -gt 0 ]; then - printf '%s\0' "${files[@]}" | xargs -0 -n4 uv run --locked shellcheck -x - printf '%s\0' "${files[@]}" | xargs -0 uv run --locked shfmt -d - else - echo "No shell files found to check." - fi + uv run --locked research-repo-tools files run --include '*.sh' --batch-size 4 -- shellcheck -x + uv run --locked research-repo-tools files run --include '*.sh' -- shfmt -d # Format maintained shell scripts. shell-fix: _ensure-shfmt - #!/usr/bin/env bash - set -euo pipefail - files=() - while IFS= read -r -d '' file; do - if [ -f "$file" ]; then - files+=("$file") - fi - done < <(git ls-files -co --exclude-standard -z -- '*.sh') - if [ "${#files[@]}" -gt 0 ]; then - echo "๐Ÿงน shfmt -w (${#files[@]} files)" - printf '%s\0' "${files[@]}" | xargs -0 -n1 uv run --locked shfmt -w - else - echo "No shell files found to format." - fi + uv run --locked research-repo-tools files run --include '*.sh' --batch-size 1 -- shfmt -w # Spell check (typos) spell-check: tools-check - #!/usr/bin/env bash - set -euo pipefail - files=() - # Check every tracked file plus untracked, non-ignored additions. This keeps - # clean CI checkouts covered while validating new files before they are staged. - while IFS= read -r -d '' file; do - if [ -f "$file" ]; then - files+=("$file") - fi - done < <(git ls-files -co --exclude-standard -z) - if [ "${#files[@]}" -gt 0 ]; then - # Exclude typos.toml itself: it intentionally contains allowlisted fragments. - printf '%s\0' "${files[@]}" | xargs -0 -n100 {{ _run }} typos --config typos.toml --force-exclude --exclude typos.toml -- - else - echo "No files found to spell-check." - fi + {{ _run }} research-repo-tools files run --exclude typos.toml -- typos --config typos.toml --force-exclude -- # Create an annotated git tag from the CHANGELOG.md section for the given version tag version: python-sync @@ -629,72 +532,29 @@ toml-check: toml-parse-check toml-fmt-check toml-lint # Format maintained TOML files. toml-fix: tools-check - #!/usr/bin/env bash - set -euo pipefail - files=() - while IFS= read -r -d '' file; do - if [ -f "$file" ]; then - files+=("$file") - fi - done < <(git ls-files -co --exclude-standard -z -- '*.toml') - if [ "${#files[@]}" -gt 0 ]; then - {{ _run }} taplo fmt "${files[@]}" - else - echo "No TOML files found to format." - fi + {{ _run }} research-repo-tools files run --include '*.toml' -- taplo fmt # Check TOML formatting without changing files. toml-fmt-check: tools-check - #!/usr/bin/env bash - set -euo pipefail - files=() - while IFS= read -r -d '' file; do - if [ -f "$file" ]; then - files+=("$file") - fi - done < <(git ls-files -co --exclude-standard -z -- '*.toml') - if [ "${#files[@]}" -gt 0 ]; then - {{ _run }} taplo fmt --check "${files[@]}" - else - echo "No TOML files found to check." - fi + {{ _run }} research-repo-tools files run --include '*.toml' -- taplo fmt --check # Lint maintained TOML files. toml-lint: tools-check - #!/usr/bin/env bash - set -euo pipefail - files=() - while IFS= read -r -d '' file; do - if [ -f "$file" ]; then - files+=("$file") - fi - done < <(git ls-files -co --exclude-standard -z -- '*.toml') - if [ "${#files[@]}" -gt 0 ]; then - {{ _run }} taplo lint "${files[@]}" - else - echo "No TOML files found to lint." - fi + {{ _run }} research-repo-tools files run --include '*.toml' -- taplo lint # Validate TOML syntax independently with Python's standard-library parser. toml-parse-check: python-sync - #!/usr/bin/env bash - set -euo pipefail - files=() - while IFS= read -r -d '' file; do - if [ -f "$file" ]; then - files+=("$file") - fi - done < <(git ls-files -co --exclude-standard -z -- '*.toml') - if [ "${#files[@]}" -gt 0 ]; then - uv run --locked python -c 'import pathlib, sys, tomllib; [tomllib.loads(pathlib.Path(path).read_text(encoding="utf-8")) for path in sys.argv[1:]]' "${files[@]}" - else - echo "No TOML files found to parse." - fi + uv run --locked research-repo-tools files run --include '*.toml' -- uv run --locked python -c 'import pathlib, sys, tomllib; [tomllib.loads(pathlib.Path(path).read_text(encoding="utf-8")) for path in sys.argv[1:]]' # Check installed tools without synchronization, downloads, or installation. tools-check: uv run --locked --no-sync --no-python-downloads research-repo-tools toolchain check +# Preview obsolete managed installations; pass --apply for explicit removal. +[positional-arguments] +tools-clean *args: + uv run --locked --no-sync research-repo-tools toolchain clean "$@" + # Export verified managed paths to GITHUB_ENV in hosted workflows. tools-export: uv run --locked --no-sync --no-python-downloads research-repo-tools toolchain export @@ -707,6 +567,11 @@ unused-deps: tools-check update: update-tools update-dependencies @echo "โœ… Repository dependencies and tools updated." +# Update explicitly selected Actions pins; --dry-run and --check only preview. +[positional-arguments] +update-actions +args: + uv run --locked research-repo-tools actions update --policy .github/actions-allowlist.json "$@" + # num-bigint and num-rational share public types and must advance together. update-cargo-dependencies: uv run --locked --only-group tooling --inexact research-repo-tools toolchain run -- cargo upgrade --incompatible allow --exclude num-bigint --exclude num-rational @@ -740,71 +605,28 @@ update-version tag *args: _ensure-gh python-sync # Check maintained JSON syntax. validate-json: _ensure-jq - #!/usr/bin/env bash - set -euo pipefail - files=() - while IFS= read -r -d '' file; do - if [ -f "$file" ]; then - files+=("$file") - fi - done < <(git ls-files -co --exclude-standard -z -- '*.json') - if [ "${#files[@]}" -gt 0 ]; then - printf '%s\0' "${files[@]}" | xargs -0 -n1 jq empty - else - echo "No JSON files found to validate." - fi + uv run --locked research-repo-tools files run --include '*.json' --batch-size 1 -- jq empty + +# Validate external workflow and composite-action dependencies against the repository allowlist. +workflow-allowlist-check: python-sync + uv run --locked research-repo-tools actions allowlist --policy .github/actions-allowlist.json .github/workflows + # The v0.2.3 parser accepts workflows only; Semgrep checks composite actions. + uv run --locked --project tooling/semgrep semgrep --metrics off --error --strict --timeout 30 --config semgrep.yaml .github/actions # Check YAML and CFF formatting and lint. yaml-check: yaml-fmt-check yaml-lint # Format maintained YAML and CFF files. yaml-fix: tools-check - #!/usr/bin/env bash - set -euo pipefail - files=() - while IFS= read -r -d '' file; do - if [ -f "$file" ]; then - files+=("$file") - fi - done < <(git ls-files -co --exclude-standard -z -- '*.yml' '*.yaml' 'CITATION.cff') - if [ "${#files[@]}" -gt 0 ]; then - printf '%s\0' "${files[@]}" | xargs -0 {{ _run }} dprint fmt --incremental=false - else - echo "No YAML files found to format." - fi + {{ _run }} research-repo-tools files run --include '*.yml' --include '*.yaml' --include CITATION.cff -- dprint fmt --incremental=false # Check YAML and CFF formatting without changing files. yaml-fmt-check: tools-check - #!/usr/bin/env bash - set -euo pipefail - files=() - while IFS= read -r -d '' file; do - if [ -f "$file" ]; then - files+=("$file") - fi - done < <(git ls-files -co --exclude-standard -z -- '*.yml' '*.yaml' 'CITATION.cff') - if [ "${#files[@]}" -gt 0 ]; then - printf '%s\0' "${files[@]}" | xargs -0 {{ _run }} dprint check --incremental=false - else - echo "No YAML files found to check." - fi + {{ _run }} research-repo-tools files run --include '*.yml' --include '*.yaml' --include CITATION.cff -- dprint check --incremental=false # Lint maintained YAML and CFF files. yaml-lint: _ensure-yamllint - #!/usr/bin/env bash - set -euo pipefail - files=() - while IFS= read -r -d '' file; do - if [ -f "$file" ]; then - files+=("$file") - fi - done < <(git ls-files -co --exclude-standard -z -- '*.yml' '*.yaml' 'CITATION.cff') - if [ "${#files[@]}" -gt 0 ]; then - echo "๐Ÿ” yamllint (${#files[@]} YAML/CFF files)" - uv run --locked yamllint --strict -c .yamllint "${files[@]}" - else - echo "No YAML files found to lint." - fi + uv run --locked research-repo-tools files run --include '*.yml' --include '*.yaml' --include CITATION.cff -- yamllint --strict -c .yamllint # Audit workflows with the declared scanner and shared authentication policy. [positional-arguments] diff --git a/pyproject.toml b/pyproject.toml index 317a6d8..41c1a34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,7 +113,7 @@ gitleaks = "8.30.1" osv-scanner = "2.7.0" [tool.research-repo-tools.toolchain.cargo] -cargo-edit = "0.13.13" +cargo-edit = "0.13.14" cargo-llvm-cov = "0.9.1" cargo-machete = "0.9.2" cargo-nextest = "0.9.149" diff --git a/semgrep.yaml b/semgrep.yaml index 8927d23..333a0dd 100644 --- a/semgrep.yaml +++ b/semgrep.yaml @@ -395,6 +395,10 @@ rules: include: - "/.github/workflows/**/*.yml" - "/.github/workflows/**/*.yaml" + - "/.github/actions/**/*.yml" + - "/.github/actions/**/*.yaml" + - "/tests/semgrep/.github/actions/**/*.yml" + - "/tests/semgrep/.github/actions/**/*.yaml" - "/tests/semgrep/.github/workflows/**/*.yml" - "/tests/semgrep/.github/workflows/**/*.yaml" patterns: @@ -413,10 +417,14 @@ rules: include: - "/.github/workflows/**/*.yml" - "/.github/workflows/**/*.yaml" + - "/.github/actions/**/*.yml" + - "/.github/actions/**/*.yaml" + - "/tests/semgrep/.github/actions/**/*.yml" + - "/tests/semgrep/.github/actions/**/*.yaml" - "/tests/semgrep/.github/workflows/**/*.yml" - "/tests/semgrep/.github/workflows/**/*.yaml" patterns: - - pattern-regex: '(?m)^[ \t]*(?:-[ \t]*)?uses:\s*(?!\./)(?!docker://)(?!(?:actions/checkout|actions/cache|actions/download-artifact|actions/github-script|actions/setup-python|actions/upload-artifact|actions-rust-lang/setup-rust-toolchain|astral-sh/setup-uv|codecov/codecov-action|github/codeql-action/(?:upload-sarif|init|analyze)|rust-lang/crates-io-auth-action|swatinem/rust-cache|taiki-e/cache-cargo-install-action|zizmorcore/zizmor-action|acgetchell/research-repo-tools/\.github/workflows/dependabot-approve\.yml)@)[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*@' # yamllint disable-line rule:line-length + - pattern-regex: '(?m)^[ \t]*(?:-[ \t]*)?uses:\s*(?!\./)(?!docker://)(?!(?:actions/attest|actions/checkout|actions/cache|actions/download-artifact|actions/github-script|actions/upload-artifact|actions-rust-lang/setup-rust-toolchain|astral-sh/setup-uv|codecov/codecov-action|github/codeql-action/(?:upload-sarif|init|analyze)|rust-lang/crates-io-auth-action|taiki-e/cache-cargo-install-action|acgetchell/research-repo-tools/\.github/workflows/dependabot-approve\.yml)@)[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*@' # yamllint disable-line rule:line-length - id: la-stack.github-actions.external-action-version-comment languages: @@ -430,6 +438,10 @@ rules: include: - "/.github/workflows/**/*.yml" - "/.github/workflows/**/*.yaml" + - "/.github/actions/**/*.yml" + - "/.github/actions/**/*.yaml" + - "/tests/semgrep/.github/actions/**/*.yml" + - "/tests/semgrep/.github/actions/**/*.yaml" - "/tests/semgrep/.github/workflows/**/*.yml" - "/tests/semgrep/.github/workflows/**/*.yaml" patterns: diff --git a/src/exact.rs b/src/exact.rs index bbd031d..f865dfc 100644 --- a/src/exact.rs +++ b/src/exact.rs @@ -686,9 +686,7 @@ fn big_int_exp_ref_to_rounded_f64( .unwrap_or_else(|| unreachable!("rounded subnormal significand must fit u64")) }; - if significand == 1_u64 << F64_FRACTION_BITS { - return Ok(f64::from_bits(sign | (1_u64 << F64_FRACTION_BITS))); - } + // Rounding up to 2^52 naturally sets the smallest normal exponent bit. Ok(f64::from_bits(sign | significand)) } diff --git a/src/lib.rs b/src/lib.rs index 1d22063..b6ed3dd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,12 +43,12 @@ pub mod guide { //! //! Explore the topic guides for more workflows: //! - //! - [LDLT determinants and symmetry](ldlt) + //! - [Adaptive determinant filtering](adaptive) + //! - [Certified dot products and affine differences](certified) //! - [Compile-time determinants](compile_time) + //! - [LDLT determinants and symmetry](ldlt) //! - [Outward-rounded interval determinants](intervals) //! - [Overflow-safe Euclidean norms](norms) - //! - [Certified dot products and affine differences](certified) - //! - [Adaptive determinant filtering](adaptive) //! //! Enabling `exact` also adds the exact-arithmetic guide to the module list. //! @@ -206,8 +206,8 @@ pub mod guide { //! [`Interval`](crate::Interval) stores two finite ordered bounds and supports //! point construction, outward-rounded subtraction, addition, multiplication, //! negation, and square. [`IntervalMatrix`](crate::IntervalMatrix) stores - //! `[[Interval; D]; D]` inline and uses a fixed 128-entry stack workspace for - //! supported determinant dimensions. [`IntervalDeterminantSign`](crate::IntervalDeterminantSign) + //! `[[Interval; D]; D]` inline and uses a dimension-sized stack workspace of + //! at most 128 entries. [`IntervalDeterminantSign`](crate::IntervalDeterminantSign) //! distinguishes positive, negative, exact zero, and inconclusive evidence. //! //! Parse numerical thresholds through [`Tolerance::try_new`](crate::Tolerance::try_new). diff --git a/tests/semgrep/.github/actions/action_policy/action.yml b/tests/semgrep/.github/actions/action_policy/action.yml new file mode 100644 index 0000000..7ab89cd --- /dev/null +++ b/tests/semgrep/.github/actions/action_policy/action.yml @@ -0,0 +1,21 @@ +name: Composite action policy fixtures +description: Exercise dependency policy inside composite actions. +runs: + using: composite + steps: + # ok: la-stack.github-actions.external-action-sha-pinned + # ok: la-stack.github-actions.external-action-approved-allowlist + # ok: la-stack.github-actions.external-action-version-comment + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + # ruleid: la-stack.github-actions.external-action-sha-pinned + - uses: astral-sh/setup-uv@v10 + # ruleid: la-stack.github-actions.external-action-approved-allowlist + - uses: unapproved/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + # ruleid: la-stack.github-actions.external-action-approved-allowlist + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + # ruleid: la-stack.github-actions.external-action-version-comment + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 + # ok: la-stack.github-actions.external-action-sha-pinned + # ok: la-stack.github-actions.external-action-approved-allowlist + # ok: la-stack.github-actions.external-action-version-comment + - uses: ./.github/actions/setup-tools diff --git a/tests/semgrep/.github/workflows/action_policy.yml b/tests/semgrep/.github/workflows/action_policy.yml index 8621a45..9934c0a 100644 --- a/tests/semgrep/.github/workflows/action_policy.yml +++ b/tests/semgrep/.github/workflows/action_policy.yml @@ -20,6 +20,10 @@ jobs: action-shorthand: runs-on: ubuntu-latest steps: + # ok: la-stack.github-actions.external-action-approved-allowlist, la-stack.github-actions.external-action-sha-pinned + - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + # ruleid: la-stack.github-actions.external-action-approved-allowlist + - uses: unapproved/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 # ok: la-stack.github-actions.external-action-approved-allowlist, la-stack.github-actions.external-action-sha-pinned - uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 # ruleid: la-stack.github-actions.external-action-approved-allowlist @@ -60,16 +64,16 @@ jobs: # ruleid: la-stack.github-actions.external-action-version-comment uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae - - name: Allowed setup-python action - # ok: la-stack.github-actions.external-action-approved-allowlist + - name: Pinned but unapproved setup-python action # ok: la-stack.github-actions.external-action-sha-pinned # ok: la-stack.github-actions.external-action-version-comment + # ruleid: la-stack.github-actions.external-action-approved-allowlist uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - - name: Allowed Rust cache action - # ok: la-stack.github-actions.external-action-approved-allowlist + - name: Pinned but unapproved Rust cache action # ok: la-stack.github-actions.external-action-sha-pinned # ok: la-stack.github-actions.external-action-version-comment + # ruleid: la-stack.github-actions.external-action-approved-allowlist uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - name: Local action diff --git a/tooling/README.md b/tooling/README.md index f0153f3..b81c864 100644 --- a/tooling/README.md +++ b/tooling/README.md @@ -3,13 +3,17 @@ The installed `research-repo-tools` package owns common development and performance tooling. The exact released pin and registry hashes live in `pyproject.toml` and `uv.lock`. This dependency-only environment has no local -Python distribution, console entry points, or production Python adapters. +Python distribution or console entry points. `release_evidence.py` is the +repository-specific Cargo evidence adapter around the installed validation +helpers; shared tooling is not copied into this repository. ## Setup and validation Use `just setup`, then `uv run --locked` for Python commands. Python 3.15 runs the shared package and the consumer tests in `tooling/tests/`. -`just python-check` checks formatting, lint and types; `just test-python` +`just python-check` delegates formatting, lint and strict types to the shared +complete tracked/nonignored Python inventory, including static-analysis fixtures. +`just test-python` runs the installed-package integration tests. `just ci` is the final repository validation. Native Linux, macOS and Windows CI is required for adoption; local macOS results do not establish the other platforms. @@ -19,6 +23,53 @@ Semgrep 1.182.0 retains its separate locked Python 3.14 environment in Python tests remain scan targets; deliberate `tests/semgrep/` fixtures are checked separately by `just semgrep-test`. +## Shared capability adoption + +The capability inventory below was checked against the installed v0.2.3 CLI and +its [versioned contracts](https://github.com/acgetchell/research-repo-tools/blob/v0.2.3/README.md#common-workflows). +Native Rust algorithms, assertions, benchmark selection and release decisions +remain owned here. + +| Capability | Consumer use | +| --- | --- | +| Actions | `workflow-allowlist-check` gates external workflow dependencies; `update-actions` updates explicitly selected workflow pins | +| Changelog and release | Shared generation, archives, metadata rules, registry gates and verification; the Cargo adapter reuses signed native/package evidence | +| CI environment and toolchain | Shared setup/check/run/export and verified cached installations; `tools-clean` previews obsolete managed tools separately from Cargo `clean` | +| Coverage | Native `cargo llvm-cov` acquisition; `coverage-report` summarizes its existing Cobertura output | +| Dependencies | Shared uv/tool upgrades and `deps update-python --all-projects`; root Python 3.15 and isolated Semgrep Python 3.14 retain separate constraints | +| Documentation and files | Shared tracked/nonignored selection and bounded argument batches for formatting, lint, spelling and syntax checks; one active Markdown policy preserves archives | +| Performance | Declarative discovery, measurement, saved-sample validation, immutable evidence, offline rendering, report/README publication and release assets | +| Python | Shared complete-inventory check/fix/typecheck with repository Ruff exceptions and strict ty diagnostics | +| Review | Shared opt-in CodeRabbit branch and uncommitted review | +| Security and workflow audit | Shared OSV, full-history Gitleaks and zizmor policy; consumer advisory exceptions and workflow permissions remain local | +| Semgrep | Shared fixture validation; native production scans retain `.semgrepignore`, explicit status gates and the reviewed in-source suppression policy | +| Validation | `tooling/examples.toml` selects the published Cargo package and exact-feature overrides; shared Cargo metadata discovery runs every binary example | + +The example runner uses compiler-reported executable paths, including configured +native targets and output directories, with per-build and per-execution budgets. +Ordinary examples run with default features; exact examples opt in individually. +Their numerical assertions remain inside the executables. New examples join +automatically; a new required feature needs a matching configuration entry. + +Notebook, paper/PDF and Tectonic capabilities do not apply to this repository. +SARIF splitting is unnecessary for the existing individual scanner uploads; +the native suppression filter retains the repository's specific GitHub policy. +Templates are reference material when changing declarations, not generated copies +to maintain here. Raw `files check-lines` intentionally does not replace +`docs check-lines`: the latter preserves the existing Markdown table exemption. + +Use `just --list` for arguments. Actions updates and managed-tool removal are +explicit maintenance operations; checking their wiring does not run them. + +`.github/actions-allowlist.json` owns exact action identities accepted by local +validation and the updater. Workflows retain full commit pins. This source policy +is separate from the historical GitHub settings payloads under `.github/settings/`, +which also constrain the publishing action to a specific commit. Reconcile live +settings before deploying a pin update; the local updater does not change them. +The v0.2.3 Actions parser accepts workflow documents only. Semgrep rules +check composite actions under `.github/actions/`, including their +external dependencies and commit pins. + ## Performance ownership The [benchmark guide](../docs/BENCHMARKING.md) owns command selection, @@ -84,8 +135,13 @@ The publishing declaration selects only `la-stack`, requires native platform and scientific checks on the exact commit, and requires the durable Criterion release asset. `release-gate`, `release-publish` and `release-verify` use the shared release contract; `release-package` owns native default/exact -Cargo dry-runs. `tests/test_release_publishing.py` checks consumer policy and -upload barriers without registry credentials. +Cargo dry-runs. Main CI signs those packaging and native validation identities; +`release-evidence` verifies the original main-push attempt and immutable signed +artifact before publication and rechecks its pinned identity after approval. +`tests/test_release_evidence.py` checks the adapter's trust boundaries, while +`tests/test_release_publishing.py` checks consumer policy and upload barriers +without registry credentials. The release guide owns retention, complete-rerun +recovery and the distinction between reused checks and Cargo repackaging. `tests/test_toolchain_integration.py` owns the package-identity check: manifest pin, registry lock, installed version and distribution hashes. diff --git a/tooling/__init__.py b/tooling/__init__.py new file mode 100644 index 0000000..9adb3d4 --- /dev/null +++ b/tooling/__init__.py @@ -0,0 +1 @@ +"""Repository-specific adapters around the installed shared tooling.""" diff --git a/tooling/examples.toml b/tooling/examples.toml new file mode 100644 index 0000000..fd7d209 --- /dev/null +++ b/tooling/examples.toml @@ -0,0 +1,19 @@ +# Discover every binary example in the published package, including future targets. +schema = 1 +package = "la-stack" +profile = "dev" +timeout = 300 +build-timeout = 1800 + +# Numerical assertions live in each executable; these examples require exact arithmetic. +[examples.exact_det_3x3] +features = [ "exact" ] + +[examples.exact_sign_3x3] +features = [ "exact" ] + +[examples.exact_solve_3x3] +features = [ "exact" ] + +[examples.rational_input_5x5] +features = [ "exact" ] diff --git a/tooling/release_evidence.py b/tooling/release_evidence.py new file mode 100644 index 0000000..c9c23ba --- /dev/null +++ b/tooling/release_evidence.py @@ -0,0 +1,251 @@ +"""Bind Cargo publication to signed, successful main-push CI evidence. + +The installed research-repo-tools owns run/job validation and safe artifact +retrieval. This adapter supplies la-stack's Cargo policy and evidence document; +it never uploads a crate, reruns checks, or accepts substitute evidence. +""" + +import argparse +import json +import os +import re +import subprocess +import sys +import tempfile +import tomllib +from pathlib import Path +from typing import Any + +from research_repo_tools.process import format_exception_diagnostics, run_git_command +from research_repo_tools.release_validation import ValidationPolicy, api, download_artifact, gh, select_main_run, validate_jobs, validate_run + +ROOT = Path(__file__).resolve().parents[1] +REPOSITORY = "acgetchell/la-stack" +POLICY = ValidationPolicy( + REPOSITORY, + ".github/workflows/ci.yml", + "CI", + ( + *((f"build ({platform})", ("Run CI checks",)) for platform in ("ubuntu-latest", "macos-latest", "windows-latest")), + ("release-package", ("Verify default and exact Cargo packages",)), + ), +) +EVIDENCE_STEPS = ("Capture successful native validation identities", "Sign original main validation evidence", "Upload signed validation evidence") + + +def read(endpoint: str) -> Any: + """Read authenticated JSON in this repository only.""" + return api(endpoint, repository=REPOSITORY) + + +def object_value(value: Any) -> dict[str, Any]: + """Require a JSON object at a provider boundary.""" + if not isinstance(value, dict): + msg = "expected an evidence object" + raise TypeError(msg) + return value + + +def positive_integer(value: Any) -> int: + """Reject missing, boolean, or nonpositive provider identifiers.""" + if type(value) is not int or value <= 0: + msg = "expected a positive integer identity" + raise ValueError(msg) + return value + + +def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Reject duplicate keys instead of silently replacing evidence fields.""" + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate evidence key: {key}") + result[key] = value + return result + + +def pages(endpoint: str, field: str) -> list[dict[str, Any]]: + """Read a bounded, fully paginated provider collection.""" + result: list[dict[str, Any]] = [] + for page in range(1, 101): + data = object_value(read(f"{endpoint}?per_page=100&page={page}")) + entries = data.get(field) + if not isinstance(entries, list): + raise TypeError(f"invalid {field} collection") + result.extend(object_value(entry) for entry in entries) + if len(entries) < 100: + return result + raise ValueError(f"{field} exceeds the bounded page limit") + + +def checkout_commit() -> str: + """Resolve the checkout independently of event or artifact claims.""" + return run_git_command(["--no-pager", "rev-parse", "HEAD"], cwd=ROOT, timeout=30).stdout.strip() + + +def current_run(commit: str, identifier: int, attempt: int, *, completed: bool = True) -> dict[str, Any]: + """Reject reruns which replaced the selected current attempt.""" + run = object_value(read(f"actions/runs/{positive_integer(identifier)}")) + validate_run(POLICY, run, commit, object_value(read("actions/workflows/ci.yml")), completed=completed) + if run["run_attempt"] != positive_integer(attempt): + msg = "selected CI attempt is stale; inspect the complete main-push run" + raise ValueError(msg) + return run + + +def manifest(run: dict[str, Any]) -> dict[str, Any]: + """Capture native check and default/exact package job identities.""" + jobs = pages(f"actions/runs/{run['id']}/attempts/{run['run_attempt']}/jobs", "jobs") + package = tomllib.loads((ROOT / "Cargo.toml").read_text(encoding="utf-8"))["package"] + return { + "schema": 1, + "repository": REPOSITORY, + "repository_id": run["repository"]["id"], + "commit": run["head_sha"], + "run_id": run["id"], + "run_attempt": run["run_attempt"], + "jobs": validate_jobs(POLICY, run, jobs), + "package": {"name": package["name"], "version": package["version"]}, + } + + +def seal(output: Path) -> None: + """Produce the document signed by this main-push CI attempt.""" + commit = checkout_commit() + if os.environ.get("GITHUB_SHA") != commit: + msg = "executing CI source differs from checkout" + raise ValueError(msg) + run = current_run(commit, int(os.environ["GITHUB_RUN_ID"]), int(os.environ["GITHUB_RUN_ATTEMPT"]), completed=False) + document = manifest(run) + current_run(commit, run["id"], run["run_attempt"], completed=False) + output.parent.mkdir(parents=True, exist_ok=True) + with output.open("x", encoding="utf-8", newline="\n") as stream: + stream.write(json.dumps(document, sort_keys=True) + "\n") + + +def artifact_identity(data: dict[str, Any], run: dict[str, Any]) -> dict[str, Any]: + """Validate immutable artifact association before shared ZIP retrieval.""" + name = f"release-evidence-{run['id']}-{run['run_attempt']}" + association = object_value(data.get("workflow_run")) + expected = { + "id": run["id"], + "head_sha": run["head_sha"], + "head_branch": "main", + "repository_id": run["repository"]["id"], + "head_repository_id": run["head_repository"]["id"], + } + if data.get("name") != name or data.get("expired") is not False or any(association.get(key) != value for key, value in expected.items()): + msg = "evidence artifact is expired or belongs to another run, attempt, repository, ref or commit" + raise ValueError(msg) + digest = data.get("digest") + if not isinstance(digest, str) or re.fullmatch(r"sha256:[0-9a-f]{64}", digest) is None: + msg = "evidence artifact lacks an authoritative SHA-256 digest" + raise ValueError(msg) + size = positive_integer(data.get("size_in_bytes")) + if size > 1024 * 1024: + msg = "evidence artifact exceeds the one-MiB limit" + raise ValueError(msg) + return {"id": positive_integer(data.get("id")), "name": name, "digest": digest, "size_in_bytes": size} + + +def find_artifact(run: dict[str, Any]) -> dict[str, Any]: + """Require one evidence artifact for the selected attempt.""" + name = f"release-evidence-{run['id']}-{run['run_attempt']}" + matches = [entry for entry in pages(f"actions/runs/{run['id']}/artifacts", "artifacts") if entry.get("name") == name] + if len(matches) != 1: + msg = "missing or ambiguous signed CI evidence; inspect retention and rerun all CI jobs if needed" + raise ValueError(msg) + return artifact_identity(matches[0], run) + + +def verify_signature(directory: Path, run: dict[str, Any]) -> None: + """Verify Sigstore provenance, including the exact signing attempt.""" + result = json.loads( + gh( + "attestation", + "verify", + str(directory / "validation.json"), + "--bundle", + str(directory / "validation.sigstore.json"), + "--repo", + REPOSITORY, + "--signer-workflow", + f"{REPOSITORY}/{POLICY.workflow}", + "--signer-digest", + run["head_sha"], + "--source-digest", + run["head_sha"], + "--source-ref", + "refs/heads/main", + "--deny-self-hosted-runners", + "--predicate-type", + "https://slsa.dev/provenance/v1", + "--format", + "json", + ), + object_pairs_hook=unique_object, + ) + if not isinstance(result, list) or len(result) != 1: + msg = "expected exactly one verified CI attestation" + raise ValueError(msg) + verification = object_value(object_value(result[0]).get("verificationResult")) + certificate = object_value(object_value(verification.get("signature")).get("certificate")) + invocation = f"https://github.com/{REPOSITORY}/actions/runs/{run['id']}/attempts/{run['run_attempt']}" + if certificate.get("runInvocationURI") != invocation or certificate.get("buildTrigger") != "push": + msg = "attestation run, attempt or push event differs" + raise ValueError(msg) + + +def verify(identity: str = "") -> dict[str, Any]: + """Reuse one exact CI attempt, or recheck the same pinned identity after approval.""" + commit = checkout_commit() + pin = object_value(json.loads(identity, object_pairs_hook=unique_object)) if identity else None + if pin is not None and set(pin) != {"run_id", "run_attempt", "artifact_id", "artifact_digest"}: + msg = "invalid pinned evidence identity" + raise ValueError(msg) + run = current_run(commit, pin["run_id"], pin["run_attempt"]) if pin is not None else select_main_run(POLICY, commit, read) + expected = manifest(run) + signing_policy = ValidationPolicy(REPOSITORY, POLICY.workflow, POLICY.workflow_name, (("release-evidence", EVIDENCE_STEPS),)) + validate_jobs(signing_policy, run, pages(f"actions/runs/{run['id']}/attempts/{run['run_attempt']}/jobs", "jobs")) + artifact = ( + artifact_identity(object_value(read(f"actions/artifacts/{positive_integer(pin['artifact_id'])}")), run) if pin is not None else find_artifact(run) + ) + result = {"run_id": run["id"], "run_attempt": run["run_attempt"], "artifact_id": artifact["id"], "artifact_digest": artifact["digest"]} + if pin is not None and pin != result: + msg = "approved evidence identity changed" + raise ValueError(msg) + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) / "evidence" + download_artifact(REPOSITORY, artifact, directory, ("validation.json", "validation.sigstore.json")) + verify_signature(directory, run) + document = json.loads((directory / "validation.json").read_text(encoding="utf-8"), object_pairs_hook=unique_object) + if document != expected: + msg = "signed evidence differs from current exact-commit Cargo and native validation identities" + raise ValueError(msg) + current_run(commit, run["id"], run["run_attempt"]) + if artifact_identity(object_value(read(f"actions/artifacts/{artifact['id']}")), run) != artifact: + msg = "evidence artifact changed during verification" + raise ValueError(msg) + return result + + +def main() -> int: + """Expose signing and read-only consumption without publication credentials.""" + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("seal").add_argument("--output", type=Path, required=True) + commands.add_parser("verify").add_argument("--identity", default="") + args = parser.parse_args() + try: + if args.command == "seal": + seal(args.output) + else: + print(json.dumps(verify(args.identity), sort_keys=True)) + except (KeyError, OSError, TypeError, ValueError, subprocess.SubprocessError) as error: + print(f"Release evidence rejected: {format_exception_diagnostics(error, single_line=True)}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tooling/semgrep/uv.lock b/tooling/semgrep/uv.lock index 0a7eb15..2b69c11 100644 --- a/tooling/semgrep/uv.lock +++ b/tooling/semgrep/uv.lock @@ -47,11 +47,11 @@ wheels = [ [[package]] name = "bracex" -version = "3.1" +version = "3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/60/e2a6409041cb3f8ce7ac3d6917b5b30c39f81a0a5b2d414ec30f36fd96bf/bracex-3.1.tar.gz", hash = "sha256:35c69fc105f08d77c02722e8ae707c656ff298dd4d26c89bcb3c98fca8bc3614", size = 29040, upload-time = "2026-10-11T15:26:07.143Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/37/adf8dd2cde958c2d086bdd87fc36b532fd824bf38f10be6348d65da0c8f9/bracex-3.2.tar.gz", hash = "sha256:0a3d40bfae4caae4d3bfd0ea715874799fab711c38fd4012cc35343e815405cd", size = 29771, upload-time = "2026-10-22T00:28:49.508Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/79/c1ba46e1acd235affd36ce1d3346320ddcda663c9482a9e5f34727db1d11/bracex-3.1-py3-none-any.whl", hash = "sha256:52002b8455658c21f3b6e5eb0f662b57ec9085f42709e58bc86eeab5bc4c37d7", size = 11925, upload-time = "2026-10-11T15:26:05.683Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/8030b03de8137d1aab7dd6ecfecdba2fc22fc849fa35959b024f62293a99/bracex-3.2-py3-none-any.whl", hash = "sha256:8f3e908a452f66707e1d6d91b3d7d7d4c649d248df18537c0dc710aeb9c30d87", size = 12019, upload-time = "2026-10-22T00:28:48.233Z" }, ] [[package]] diff --git a/tooling/tests/test_justfile_discoverability.py b/tooling/tests/test_justfile_discoverability.py index 8feaaf9..1f448e8 100644 --- a/tooling/tests/test_justfile_discoverability.py +++ b/tooling/tests/test_justfile_discoverability.py @@ -3,12 +3,15 @@ import json import re import shlex +import tomllib from pathlib import Path from typing import TYPE_CHECKING, Any import yaml +from research_repo_tools.cargo_examples import discover_examples from research_repo_tools.just_inspect import dry_run, inspect_justfile from research_repo_tools.process import run_command +from research_repo_tools.selection import select_files REPO_ROOT = Path(__file__).resolve().parents[2] @@ -87,11 +90,29 @@ def test_ci_enforces_full_python_fixture_lint_policy() -> None: """Canonical CI should lint fixtures without narrowing the Ruff configuration.""" recipes = just_recipes() ci_dependencies = {dependency["recipe"] for dependency in recipes["ci"]["dependencies"]} - fixture_lint_body = json.dumps(recipes["python-fixture-lint"]["body"]) - - assert "python-fixture-lint" in ci_dependencies - assert "ruff check tests/semgrep/scripts/" in fixture_lint_body - assert "--select" not in fixture_lint_body + python_body = json.dumps(recipes["python-check"]["body"]) + + assert "python-check" in ci_dependencies + assert "research-repo-tools python check" in python_body + assert "--select" not in python_body + inventory = set(select_files(REPO_ROOT, include=("*.py", "*.pyi"))) + assert {"tooling/release_evidence.py", "tooling/tests/test_release_evidence.py", "tests/semgrep/scripts/python_portability.py"} <= inventory + assert not any(".venv" in Path(name).parts for name in inventory) + + +def test_example_policy_covers_cargo_targets_and_required_features() -> None: + """Future Cargo examples join automatically; exact targets need explicit policy.""" + policy = tomllib.loads((REPO_ROOT / "tooling/examples.toml").read_text(encoding="utf-8")) + assert policy["package"] == "la-stack" + assert "include" not in policy + assert "exclude" not in policy + discovered = discover_examples(REPO_ROOT, package="la-stack") + assert {example.name for example in discovered} >= {"vector_angles", "exact_solve_3x3", "rational_input_5x5"} + overrides = policy["examples"] + for example in discovered: + assert set(example.required_features) <= set(overrides.get(example.name, {}).get("features", [])) + assert set(overrides) <= {example.name for example in discovered} + assert "validation cargo-examples tooling/examples.toml" in dry_run(REPO_ROOT, "examples").stderr def test_bare_just_lists_the_complete_documented_surface_after_source_sorting() -> None: @@ -140,8 +161,8 @@ def test_dependabot_shared_caller_covers_dependencies_with_explicit_manual_revie for path in directory.rglob("*") if path.suffix in {".yml", ".yaml"} } - # Publishing action pins must be reconciled with the live selected-actions policy. - manual_review = {".github/workflows/publish-crates.yml"} + # Publication and signed evidence production retain maintainer review. + manual_review = {".github/workflows/publish-crates.yml", ".github/workflows/ci.yml"} assert manual_review <= actions assert set(policy["github_actions"]["files"]) == actions - manual_review @@ -163,6 +184,24 @@ def test_zizmor_workflow_separates_findings_from_sarif_export() -> None: assert "github.actor != 'dependabot[bot]'" in upload["if"] +def test_composite_allowlist_agrees_with_repository_policy() -> None: + """Pinned references need approval in both workflow and composite checks.""" + policy = json.loads((REPO_ROOT / ".github/actions-allowlist.json").read_bytes()) + approved = {pattern.removesuffix("@*") for pattern in policy["patterns_allowed"]} + rules = yaml.safe_load((REPO_ROOT / "semgrep.yaml").read_bytes())["rules"] + rule = next(rule for rule in rules if rule["id"] == "la-stack.github-actions.external-action-approved-allowlist") + pattern = re.compile(rule["patterns"][0]["pattern-regex"]) + unapproved = {"actions/setup-python", "swatinem/rust-cache", "zizmorcore/zizmor-action", "unapproved/setup-uv"} + candidates = approved | unapproved | {f"{action}/unapproved" for action in approved} + for action in candidates: + for prefix in (" uses: ", " - uses: "): + reference = f"{prefix}{action}@{'a' * 40} # v1.0.0" + assert bool(pattern.search(reference)) == (action not in approved), reference + commands = dry_run(REPO_ROOT, "workflow-allowlist-check").stderr + assert "--policy .github/actions-allowlist.json .github/workflows" in commands + assert "--config semgrep.yaml .github/actions" in commands + + def test_semgrep_sarif_upload_honors_only_in_source_suppressions() -> None: """GitHub receives actionable findings with their original metadata across every run.""" workflow = yaml.safe_load((REPO_ROOT / ".github/workflows/semgrep-sarif.yml").read_bytes()) diff --git a/tooling/tests/test_maintenance_integration.py b/tooling/tests/test_maintenance_integration.py index a0d24ed..1aa0ac1 100644 --- a/tooling/tests/test_maintenance_integration.py +++ b/tooling/tests/test_maintenance_integration.py @@ -2,6 +2,7 @@ import os import re +import shlex import shutil import sys import tomllib @@ -130,13 +131,23 @@ def test_update_recipe_runs_the_installed_cli_with_preview_arguments(consumer: P assert snapshot(consumer) == before -def test_markdown_recipe_uses_the_published_checker() -> None: - """Retain the consumer's tracked-file selection and quoted path forwarding.""" - result = dry_run(REPO_ROOT, "markdown-check") - assert 'research-repo-tools docs check-lines "${raw_files[@]}"' in result.stderr - assert 'if [ "$file" != README.md ]; then' in result.stderr - assert '"${files[@]}" | xargs -0 -n100' in result.stderr - assert "git ls-files -co --exclude-standard -z" in result.stderr +def test_markdown_recipe_preserves_active_and_historical_selection(consumer: Path, capsys: pytest.CaptureFixture[str]) -> None: + """The real shared selector includes new guides and preserves measured history.""" + run_command("git", ["init", "--quiet"], cwd=consumer) + added = consumer / "docs/new guide.md" + added.write_text("# New guide\n", encoding="utf-8") + selection = shlex.split(run_command("just", ["--evaluate", "_markdown_inputs"], cwd=REPO_ROOT).stdout) + assert main(["--root", str(consumer), "files", "list", *selection]) == 0 + selected = set(capsys.readouterr().out.splitlines()) + assert {"README.md", "docs/new guide.md", "docs/RELEASING.md", "tooling/README.md"} <= selected + assert not any(name.startswith(("docs/archive/", "docs/archives/changelog/", "docs/performance-runs/")) for name in selected) + assert not {"CHANGELOG.md", "docs/performance.md"} & selected + checked = dry_run(REPO_ROOT, "markdown-check").stderr + fixed = dry_run(REPO_ROOT, "markdown-fix").stderr + assert "research-repo-tools files run" in checked + assert "research-repo-tools files run" in fixed + assert "--exclude README.md -- research-repo-tools docs check-lines" in checked + assert "-- rumdl check" in checked def test_semgrep_adapter_uses_real_consumer_rules_and_fixtures() -> None: diff --git a/tooling/tests/test_performance_workflow.py b/tooling/tests/test_performance_workflow.py index bcbe7a8..8177241 100644 --- a/tooling/tests/test_performance_workflow.py +++ b/tooling/tests/test_performance_workflow.py @@ -44,8 +44,8 @@ for name in ids: path = Path(os.environ["CRITERION_HOME"]) / name / "new" path.mkdir(parents=True) - median = (16.0 if "/faer_" in name else 10.0) if phase == "baseline" else 8.0 - mean = (32.0 if "/faer_" in name else 20.0) if phase == "baseline" else 12.0 + median = TIMINGS[phase][name]["median"] + mean = TIMINGS[phase][name]["mean"] def estimate(value): return {"point_estimate": value, "confidence_interval": {"lower_bound": value * .9, "upper_bound": value * 1.1, "confidence_level": .95}} data = { @@ -79,6 +79,17 @@ def scientific_ids() -> tuple[set[str], set[str]]: return library, peers +def synthetic_timings() -> dict[str, dict[str, dict[str, float]]]: + """Distinguish every case, phase, and statistic so selection errors stay visible.""" + library, peers = scientific_ids() + return { + phase: { + name: {"median": float(offset + index * 4), "mean": float(offset + index * 4 + 1)} for index, name in enumerate(sorted(library | peers), start=1) + } + for phase, offset in (("current", 0), ("baseline", 10_000)) + } + + @pytest.mark.parametrize("view", ["full", "non-exact"]) def test_declared_scientific_sampling_coverage_and_original_peer_phases(view: str) -> None: library, peers = scientific_ids() @@ -108,6 +119,14 @@ def test_declared_scientific_sampling_coverage_and_original_peer_phases(view: st assert isinstance(item, RunSeries) if item.name.startswith("la-stack"): assert dict(item.rows) == {name: name for name in library} + else: + assert dict(item.rows) == { + f"d{dimension}/{name}": f"d{dimension}/{peer}" + for dimension in catalog["dimensions"] + for name, peer_names in catalog["peers"].items() + for peer in peer_names + if peer.startswith(f"{item.name}_") + } assert {"context.os", "context.architecture", "context.cpu", "harness_sha256"} <= set(plan.measurement.compatible) assert {"benches/**/*.rs", "tests/exact_bench_config.rs", "tooling/performance.just", "tooling/measurements/*.toml"} <= set(plan.harness) @@ -140,6 +159,22 @@ def test_named_views_keep_every_metric_statistic_axis_and_explicit_input_choice( assert "manifest" not in view +def test_canonical_readme_selects_all_lu_solve_medians_and_peer_over_current_ratios() -> None: + catalog = tomllib.loads((ROOT / "tooling/performance-policy.toml").read_text(encoding="utf-8")) + view = tomllib.loads((ROOT / "tooling/performance-readme.toml").read_text(encoding="utf-8")) + assert view["measurement"] == "tooling/measurements/full.toml" + assert view["statistic"] == "median" + assert view["unit"] == "ns" + assert view["rows"] == [{"key": str(d), "label": str(d), "coordinate": d} for d in catalog["dimensions"]] + assert [series["name"] for series in view["series"]] == ["la-stack current", "nalgebra", "faer"] + for series, benchmark in zip(view["series"], catalog["metrics"]["lu_solve"]["benchmarks"], strict=True): + assert series["rows"] == {str(d): f"d{d}/{benchmark}" for d in catalog["dimensions"]} + assert view["comparisons"] == [ + {"numerator": "nalgebra", "denominator": "la-stack current"}, + {"numerator": "faer", "denominator": "la-stack current"}, + ] + + def recipe(root: Path, name: str, *arguments: str) -> subprocess.CompletedProcess[str]: """Invoke the actual recipe with the already-installed locked consumer environment.""" return run_command( @@ -195,7 +230,7 @@ def prepared(tmp_path: Path) -> tuple[Path, Path]: contents = manifest.read_text(encoding="utf-8") version = tomllib.loads(contents)["package"]["version"] manifest.write_text(contents.replace(f'version = "{version}"', 'version = "0.5.1"', 1), encoding="utf-8", newline="\n") - (root / "tooling/fixture.py").write_text(f"IDS = {ids!r}\n" + DRIVER, encoding="utf-8", newline="\n") + (root / "tooling/fixture.py").write_text(f"IDS = {ids!r}\nTIMINGS = {synthetic_timings()!r}\n" + DRIVER, encoding="utf-8", newline="\n") driver = f"{shlex.quote(sys.executable)} -X utf8 tooling/fixture.py" native = 'export CRITERION_HOME := absolute_path(env("CRITERION_HOME", "target/criterion"))\n' for side in ("baseline", "current"): @@ -336,6 +371,25 @@ def test_actual_retention_offline_plot_and_readme_recipes(complete_run: tuple[Pa assert "Complete run evidence" in document assert "Evidence envelope" in document assert "la-stack v0.5.1" in document + assert "Statistic: median. Unit: ns." in document + catalog = tomllib.loads((ROOT / "tooling/performance-policy.toml").read_text(encoding="utf-8")) + rows = [ + [cell.strip() for cell in line.strip("|").split("|")] for line in document.splitlines() if line.startswith("|") and line.split("|")[1].strip().isdigit() + ] + assert [row[0] for row in rows] == [str(d) for d in catalog["dimensions"]] + timings = synthetic_timings() + for dimension, row in zip(catalog["dimensions"], rows, strict=True): + assert len(row) == 6 + expected = [ + timings[phase][f"d{dimension}/{benchmark}"]["median"] + for phase, benchmark in zip(("current", "baseline", "baseline"), catalog["metrics"]["lu_solve"]["benchmarks"], strict=True) + ] + assert [float(cell.split("
")[0]) for cell in row[1:4]] == pytest.approx(expected) + # Published ratios have six significant digits; the oracle uses the + # intended peer/current medians, independently of the view declaration. + assert [float(cell.removesuffix("\N{MULTIPLICATION SIGN}")) for cell in row[4:]] == pytest.approx( + [expected[1] / expected[0], expected[2] / expected[0]], rel=1e-5 + ) checked = recipe(root, "markdown-check") assert checked.returncode == 0, checked.stderr diff --git a/tooling/tests/test_release_baseline.py b/tooling/tests/test_release_baseline.py index 0368a7d..9dfc343 100644 --- a/tooling/tests/test_release_baseline.py +++ b/tooling/tests/test_release_baseline.py @@ -21,8 +21,25 @@ TOOLING_REQUIREMENT = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))["dependency-groups"]["tooling"][0] WORKFLOW = REPO_ROOT / ".github/workflows/release-benchmarks.yml" PREPARATION = REPO_ROOT / ".github/actions/prepare-release-benchmarks/action.yml" -REQUIRED = load_discovery(REPO_ROOT, "tooling/criterion-inventory.toml").required -IDS = {"vs_linalg": sorted(name for name in REQUIRED if name.startswith("d")), "exact": sorted(name for name in REQUIRED if not name.startswith("d"))} +CATALOG = tomllib.loads((REPO_ROOT / "tooling/performance-policy.toml").read_text(encoding="utf-8")) +REQUIRED = { + *(f"{group}/{name}" for group, names in CATALOG["exact"].items() for name in names), + *( + f"d{dimension}/{name}" + for dimension in CATALOG["dimensions"] + for name in [*CATALOG["library-benchmarks"], *CATALOG["additional"].get(str(dimension), [])] + ), + *(f"d{dimension}/{peer}" for dimension in CATALOG["dimensions"] for peers in CATALOG["peers"].values() for peer in peers), +} +# Retain optional diagnostic additions without letting a declaration omission +# remove independently required scientific cases from the archive fixture. +DECLARED = set(load_discovery(REPO_ROOT, "tooling/criterion-inventory.toml").required) | set( + tomllib.loads((REPO_ROOT / "tooling/criterion-saved.toml").read_text(encoding="utf-8"))["required"] +) +IDS = { + "vs_linalg": sorted(name for name in REQUIRED | DECLARED if name.startswith("d")), + "exact": sorted(name for name in REQUIRED | DECLARED if not name.startswith("d")), +} BASELINE = "v1.2.3" RAW_FILES = ("benchmark.json", "estimates.json", "sample.json", "tukey.json") @@ -146,20 +163,40 @@ def test_report_consumers_require_canonical_paths(dataset: tuple[Path, Path], re assert {path: path.read_bytes() for path in criterion.rglob("*") if path.is_file()} == before -def test_configured_inventory_includes_release_consumers_and_peers() -> None: - expected = set(REQUIRED) - assert {"d64/faer_lu_solve", "d64/nalgebra_lu_solve", "exact_hilbert_5x5/solve_exact", "rational_input_d8/solve_big_rational_gaussian"} <= expected - assert "d64/faer_dot_native" in expected - assert "d64/faer_dot" not in expected - discovery = load_discovery(REPO_ROOT, "tooling/criterion-inventory.toml") +def assert_scientific_inventory(root: Path) -> None: + """Require the independent domain catalog while allowing diagnostic extras.""" + discovery = load_discovery(root, "tooling/criterion-inventory.toml") + missing = REQUIRED - set(discovery.required) + assert not missing, f"discovery omitted scientific cases: {sorted(missing)}" assert {suite.name for suite in discovery.suites} == {"vs_linalg", "exact"} assert all(suite.command[-1] == "list" for suite in discovery.suites) - saved = tomllib.loads((REPO_ROOT / "tooling/criterion-saved.toml").read_text(encoding="utf-8")) - assert set(saved["required"]) == expected - assert saved["storage-paths"] == {name: name for name in expected} + saved = tomllib.loads((root / "tooling/criterion-saved.toml").read_text(encoding="utf-8")) + missing = REQUIRED - set(saved["required"]) + assert not missing, f"saved inventory omitted scientific cases: {sorted(missing)}" + assert saved["storage-paths"] == {name: name for name in saved["required"]} assert saved["raw-files"] == list(RAW_FILES) +def test_configured_inventory_includes_release_consumers_and_peers() -> None: + assert_scientific_inventory(REPO_ROOT) + + +@pytest.mark.parametrize("declaration", ["criterion-inventory.toml", "criterion-saved.toml", "both"]) +def test_scientific_inventory_detects_a_nonsentinel_omission(tmp_path: Path, declaration: str) -> None: + removed = "d3/la_stack_norm2_sq" + tooling = tmp_path / "tooling" + tooling.mkdir() + for filename in ("criterion-inventory.toml", "criterion-saved.toml"): + contents = (REPO_ROOT / "tooling" / filename).read_text(encoding="utf-8") + if declaration in {filename, "both"}: + assert removed in tomllib.loads(contents)["required"] + contents = contents.replace(f' "{removed}",\n', "").replace(f'"{removed}" = "{removed}"\n', "") + assert removed not in tomllib.loads(contents)["required"] + (tooling / filename).write_text(contents, encoding="utf-8", newline="\n") + with pytest.raises(AssertionError, match=f"omitted scientific cases:.*{re.escape(removed)}"): + assert_scientific_inventory(tmp_path) + + def test_workflow_requires_preflight_and_isolates_publication_permissions() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") preparation = PREPARATION.read_text(encoding="utf-8") diff --git a/tooling/tests/test_release_evidence.py b/tooling/tests/test_release_evidence.py new file mode 100644 index 0000000..0201906 --- /dev/null +++ b/tooling/tests/test_release_evidence.py @@ -0,0 +1,509 @@ +"""Exercise the Cargo evidence adapter against authenticated provider boundaries.""" + +import importlib.util +import json +import subprocess +import sys +from copy import deepcopy +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pytest + +if TYPE_CHECKING: + from types import ModuleType + +REPO_ROOT = Path(__file__).resolve().parents[2] +REPOSITORY = "acgetchell/la-stack" +COMMIT = "a" * 40 +DIGEST = "sha256:" + "b" * 64 +RUN_ENDPOINT = "actions/runs/101" +JOBS_ENDPOINT = "actions/runs/101/attempts/2/jobs?per_page=100&page=1" +ARTIFACTS_ENDPOINT = "actions/runs/101/artifacts?per_page=100&page=1" +ARTIFACT_ENDPOINT = "actions/artifacts/301" +RUNS_ENDPOINT = f"actions/workflows/9/runs?event=push&branch=main&head_sha={COMMIT}&per_page=100&page=1" +INVOCATION = f"https://github.com/{REPOSITORY}/actions/runs/101/attempts/2" +PIN = {"run_id": 101, "run_attempt": 2, "artifact_id": 301, "artifact_digest": DIGEST} +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), +) +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))) + + +class EvidenceProvider: + """Keep signed inputs independent from live provider responses.""" + + def __init__(self, adapter: ModuleType, root: Path) -> None: + """Create independent signed and provider identities for one CI attempt.""" + self.adapter = adapter + self.root = root + self.run: dict[str, Any] = { + "id": 101, + "run_attempt": 2, + "workflow_id": 9, + "path": ".github/workflows/ci.yml", + "name": "CI", + "event": "push", + "head_branch": "main", + "head_sha": COMMIT, + "pull_requests": [], + "repository": {"id": 11, "full_name": REPOSITORY}, + "head_repository": {"id": 11, "full_name": REPOSITORY}, + "status": "completed", + "conclusion": "success", + } + gates = [*((name, (step,), identifier) for name, step, identifier in NATIVE_GATES), ("release-evidence", SIGNING_STEPS, 205)] + self.jobs: list[dict[str, Any]] = [ + { + "id": identifier, + "name": name, + "run_id": 101, + "run_attempt": 2, + "head_branch": "main", + "head_sha": COMMIT, + "status": "completed", + "conclusion": "success", + "steps": [{"number": number, "name": step, "status": "completed", "conclusion": "success"} for number, step in enumerate(steps, 1)], + } + for name, steps, identifier in gates + ] + self.artifact: dict[str, Any] = { + "id": 301, + "name": "release-evidence-101-2", + "expired": False, + "digest": DIGEST, + "size_in_bytes": 512, + "workflow_run": {"id": 101, "head_sha": COMMIT, "head_branch": "main", "repository_id": 11, "head_repository_id": 11}, + } + self.document: dict[str, Any] = { + "schema": 1, + "repository": REPOSITORY, + "repository_id": 11, + "commit": COMMIT, + "run_id": 101, + "run_attempt": 2, + "jobs": {name: identifier for name, _, identifier in NATIVE_GATES}, + "package": {"name": "la-stack", "version": "0.5.42"}, + } + self.signature = [{"verificationResult": {"signature": {"certificate": {"runInvocationURI": INVOCATION, "buildTrigger": "push"}}}}] + self.responses: dict[str, Any] = { + "actions/workflows/ci.yml": {"id": 9, "path": ".github/workflows/ci.yml", "name": "CI", "state": "active"}, + RUNS_ENDPOINT: {"workflow_runs": [self.run]}, + RUN_ENDPOINT: self.run, + JOBS_ENDPOINT: {"jobs": self.jobs}, + ARTIFACTS_ENDPOINT: {"artifacts": [self.artifact]}, + ARTIFACT_ENDPOINT: self.artifact, + } + self.scripted: dict[str, list[Any]] = {} + self.api_calls: list[str] = [] + self.gh_calls: list[tuple[str, ...]] = [] + self.downloads: list[tuple[str, dict[str, Any], Path, tuple[str, ...]]] = [] + self.document_text: str | None = None + self.signature_text: str | None = None + + def api(self, endpoint: str, *, repository: str) -> Any: + assert repository == REPOSITORY + self.api_calls.append(endpoint) + if endpoint in self.scripted: + return deepcopy(self.scripted[endpoint].pop(0)) + assert endpoint in self.responses, f"unexpected authenticated request: {endpoint}" + return deepcopy(self.responses[endpoint]) + + def git(self, args: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + assert args == ["--no-pager", "rev-parse", "HEAD"] + assert kwargs["cwd"] == self.root + assert kwargs["timeout"] == 30 + return subprocess.CompletedProcess(args, 0, stdout=COMMIT + "\n", stderr="") + + def gh(self, *args: str) -> str: + assert args[:2] == ("attestation", "verify") + self.gh_calls.append(args) + return self.signature_text if self.signature_text is not None else json.dumps(self.signature) + + def download(self, repository: str, artifact: dict[str, Any], directory: Path, names: tuple[str, ...]) -> None: + # ZIP authentication and extraction are covered by the shared package. + # This boundary supplies only the adapter's two extracted input files. + self.downloads.append((repository, deepcopy(artifact), directory, names)) + assert names == ("validation.json", "validation.sigstore.json") + directory.mkdir() + payload = self.document_text if self.document_text is not None else json.dumps(self.document) + (directory / "validation.json").write_text(payload, encoding="utf-8") + (directory / "validation.sigstore.json").write_text('{"fixture":"signed bundle"}', encoding="utf-8") + + +@pytest.fixture +def provider(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> EvidenceProvider: + spec = importlib.util.spec_from_file_location("la_stack_release_evidence", REPO_ROOT / "tooling/release_evidence.py") + assert spec is not None + assert spec.loader is not None + adapter = importlib.util.module_from_spec(spec) + spec.loader.exec_module(adapter) + root = tmp_path / "checkout with spaces" + root.mkdir() + (root / "Cargo.toml").write_text('[package]\nname = "la-stack"\nversion = "0.5.42"\n', encoding="utf-8") + result = EvidenceProvider(adapter, root) + monkeypatch.setattr(adapter, "ROOT", root) + monkeypatch.setattr(adapter, "api", result.api) + monkeypatch.setattr(adapter, "gh", result.gh) + monkeypatch.setattr(adapter, "download_artifact", result.download) + monkeypatch.setattr(adapter, "run_git_command", result.git) + monkeypatch.setenv("GITHUB_SHA", COMMIT) + monkeypatch.setenv("GITHUB_RUN_ID", "101") + monkeypatch.setenv("GITHUB_RUN_ATTEMPT", "2") + return result + + +def test_verify_consumes_exact_main_attempt_with_bound_attestation_flags(provider: EvidenceProvider) -> None: + assert provider.adapter.verify() == PIN + assert provider.api_calls.count(JOBS_ENDPOINT) == 2 + assert provider.api_calls[-3:] == [RUN_ENDPOINT, "actions/workflows/ci.yml", ARTIFACT_ENDPOINT] + assert len(provider.downloads) == 1 + repository, artifact, directory, names = provider.downloads[0] + assert repository == REPOSITORY + assert artifact == {"id": 301, "name": "release-evidence-101-2", "digest": DIGEST, "size_in_bytes": 512} + assert names == ("validation.json", "validation.sigstore.json") + assert provider.gh_calls == [ + ( + "attestation", + "verify", + str(directory / "validation.json"), + "--bundle", + str(directory / "validation.sigstore.json"), + "--repo", + REPOSITORY, + "--signer-workflow", + "acgetchell/la-stack/.github/workflows/ci.yml", + "--signer-digest", + COMMIT, + "--source-digest", + COMMIT, + "--source-ref", + "refs/heads/main", + "--deny-self-hosted-runners", + "--predicate-type", + "https://slsa.dev/provenance/v1", + "--format", + "json", + ) + ] + assert not directory.exists() + + +def test_seal_captures_successful_native_jobs_while_its_signing_job_is_pending(provider: EvidenceProvider) -> None: + provider.run.update(status="in_progress", conclusion=None) + provider.jobs.pop() # The sealing job cannot have completed its own upload. + output = provider.root / "evidence" / "validation.json" + provider.adapter.seal(output) + assert json.loads(output.read_text(encoding="utf-8")) == provider.document + assert provider.api_calls == [RUN_ENDPOINT, "actions/workflows/ci.yml", JOBS_ENDPOINT, RUN_ENDPOINT, "actions/workflows/ci.yml"] + assert provider.downloads == [] + assert provider.gh_calls == [] + original = output.read_bytes() + with pytest.raises(FileExistsError): + provider.adapter.seal(output) + assert output.read_bytes() == original + + +def test_seal_rejects_environment_checkout_mismatch_before_creating_output(provider: EvidenceProvider, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_SHA", "c" * 40) + output = provider.root / "evidence" / "validation.json" + with pytest.raises(ValueError, match="executing CI source differs from checkout"): + provider.adapter.seal(output) + assert not output.parent.exists() + assert provider.api_calls == [] + + +def test_seal_rechecks_the_attempt_before_publishing_the_document(provider: EvidenceProvider) -> None: + provider.scripted[RUN_ENDPOINT] = [deepcopy(provider.run), {**provider.run, "run_attempt": 3}] + output = provider.root / "evidence" / "validation.json" + with pytest.raises(ValueError, match="selected CI attempt is stale"): + provider.adapter.seal(output) + assert not output.parent.exists() + assert JOBS_ENDPOINT in provider.api_calls + + +@pytest.mark.parametrize("job_index", [0, 3], ids=["native", "package"]) +def test_seal_never_writes_a_manifest_for_failed_required_validation(provider: EvidenceProvider, job_index: int) -> None: + provider.jobs[job_index]["steps"][0]["conclusion"] = "failure" + output = provider.root / "evidence" / "validation.json" + with pytest.raises(ValueError, match="did not validate required step"): + provider.adapter.seal(output) + assert not output.parent.exists() + + +def test_pinned_recheck_uses_only_the_approved_run_attempt_and_artifact(provider: EvidenceProvider) -> None: + identity = json.dumps(provider.adapter.verify()) + provider.api_calls.clear() + del provider.responses[RUNS_ENDPOINT] + del provider.responses[ARTIFACTS_ENDPOINT] + assert provider.adapter.verify(identity) == PIN + assert provider.api_calls.count(RUN_ENDPOINT) == 2 + assert provider.api_calls.count(ARTIFACT_ENDPOINT) == 2 + assert len(provider.gh_calls) == 2 + + +def test_pinned_recheck_rejects_an_attempt_replaced_by_a_rerun(provider: EvidenceProvider) -> None: + identity = json.dumps({**PIN, "run_attempt": 1}) + with pytest.raises(ValueError, match="selected CI attempt is stale"): + provider.adapter.verify(identity) + assert provider.downloads == [] + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("event", "pull_request"), + ("event", "workflow_dispatch"), + ("head_branch", "release"), + ("head_sha", "c" * 40), + ("workflow_id", 10), + ("path", ".github/workflows/other.yml"), + ], +) +def test_verify_rejects_another_source_event_ref_commit_or_workflow(provider: EvidenceProvider, field: str, value: Any) -> None: + provider.run[field] = value + with pytest.raises(ValueError, match="exact commit's trusted main-push"): + provider.adapter.verify() + assert provider.downloads == [] + + +def test_verify_rejects_a_fork_run_with_matching_commit(provider: EvidenceProvider) -> None: + provider.run["head_repository"] = {"id": 12, "full_name": "fork/la-stack"} + with pytest.raises(ValueError, match="repository or head repository differs"): + provider.adapter.verify() + assert provider.downloads == [] + + +def test_verify_rejects_a_failed_run_without_using_successful_job_fragments(provider: EvidenceProvider) -> None: + provider.run["conclusion"] = "failure" + with pytest.raises(ValueError, match="missing or failed main-push validation"): + provider.adapter.verify() + assert JOBS_ENDPOINT not in provider.api_calls + assert provider.downloads == [] + + +@pytest.mark.parametrize("job_index", [0, 4], ids=["native", "signing"]) +@pytest.mark.parametrize(("field", "value"), [("run_id", 102), ("run_attempt", 1), ("head_sha", "c" * 40), ("head_branch", "release")]) +def test_job_identity_must_belong_to_the_exact_run_and_attempt(provider: EvidenceProvider, job_index: int, field: str, value: Any) -> None: + provider.jobs[job_index][field] = value + with pytest.raises(ValueError, match="belongs to another run, attempt, ref or commit"): + provider.adapter.verify() + assert provider.downloads == [] + + +@pytest.mark.parametrize("job_index", [0, 4], ids=["native", "signing"]) +def test_failed_jobs_cannot_be_replaced_by_successful_step_fragments(provider: EvidenceProvider, job_index: int) -> None: + provider.jobs[job_index]["conclusion"] = "failure" + with pytest.raises(ValueError, match="did not succeed"): + provider.adapter.verify() + assert provider.downloads == [] + + +def test_native_job_identities_cannot_alias_one_another(provider: EvidenceProvider) -> None: + provider.jobs[1]["id"] = provider.jobs[0]["id"] + with pytest.raises(ValueError, match="validation job IDs are not unique"): + provider.adapter.verify() + assert provider.downloads == [] + + +@pytest.mark.parametrize("job_index", range(5)) +@pytest.mark.parametrize("duplicate", [False, True], ids=["missing", "ambiguous"]) +def test_every_native_package_and_signing_job_is_unambiguous(provider: EvidenceProvider, job_index: int, duplicate: bool) -> None: + name = provider.jobs[job_index]["name"] + if duplicate: + provider.jobs.append(deepcopy(provider.jobs[job_index])) + else: + provider.jobs.pop(job_index) + with pytest.raises(ValueError, match="missing or ambiguous") as failure: + provider.adapter.verify() + assert name in str(failure.value) + assert provider.downloads == [] + + +@pytest.mark.parametrize(("job_index", "step_index"), REQUIRED_STEPS) +@pytest.mark.parametrize("conclusion", ["failure", "skipped"]) +def test_required_native_package_and_signing_steps_must_succeed(provider: EvidenceProvider, job_index: int, step_index: int, conclusion: str) -> None: + step = provider.jobs[job_index]["steps"][step_index] + step["conclusion"] = conclusion + with pytest.raises(ValueError, match="did not validate required step") as failure: + provider.adapter.verify() + assert step["name"] in str(failure.value) + assert provider.downloads == [] + + +@pytest.mark.parametrize("duplicate", [False, True], ids=["missing", "ambiguous"]) +def test_required_step_names_cannot_be_substituted_or_duplicated(provider: EvidenceProvider, duplicate: bool) -> None: + steps = provider.jobs[3]["steps"] + if duplicate: + steps.append(deepcopy(steps[0])) + else: + steps[0]["name"] = "Package check skipped" + with pytest.raises(ValueError, match="did not validate required step Verify default and exact Cargo packages"): + provider.adapter.verify() + + +@pytest.mark.parametrize( + ("field", "value"), + [("id", 102), ("head_sha", "c" * 40), ("head_branch", "release"), ("repository_id", 12), ("head_repository_id", 12)], +) +def test_artifact_association_must_match_the_validated_run(provider: EvidenceProvider, field: str, value: Any) -> None: + provider.artifact["workflow_run"][field] = value + with pytest.raises(ValueError, match="artifact is expired or belongs to another"): + provider.adapter.verify() + assert provider.downloads == [] + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("expired", True, "artifact is expired"), + ("digest", None, "authoritative SHA-256 digest"), + ("digest", "sha256:not-a-digest", "authoritative SHA-256 digest"), + ("size_in_bytes", 0, "positive integer identity"), + ("size_in_bytes", 1024 * 1024 + 1, "one-MiB limit"), + ("id", True, "positive integer identity"), + ], +) +def test_artifact_requires_unexpired_bounded_authoritative_identity(provider: EvidenceProvider, field: str, value: Any, message: str) -> None: + provider.artifact[field] = value + with pytest.raises(ValueError, match=message): + provider.adapter.verify() + assert provider.downloads == [] + + +@pytest.mark.parametrize("inventory", ["missing", "ambiguous", "other-attempt"]) +def test_artifact_selection_does_not_fall_back_or_choose_ambiguously(provider: EvidenceProvider, inventory: str) -> None: + variants = { + "missing": [], + "ambiguous": [provider.artifact, deepcopy(provider.artifact)], + "other-attempt": [{**provider.artifact, "name": "release-evidence-101-1"}], + } + provider.responses[ARTIFACTS_ENDPOINT] = {"artifacts": variants[inventory]} + with pytest.raises(ValueError, match="missing or ambiguous signed CI evidence"): + provider.adapter.verify() + assert provider.downloads == [] + + +def test_pinned_digest_cannot_be_replaced_by_another_valid_digest(provider: EvidenceProvider) -> None: + provider.artifact["digest"] = "sha256:" + "c" * 64 + with pytest.raises(ValueError, match="approved evidence identity changed"): + provider.adapter.verify(json.dumps(PIN)) + assert provider.downloads == [] + + +@pytest.mark.parametrize( + ("path", "value"), + [ + (("schema",), 2), + (("commit",), "c" * 40), + (("run_id",), 102), + (("run_attempt",), 1), + (("repository_id",), 12), + (("package", "version"), "0.5.41"), + (("jobs", "build (ubuntu-latest)"), 999), + ], +) +def test_signed_manifest_must_equal_current_package_and_native_identities(provider: EvidenceProvider, path: tuple[str, ...], value: Any) -> None: + target = provider.document + for key in path[:-1]: + target = target[key] + target[path[-1]] = value + with pytest.raises(ValueError, match="signed evidence differs from current exact-commit Cargo and native validation identities"): + provider.adapter.verify() + assert len(provider.gh_calls) == 1 + assert not provider.downloads[0][2].exists() + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("runInvocationURI", INVOCATION.replace("/101/", "/102/")), + ("runInvocationURI", INVOCATION.replace("/attempts/2", "/attempts/1")), + ("buildTrigger", "pull_request"), + ("buildTrigger", "workflow_dispatch"), + ], +) +def test_verified_attestation_must_name_this_push_invocation(provider: EvidenceProvider, field: str, value: str) -> None: + provider.signature[0]["verificationResult"]["signature"]["certificate"][field] = value + with pytest.raises(ValueError, match="attestation run, attempt or push event differs"): + provider.adapter.verify() + assert not provider.downloads[0][2].exists() + + +@pytest.mark.parametrize("source", ["pin", "manifest", "attestation"]) +def test_duplicate_keys_cannot_replace_pinned_signed_or_verified_claims(provider: EvidenceProvider, source: str) -> None: + identity = "" + if source == "pin": + identity = json.dumps(PIN).replace('"run_id": 101', '"run_id": 101, "run_id": 101', 1) + elif source == "manifest": + provider.document_text = json.dumps(provider.document).replace('"schema": 1', '"schema": 1, "schema": 1', 1) + else: + provider.signature_text = json.dumps(provider.signature).replace('"buildTrigger": "push"', '"buildTrigger": "push", "buildTrigger": "push"', 1) + with pytest.raises(ValueError, match="duplicate evidence key"): + provider.adapter.verify(identity) + if source == "pin": + assert provider.api_calls == [] + else: + assert not provider.downloads[0][2].exists() + + +def test_successful_verification_rejects_a_rerun_started_during_consumption(provider: EvidenceProvider) -> None: + provider.responses[RUN_ENDPOINT] = {**provider.run, "run_attempt": 3} + with pytest.raises(ValueError, match="selected CI attempt is stale"): + provider.adapter.verify() + assert len(provider.gh_calls) == 1 + assert not provider.downloads[0][2].exists() + + +def test_successful_verification_rechecks_immutable_artifact_identity(provider: EvidenceProvider) -> None: + provider.responses[ARTIFACT_ENDPOINT] = {**provider.artifact, "digest": "sha256:" + "c" * 64} + with pytest.raises(ValueError, match="evidence artifact changed during verification"): + provider.adapter.verify() + assert len(provider.gh_calls) == 1 + assert not provider.downloads[0][2].exists() + + +@pytest.mark.parametrize("binary", [False, True]) +@pytest.mark.parametrize("timeout", [False, True]) +def test_cli_preserves_provider_failure_diagnostics( + provider: EvidenceProvider, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], binary: bool, timeout: bool +) -> None: + command = ["gh", "attestation", "verify", "validation.json"] + output = b"provider response" if binary else "provider response" + diagnostic = b"artifact 301 expired\xff" if binary else "artifact 301 expired" + error = ( + subprocess.TimeoutExpired(command, 30, output=output, stderr=diagnostic) + if timeout + else subprocess.CalledProcessError(1, command, output=output, stderr=diagnostic) + ) + + def fail(*args: str) -> str: + raise error + + monkeypatch.setattr(provider.adapter, "gh", fail) + monkeypatch.setattr(sys, "argv", ["release_evidence", "verify"]) + assert provider.adapter.main() == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "artifact 301 expired" in captured.err + assert "provider response" in captured.err + assert "timed out after 30 seconds" in captured.err if timeout else "exit status 1" in captured.err + assert not provider.downloads[0][2].exists() + + +def test_cli_success_emits_only_the_pinned_json_identity( + provider: EvidenceProvider, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr(sys, "argv", ["release_evidence", "verify"]) + assert provider.adapter.main() == 0 + captured = capsys.readouterr() + assert json.loads(captured.out) == PIN + assert captured.err == "" diff --git a/tooling/tests/test_release_publishing.py b/tooling/tests/test_release_publishing.py index fdd4ee3..2e88479 100644 --- a/tooling/tests/test_release_publishing.py +++ b/tooling/tests/test_release_publishing.py @@ -180,10 +180,12 @@ def test_workflow_requires_approval_revalidation_and_absence_before_oidc() -> No jobs = workflow["jobs"] assert jobs["publish"]["needs"] == "verify" assert jobs["publish"]["environment"] == "crates-io" - assert jobs["verify"]["permissions"] == {"contents": "read", "checks": "read"} - assert jobs["publish"]["permissions"] == {"contents": "read", "checks": "read", "id-token": "write"} + assert jobs["verify"]["permissions"] == {"contents": "read", "checks": "read", "actions": "read"} + assert jobs["publish"]["permissions"] == {"contents": "read", "checks": "read", "actions": "read", "id-token": "write"} assert "github.repository == 'acgetchell/la-stack'" in jobs["verify"]["if"] - assert jobs["verify"]["steps"][-1]["run"] == "just release-package" + assert jobs["verify"]["steps"][-1]["id"] == "evidence" + assert "identity=$(just --quiet release-evidence)" in jobs["verify"]["steps"][-1]["run"] + assert jobs["verify"]["outputs"] == {"ci-evidence": "${{ steps.evidence.outputs.identity }}"} assert "CARGO_REGISTRY_TOKEN" not in yaml.safe_dump({"env": workflow["env"], "verify": jobs["verify"]}) for job in jobs.values(): checkout = job["steps"][0] @@ -192,6 +194,9 @@ def test_workflow_requires_approval_revalidation_and_absence_before_oidc() -> No steps = jobs["publish"]["steps"] gate = steps[2]["run"] assert 'just release-gate "$RELEASE_TAG"' in gate + assert 'test -n "$CI_EVIDENCE"' in gate + assert 'just release-evidence "$CI_EVIDENCE"' in gate + assert steps[2]["env"]["CI_EVIDENCE"] == "${{ needs.verify.outputs.ci-evidence }}" assert 'release registry "$RELEASE_TAG" --expect absent' in gate assert steps[3]["id"] == "auth" assert steps[4]["run"].endswith("cargo publish --locked --package la-stack --registry crates-io --no-verify") @@ -206,25 +211,55 @@ def test_workflow_requires_approval_revalidation_and_absence_before_oidc() -> No assert "--no-verify" not in commands -@pytest.mark.parametrize(("failure", "expected_exit"), [("none", 0), ("evidence", 1), ("present", 1), ("unavailable", 2)]) +@pytest.mark.parametrize(("failure", "expected_exit"), [("none", 0), ("evidence", 1), ("ci-evidence", 1), ("empty-pin", 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 = workflow["jobs"]["publish"]["steps"][2]["run"] marker = tmp_path / "upload-reached" gate = "just() { return 1; }" if failure == "evidence" else "just() { return 0; }" + if failure == "ci-evidence": + gate = 'just() { [[ "$1" != release-evidence ]]; }' registry_exit = expected_exit if failure in {"present", "unavailable"} else 0 registry = f"uv() {{ return {registry_exit}; }}" script = f"{gate}\n{registry}\n{script}\ntouch {shlex.quote(marker.as_posix())}" result = run_command( "bash", ["--noprofile", "--norc", "-euc", script], - env={**{key: value for key, value in os.environ.items() if key not in {"BASH_ENV", "SHELLOPTS", "BASHOPTS"}}, "RELEASE_TAG": TAG}, + env={ + **{key: value for key, value in os.environ.items() if key not in {"BASH_ENV", "SHELLOPTS", "BASHOPTS"}}, + "RELEASE_TAG": TAG, + "CI_EVIDENCE": "" if failure == "empty-pin" else '{"run_id": 11}', + }, check=False, ) assert result.returncode == expected_exit, result.stderr assert marker.exists() == (failure == "none") +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"} + package = jobs["release-package"] + assert package["steps"][1]["with"]["cache"] == "false" + assert package["steps"][-1] == {"name": "Verify default and exact Cargo packages", "run": "just release-package"} + 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'" + assert evidence["permissions"] == {"contents": "read", "actions": "read", "attestations": "write", "id-token": "write"} + steps = {step["name"]: step for step in evidence["steps"] if "name" in step} + assert steps["Capture successful native validation identities"]["run"].endswith("seal --output target/release-evidence/validation.json") + assert steps["Sign original main validation evidence"]["with"]["subject-path"] == "target/release-evidence/validation.json" + upload = steps["Upload signed validation evidence"]["with"] + assert upload == { + "name": "release-evidence-${{ github.run_id }}-${{ github.run_attempt }}", + "path": "target/release-evidence/", + "if-no-files-found": "error", + "retention-days": 30, + } + + def test_native_packaging_failure_stops_before_further_validation(tmp_path: Path) -> None: script = dry_run(REPO_ROOT, "release-package").stderr calls = tmp_path / "calls" diff --git a/ty.toml b/ty.toml index 9f035a9..8830ca6 100644 --- a/ty.toml +++ b/ty.toml @@ -1,10 +1,6 @@ # ty.toml # Astral ty configuration for la-stack -# Python contains tooling integration tests; Rust is primary. - -[src] -# Restrict analysis strictly to Python tooling. -include = [ "tooling/tests" ] +# Python contains a release evidence adapter and integration tests; Rust is primary. [terminal] # Keep output concise by default.