From 5dcf6c3c8b7ef2eadb8799efcc08f1c18a437969 Mon Sep 17 00:00:00 2001 From: Aaron Gonzales Date: Fri, 16 Oct 2026 14:41:50 +0000 Subject: [PATCH] build: prepare PR11 with reviewed workflow simplifications Signed-off-by: Aaron Gonzales --- .dockerignore | 2 + .github/CODEOWNERS | 7 +- .github/workflows/ci.yml | 107 ++++-- .github/workflows/health-checks.yml | 24 +- .github/workflows/release.yml | 28 +- .gitignore | 3 + .mise.toml | 17 + .mise/tasks/check/dist | 24 ++ .mise/tasks/check/extensions | 22 ++ .mise/tasks/clean | 12 + .mise/tasks/containers.toml | 17 + .mise/tasks/packaging.toml | 28 ++ .mise/tasks/quality.toml | 97 +++++ .mise/tasks/setup.toml | 23 ++ .mise/tasks/tests.toml | 27 ++ .pre-commit-config.yaml | 16 +- CONTRIBUTING.md | 58 +-- Makefile | 449 +++++++--------------- README.md | 6 +- containers/Dockerfile.test | 11 +- docs/engineering/ci.md | 26 +- docs/engineering/container-tests.md | 9 +- docs/engineering/contribution-policy.md | 2 +- docs/engineering/e2e.md | 10 +- docs/engineering/mise.md | 56 +++ docs/engineering/releases.md | 10 +- docs/notebooks.md | 11 +- mise.lock | 29 ++ pyproject.toml | 4 +- scripts/check_health.py | 3 +- scripts/check_notebooks.py | 30 ++ scripts/check_task_scripts.py | 24 ++ scripts/check_tool_versions.py | 33 ++ scripts/install_mise.sh | 48 +++ scripts/mise | 28 ++ scripts/run_container_tests.py | 8 +- scripts/run_e2e.py | 18 +- scripts/run_ruff.py | 26 ++ scripts/run_test_suite.py | 31 ++ scripts/update_license_headers.py | 15 +- tests/test_ci_workflows.py | 67 ++-- tests/test_container_runner.py | 3 +- tests/test_coverage_gate.py | 39 +- tests/test_e2e_harness.py | 85 +++- tests/test_mise_bootstrap.py | 110 ++++++ tests/test_mise_tasks.py | 111 ++++++ tests/test_release_validation_workflow.py | 23 +- 47 files changed, 1319 insertions(+), 518 deletions(-) create mode 100644 .mise.toml create mode 100755 .mise/tasks/check/dist create mode 100755 .mise/tasks/check/extensions create mode 100755 .mise/tasks/clean create mode 100644 .mise/tasks/containers.toml create mode 100644 .mise/tasks/packaging.toml create mode 100644 .mise/tasks/quality.toml create mode 100644 .mise/tasks/setup.toml create mode 100644 .mise/tasks/tests.toml create mode 100644 docs/engineering/mise.md create mode 100644 mise.lock create mode 100644 scripts/check_notebooks.py create mode 100644 scripts/check_task_scripts.py create mode 100644 scripts/check_tool_versions.py create mode 100755 scripts/install_mise.sh create mode 100755 scripts/mise create mode 100644 scripts/run_ruff.py create mode 100644 scripts/run_test_suite.py create mode 100644 tests/test_mise_bootstrap.py create mode 100644 tests/test_mise_tasks.py diff --git a/.dockerignore b/.dockerignore index a2dfc6b..5a76fa9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -46,6 +46,8 @@ _*/ **/AGENTS.local.md **/CLAUDE.local.md +.cache + # Generated model-specific vectors; retain the source asset banks. src/usersim/engine/assets/financial_services/**/embeddings.parquet **/junit.xml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4ca330c..8a110b8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -26,10 +26,15 @@ /tests/test_ignore_policy.py @NVIDIA-NeMo/user_sim_maintainers /tests/test_update_license_headers.py @NVIDIA-NeMo/user_sim_maintainers /docs/engineering/ @NVIDIA-NeMo/user_sim_maintainers -/tests/fixtures/exclusion-policy.json @NVIDIA-NeMo/user_sim_maintainers /containers/ @NVIDIA-NeMo/user_sim_maintainers /.dockerignore @NVIDIA-NeMo/user_sim_maintainers /tests/test_container_runner.py @NVIDIA-NeMo/user_sim_maintainers /tests/test_coverage_gate.py @NVIDIA-NeMo/user_sim_maintainers /tests/test_check_ci_results.py @NVIDIA-NeMo/user_sim_maintainers /tests/test_release_validation_workflow.py @NVIDIA-NeMo/user_sim_maintainers + +/.mise.toml @NVIDIA-NeMo/user_sim_maintainers +/mise.lock @NVIDIA-NeMo/user_sim_maintainers +/.mise/ @NVIDIA-NeMo/user_sim_maintainers + +/tests/fixtures/exclusion-policy.json @NVIDIA-NeMo/user_sim_maintainers diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82c01c1..e578bbd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -# Every job here wraps a `make` target rather than restating the command, so +# Every job here calls a shared Mise task rather than restating the command, so # what CI enforces and what you can run locally cannot drift apart. If a job # fails, the same target reproduces it on your machine. # @@ -25,6 +25,7 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: + UV_PYTHON: "3.12" PYTEST_WORKERS: "2" NEMO_TELEMETRY_ENABLED: "false" @@ -36,19 +37,23 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + - name: Verified Mise bootstrap + run: bash scripts/install_mise.sh + - name: Cache locked Python dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - version-file: "pyproject.toml" - python-version: "3.12" - enable-cache: true + path: ~/.cache/uv + key: uv-${{ runner.os }}-${{ runner.arch }}-${{ env.UV_PYTHON }}-${{ hashFiles('uv.lock', 'mise.lock') }} + - name: Locked registry tools + run: scripts/mise run setup:tools - - run: make install-ci + - run: scripts/mise run setup:ci - name: Static checks and all tracked-file hooks - run: make check-all + run: scripts/mise run check - name: Dependency licences - run: make check-dependency-licenses + run: scripts/mise run check:license:dependencies typecheck: @@ -57,15 +62,21 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + - name: Verified Mise bootstrap + run: bash scripts/install_mise.sh + - name: Cache locked Python dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - version-file: "pyproject.toml" - python-version: "3.12" - enable-cache: true - - run: make install-ci - - run: make typecheck + path: ~/.cache/uv + key: uv-${{ runner.os }}-${{ runner.arch }}-${{ env.UV_PYTHON }}-${{ hashFiles('uv.lock', 'mise.lock') }} + - name: Locked registry tools + run: scripts/mise run setup:tools + - run: scripts/mise run setup:ci + - run: scripts/mise run check:type test: + env: + UV_PYTHON: ${{ matrix.python-version }} name: Tests (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest timeout-minutes: 30 @@ -76,24 +87,32 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + - name: Verified Mise bootstrap + run: bash scripts/install_mise.sh + - name: Cache locked Python dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - version-file: "pyproject.toml" - python-version: ${{ matrix.python-version }} - enable-cache: true + path: ~/.cache/uv + key: uv-${{ runner.os }}-${{ runner.arch }}-${{ env.UV_PYTHON }}-${{ hashFiles('uv.lock', 'mise.lock') }} + - name: Locked registry tools + run: scripts/mise run setup:tools - - run: make install-ci + - run: scripts/mise run setup:ci - name: Test suite if: matrix.python-version != '3.12' # Durations are reported because a slow test is invisible until # someone profiles the suite, and the runner is the only place that # measures the hardware CI actually runs on. - run: make test PYTEST_ARGS="--durations=25 --junitxml=junit.xml" + env: + PYTEST_ARGS: "--durations=25 --junitxml=junit.xml" + run: scripts/mise run test - name: Test suite with coverage if: matrix.python-version == '3.12' - run: make coverage PYTEST_ARGS="--durations=25 --junitxml=junit.xml" + env: + PYTEST_ARGS: "--durations=25 --junitxml=junit.xml" + run: scripts/mise run test:ci - name: Coverage report if: always() && matrix.python-version == '3.12' @@ -114,12 +133,12 @@ jobs: retention-days: 14 - name: Offline self-check - run: make smoke + run: scripts/mise run test:smoke - name: Dependency licences # Each Python resolves its own releases of some dependencies, and the # policy approves exceptions per release and reported licence. - run: make check-dependency-licenses + run: scripts/mise run check:license:dependencies packaging: name: Wheel and extension seams @@ -128,19 +147,23 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + - name: Verified Mise bootstrap + run: bash scripts/install_mise.sh + - name: Cache locked Python dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - version-file: "pyproject.toml" - python-version: "3.12" - enable-cache: true + path: ~/.cache/uv + key: uv-${{ runner.os }}-${{ runner.arch }}-${{ env.UV_PYTHON }}-${{ hashFiles('uv.lock', 'mise.lock') }} + - name: Locked registry tools + run: scripts/mise run setup:tools - - run: make install-ci + - run: scripts/mise run setup:ci - name: Build and check the sdist and wheel, install the wheel clean, run it from an empty directory - run: make check-dist + run: scripts/mise run check:dist - name: An out-of-tree package reaches every extension seam - run: make check-extensions + run: scripts/mise run check:extensions e2e: name: Installed CLI e2e @@ -148,13 +171,19 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + - name: Verified Mise bootstrap + run: bash scripts/install_mise.sh + - name: Cache locked Python dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - version-file: "pyproject.toml" - python-version: "3.12" - enable-cache: true - - run: make install-ci - - run: make test-e2e E2E_ARGS="--artifacts test-results/e2e" + path: ~/.cache/uv + key: uv-${{ runner.os }}-${{ runner.arch }}-${{ env.UV_PYTHON }}-${{ hashFiles('uv.lock', 'mise.lock') }} + - name: Locked registry tools + run: scripts/mise run setup:tools + - run: scripts/mise run setup:ci + - env: + E2E_ARGS: "--artifacts test-results/e2e" + run: scripts/mise run test:e2e - name: E2e diagnostics on failure if: failure() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -170,9 +199,11 @@ jobs: timeout-minutes: 65 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Verified Mise bootstrap + run: bash scripts/install_mise.sh - name: Build and run isolated CPU validation - run: make test-container + run: scripts/mise run test:ci-container - name: Container test diagnostics if: always() @@ -194,4 +225,4 @@ jobs: CI_JOB_RESULTS: ${{ toJSON(needs) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - run: make check-ci-status + - run: python3 scripts/check_ci_results.py diff --git a/.github/workflows/health-checks.yml b/.github/workflows/health-checks.yml index aabbeb5..edf36b1 100644 --- a/.github/workflows/health-checks.yml +++ b/.github/workflows/health-checks.yml @@ -32,6 +32,9 @@ concurrency: group: health-checks cancel-in-progress: false +env: + UV_PYTHON: "3.12" + jobs: live: name: ${{ matrix.provider }} end to end @@ -66,13 +69,17 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + - name: Verified Mise bootstrap + run: bash scripts/install_mise.sh + - name: Cache locked Python dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - version-file: "pyproject.toml" - python-version: "3.12" - enable-cache: true + path: ~/.cache/uv + key: uv-${{ runner.os }}-${{ runner.arch }}-${{ env.UV_PYTHON }}-${{ hashFiles('uv.lock', 'mise.lock') }} + - name: Locked registry tools + run: scripts/mise run setup:tools - - run: make install-ci + - run: scripts/mise run setup:ci - name: Simulate, evaluate and report env: @@ -90,11 +97,8 @@ jobs: HEALTH_LOCALE: ${{ matrix.locale }} run: | export "$KEY_ENV=$PROVIDER_API_KEY" - make health-check \ - PROVIDER_CONFIG="$PROVIDER_CONFIG" \ - HEALTH_LOCALE="$HEALTH_LOCALE" \ - HEALTH_SUMMARY_FILE=/tmp/usersim-health-summary.md \ - | tee /tmp/health.log + export HEALTH_SUMMARY_FILE=/tmp/usersim-health-summary.md + scripts/mise run test:health | tee /tmp/health.log - name: Summary if: always() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a27d328..3f9947e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,9 @@ concurrency: group: release-${{ github.ref }} cancel-in-progress: false +env: + UV_PYTHON: "3.12" + jobs: build: name: Build and check @@ -39,22 +42,19 @@ jobs: fetch-depth: 0 persist-credentials: false - - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 - with: - version-file: "pyproject.toml" - python-version: "3.12" - # A cache written by another run could otherwise reach the - # published files. - enable-cache: false + - name: Verified Mise bootstrap + run: bash scripts/install_mise.sh + - name: Locked registry tools + run: scripts/mise run setup:tools - - run: make install-ci + - run: scripts/mise run setup:ci - name: Native checks, coverage, smoke, and extension contracts # Re-run the shared gates on the tagged commit, even if PR CI passed. - run: make validate-native + run: scripts/mise run validate:native - name: Build and check the sdist and wheel, install the wheel clean, run it from an empty directory - run: make check-dist + run: scripts/mise run check:dist - name: The tag names the built release version and a commit on main id: tag @@ -62,15 +62,17 @@ jobs: # In the environment, not on make's command line: make would expand # a `$(shell ...)` in the tag name there. TAG: ${{ github.ref_name }} - run: make check-release-tag + run: scripts/mise run check:release-tag - name: Installed CLI e2e against the original release wheel # check-dist produced and validated this single wheel. Do not rebuild. - run: make test-e2e WHEEL_DIR=dist + env: + WHEEL_DIR: dist + run: scripts/mise run test:e2e --wheel-dir dist - name: SHA-256 digests for publication approval # Available from the read-only build before the release environment gate. - run: make release-digests + run: scripts/mise run release:digests - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: diff --git a/.gitignore b/.gitignore index fb305ec..52523ae 100644 --- a/.gitignore +++ b/.gitignore @@ -74,5 +74,8 @@ CLAUDE.local.md .codex/config.local.toml .worktrees/ +# Repository-scoped Mise installations and runtime state +.cache/ + # Local agent working notes /.agent-work/ diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..1463a4e --- /dev/null +++ b/.mise.toml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Mise owns the uv release; Python quality tools remain owned by uv.lock. +min_version = "2026.8.5" + +[tools] +uv = "0.13.1" + +[task_config] +includes = [".mise/tasks", ".mise/tasks/*.toml"] + +[settings] +lockfile = true +lockfile_platforms = ["linux-x64", "linux-arm64", "macos-arm64", "macos-x64"] +task.run_auto_install = false +idiomatic_version_file_enable_tools = [] diff --git a/.mise/tasks/check/dist b/.mise/tasks/check/dist new file mode 100755 index 0000000..2edc796 --- /dev/null +++ b/.mise/tasks/check/dist @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +#MISE description="Validate archives and a clean wheel installation" +#MISE depends=["build:dist"] + +set -euo pipefail +cd "${MISE_CONFIG_ROOT:?}" +"${UV:-uv}" run python scripts/check_dist.py dist; \ +WORK=$(mktemp -d); \ +EMPTY=$(mktemp -d); \ +trap 'rm -rf "$WORK" "$EMPTY"' EXIT; \ +echo "→ installing the wheel into a throwaway venv"; \ +"${UV:-uv}" venv "$WORK/venv" >/dev/null; \ +test -x "$WORK/venv/bin/python" || { echo "venv was not created"; exit 1; }; \ +"${UV:-uv}" pip install --quiet --python "$WORK/venv/bin/python" dist/*.whl; \ +test -x "$WORK/venv/bin/usersim" \ + || { echo " ✗ the wheel installed but shipped no usersim entry point"; exit 1; }; \ +echo "→ running from $EMPTY (no repo in sight)"; \ +cd "$EMPTY" && "$WORK/venv/bin/usersim" smoke; \ +cd "$EMPTY" && "$WORK/venv/bin/usersim" simulate --dry-run \ + --locale en_US --num-rows 1 --out ./out >/dev/null; \ +echo "✓ installed package runs from a clean environment" diff --git a/.mise/tasks/check/extensions b/.mise/tasks/check/extensions new file mode 100755 index 0000000..0d686a9 --- /dev/null +++ b/.mise/tasks/check/extensions @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +#MISE description="Validate installed extension seams" + +set -euo pipefail +cd "${MISE_CONFIG_ROOT:?}" +WORK=$(mktemp -d); \ +EMPTY=$(mktemp -d); \ +trap 'rm -rf "$WORK" "$EMPTY"' EXIT; \ +echo "→ building wheels into $WORK"; \ +"${UV:-uv}" build --wheel -o "$WORK" >/dev/null; \ +echo "→ installing them plus the fixture extension into a throwaway venv"; \ +"${UV:-uv}" venv "$WORK/venv" >/dev/null; \ +test -x "$WORK/venv/bin/python" || { echo "venv was not created"; exit 1; }; \ +"${UV:-uv}" pip install --quiet --python "$WORK/venv/bin/python" "$WORK"/*.whl; \ +cp -R tests/fixtures/extension_package "$WORK/ext"; \ +"${UV:-uv}" pip install --quiet --python "$WORK/venv/bin/python" "$WORK/ext"; \ +echo "→ asking the installed CLI what it can see, from $EMPTY"; \ +cp tests/fixtures/extension_package/verify_seams.py "$EMPTY/"; \ +cd "$EMPTY" && "$WORK/venv/bin/python" verify_seams.py diff --git a/.mise/tasks/clean b/.mise/tasks/clean new file mode 100755 index 0000000..8aa515e --- /dev/null +++ b/.mise/tasks/clean @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +#MISE description="Remove generated Python and build artifacts" + +set -euo pipefail +cd "${MISE_CONFIG_ROOT:?}" +find . -type d -name __pycache__ -not -path './.venv*' -exec rm -rf {} + 2>/dev/null || true +find . -type f -name '*.py[co]' -not -path './.venv*' -delete 2>/dev/null || true +rm -rf .pytest_cache .ruff_cache .coverage coverage.xml htmlcov build dist src/*.egg-info +echo "Cleaned." diff --git a/.mise/tasks/containers.toml b/.mise/tasks/containers.toml new file mode 100644 index 0000000..8396449 --- /dev/null +++ b/.mise/tasks/containers.toml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +["container:audit"] +description = "Require local Docker and verify synthetic context exclusions" +run = 'python3 scripts/run_container_tests.py audit' + +["container:build:test"] +description = "Audit context and build the digest-pinned CPU validation image" +run = 'python3 scripts/run_container_tests.py build' + +["test:ci-container"] +description = "Build fresh and run CPU coverage and installed e2e offline with bounded resources" +run = [ + { task = "container:build:test" }, + 'python3 scripts/run_container_tests.py run', +] diff --git a/.mise/tasks/packaging.toml b/.mise/tasks/packaging.toml new file mode 100644 index 0000000..3322d8e --- /dev/null +++ b/.mise/tasks/packaging.toml @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +["check:release-tag"] +description = "Verify the tag/version and origin/main ancestry against existing dist archives" +run = 'test -n "$TAG" && "${UV:-uv}" run python scripts/check_release_tag.py "$TAG" dist' + +["validate:native"] +description = "Validate static gates, dependency licenses, coverage, smoke and extension seams" +run = [ + { task = "check" }, { task = "check:license:dependencies" }, + { task = "test:ci" }, { task = "test:smoke" }, { task = "check:extensions" }, +] + +["validate:ci"] +description = "Validate source, build archives once, then run e2e against the same wheel" +run = [ + { task = "validate:native" }, { task = "check:dist" }, + 'mise run test:e2e --wheel-dir dist', +] + +["build:dist"] +description = "Build fresh distribution archives, sdist then wheel" +run = ["rm -rf dist", '"${UV:-uv}" build -q -o dist', "ls -1 dist"] + +["release:digests"] +description = "Print original release archive digests and append the approval summary" +run = 'python3 scripts/release_digests.py dist' diff --git a/.mise/tasks/quality.toml b/.mise/tasks/quality.toml new file mode 100644 index 0000000..57b03a0 --- /dev/null +++ b/.mise/tasks/quality.toml @@ -0,0 +1,97 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +["check:tools"] +description = "Reject uv versions that differ from the Mise registry" +run = 'python3 scripts/check_tool_versions.py' + +["check:lock"] +description = "Verify uv.lock without rewriting it" +depends = ["check:tools"] +run = '"${UV:-uv}" lock --check' + +["check:lint"] +description = "Lint source, scripts, templates and tests without changes" +run = 'python3 scripts/run_ruff.py check --output-format=full' + +["lint:fix"] +description = "Fix Ruff lint findings in the same source scope" +run = 'python3 scripts/run_ruff.py check --fix' + +["check:format"] +description = "Check Python formatting without changes" +run = 'python3 scripts/run_ruff.py format --check' + +[format] +description = "Format Python source, scripts, templates and tests" +run = 'python3 scripts/run_ruff.py format' + +["format:all"] +description = "Run lint fixes and Python formatting" +run = [{ task = "lint:fix" }, { task = "format" }] + +["check:license:headers"] +description = "Verify SPDX headers in source and gate scripts" +run = '"${UV:-uv}" run python scripts/update_license_headers.py --check' + +["license:headers:fix"] +description = "Fix SPDX headers in all source or supplied staged files" +run = '"${UV:-uv}" run python scripts/update_license_headers.py' + +["check:license:dependencies"] +description = "Check runtime dependencies in a locked isolated license environment" +run = '"${UV:-uv}" run --isolated --no-dev --group license-check --locked python scripts/check_dependency_licenses.py' + +["check:docs"] +description = "Check tracked relative documentation links" +run = '"${UV:-uv}" run python scripts/check_doc_links.py' + +["check:notebooks"] +description = "Reject committed notebook output and execution counts" +run = '"${UV:-uv}" run python scripts/check_notebooks.py' + +["notebooks:clean"] +description = "Strip tracked notebook outputs" +run = '"${UV:-uv}" run --with nbstripout nbstripout $(git ls-files "*.ipynb" | grep -v "^_")' + +["check:hooks"] +description = "Run all tracked-file hooks and fail after any rewrite" +run = '"${UV:-uv}" run pre-commit run --all-files --show-diff-on-failure' + +["check:tasks"] +description = "Validate Mise task definitions and gate shell syntax" +run = ["mise tasks validate", "python3 scripts/check_task_scripts.py"] + +[check] +description = "Run the shared static gates; hooks may rewrite files" +run = [ + { task = "check:tools" }, { task = "check:lock" }, + { task = "check:type" }, { task = "check:lint" }, { task = "check:format" }, + { task = "check:license:headers" }, { task = "check:docs" }, + { task = "check:notebooks" }, { task = "check:tasks" }, { task = "check:hooks" }, +] + +["lint:files"] +description = "Fix Ruff lint for filenames supplied by hooks" +run = '"${UV:-uv}" run ruff check --fix' + +["format:files"] +description = "Format filenames supplied by hooks" +run = '"${UV:-uv}" run ruff format' + +["check:dco"] +description = "Validate commit-message signoff trailers" +run = 'python3 scripts/check_commit_signoffs.py --commit-msg' + +["check:type"] +description = "Check shipped code, gate scripts and e2e helpers against Python 3.11" +depends = ["check:tools"] +run = '"${UV:-uv}" run --locked ty check --error-on-warning' + +["check:ci-status"] +description = "Require every mandatory CI job to complete successfully" +run = 'python3 scripts/check_ci_results.py' + +["check:dco:range"] +description = "Validate every commit between the PR base and head" +run = 'python3 scripts/check_commit_signoffs.py --base "$BASE_SHA" --head "$HEAD_SHA"' diff --git a/.mise/tasks/setup.toml b/.mise/tasks/setup.toml new file mode 100644 index 0000000..7ccca79 --- /dev/null +++ b/.mise/tasks/setup.toml @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +["setup:tools"] +description = "Install the exact registry tools from mise.lock" +run = "mise install --locked uv" + +["setup:ci"] +description = "Sync locked dependencies for the requested UV_PYTHON, without hooks" +depends = ["check:tools"] +run = '"${UV:-uv}" sync --locked' + +[setup] +description = "Sync development dependencies and install static and commit-message hooks" +depends = ["check:tools"] +run = [ + '"${UV:-uv}" sync --locked --reinstall-package usersim', + '"${UV:-uv}" run pre-commit install --hook-type pre-commit --hook-type commit-msg', +] + +["setup:kernel"] +description = "Register the project Jupyter kernel for this user" +run = '"${UV:-uv}" run python -m ipykernel install --user --name usersim --display-name "User Sim (.venv)"' diff --git a/.mise/tasks/tests.toml b/.mise/tasks/tests.toml new file mode 100644 index 0000000..735e092 --- /dev/null +++ b/.mise/tasks/tests.toml @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[test] +description = "Run the ordinary offline test suite with PYTEST_ARGS and worker overrides" +run = 'python3 scripts/run_test_suite.py' + +["test:fast"] +description = "Run the ordinary suite serially and stop at the first failure" +run = '"${UV:-uv}" run pytest -x -q' + +["test:ci"] +description = "Run the ordinary suite with coverage XML and enforce the 84 percent floor" +run = 'python3 scripts/run_test_suite.py --coverage' + +["test:smoke"] +description = "Run the offline installed-resource self-check" +run = '"${UV:-uv}" run usersim smoke' + +["test:e2e"] +description = "Run the installed CLI suite against the local fake provider" +depends = ["check:tools"] +run = '"${UV:-uv}" run --frozen python scripts/run_e2e.py' + +["test:health"] +description = "Run the live credentialed provider pipeline; missing keys explicitly skip" +run = '"${UV:-uv}" run python scripts/check_health.py --models "${PROVIDER_CONFIG:-src/usersim/cli/models_default.toml}" --locale "${HEALTH_LOCALE:-ja_JP}" --probe "${HEALTH_PROBE:-sov_ai_dynamic}" --timeout "${HEALTH_TIMEOUT:-900}"' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index deacff2..d5311e7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ # # Top-level underscore directories contain private, non-distribution material. # uv owns lock formatting; notebooks skip rewriting text hooks individually. -exclude: '^_[^/]+/|^uv\.lock$' +exclude: '^_[^/]+/|^(uv\.lock|mise\.lock)$' default_install_hook_types: [pre-commit, commit-msg] default_stages: [pre-commit] @@ -42,35 +42,35 @@ repos: hooks: - id: check-lock name: dependency lock consistency - entry: make check-lock + entry: scripts/mise run check:lock language: system pass_filenames: false always_run: true - id: typecheck name: type checking (Python 3.11) - entry: make typecheck + entry: scripts/mise run check:type language: system pass_filenames: false always_run: true - id: spdx-headers name: SPDX headers (staged files) - entry: uv run python scripts/update_license_headers.py + entry: scripts/mise run license:headers:fix language: system - files: '^(src/|tests/|tests_e2e/|scripts/|templates/|Makefile$|conftest\.py$)' + files: '^(\.mise/|\.mise\.toml$|src/|tests/|tests_e2e/|scripts/|templates/|Makefile$|conftest\.py$)' - id: dco-trailers name: DCO author and co-author sign-offs - entry: python3 scripts/check_commit_signoffs.py --commit-msg + entry: scripts/mise run check:dco language: system stages: [commit-msg] always_run: true - id: ruff name: ruff (lint) - entry: uv run ruff check --fix + entry: scripts/mise run lint:files language: system types: [python] - id: ruff-format name: ruff format - entry: uv run ruff format + entry: scripts/mise run format:files language: system types: [python] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e0c8fb1..a4b4bba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,7 @@ is not required by this repository. Install both local hooks after setting up the development environment: ```bash -uv run pre-commit install --hook-type pre-commit --hook-type commit-msg +scripts/mise exec -- uv run pre-commit install --hook-type pre-commit --hook-type commit-msg ``` The commit-message hook checks the author identity provided by Git, including @@ -108,30 +108,31 @@ passes the check automatically. ## Getting set up Supports Python 3.11 to 3.14. The default development interpreter is Python 3.12 -from `.python-version`; CI tests every supported version. Start with an existing -[uv](https://docs.astral.sh/uv/) installation, then install the exact -`required-version` from `pyproject.toml`: +from `.python-version`; CI tests every supported version. Start with Bash, +Python 3, curl, and GPG. Bootstrap installs the verified Mise release and the +exact uv version declared in `.mise.toml`: ```bash -make bootstrap # installs the declared uv; ensure its bin directory is on PATH -make install-dev # locked dev dependencies and pre-commit hook -uv run usersim smoke # offline self-check; no API key, no network +make bootstrap # verified Mise and locked uv registry tool +scripts/mise run setup # locked dev dependencies and both Git hooks +scripts/mise run test:smoke # offline self-check; no API key or network ``` -`make check-tools` rejects another uv version. `make check-lock` checks that -`uv.lock` agrees with dependency metadata without rewriting it. Both setup -targets use `--locked`, so stale metadata fails rather than updating the lock. -After a deliberate dependency change, run `uv lock`, review `uv.lock`, and -commit it with the metadata change. CI reads the same uv version declaration; -update it deliberately and validate the lock before committing a tool upgrade. - -`make install-ci` performs locked setup without installing hooks. In a Git -worktree, `make install-dev` finds the hook directory through Git rather than -assuming `.git` is a directory. - -`smoke` is the fastest way to confirm an install is sound. It resolves the -model catalog, every probe's assets, and the plugin entry points without -making a single model call. +[The Mise workflow](docs/engineering/mise.md) owns tools and task definitions. +The exact uv version lives in `.mise.toml`; `mise.lock` records platform +artifacts. Ruff and ty remain in the locked Python development environment. +`pyproject.toml` declares only the compatible uv family. `scripts/mise run +check:tools` checks the actual executable against the registry, and +`scripts/mise run check:lock` checks dependency metadata without rewriting it. +After a deliberate dependency change, run `scripts/mise exec -- uv lock`, review +`uv.lock`, and commit it with the metadata change. + +`scripts/mise run setup:ci` performs locked setup without installing hooks. +`UV_PYTHON` selects a CI interpreter; `UV_PROJECT_ENVIRONMENT` selects another +Python environment. The wrapper uses writable repository-local Mise state and +does not change shell profiles or global configuration. In a worktree, setup +locates the hook directory through Git. Existing Make targets remain equivalent +compatibility commands, including variable overrides. ### Reproducing CI and coverage @@ -265,10 +266,12 @@ tracked files. Hooks may rewrite files and fail: review those changes and rerun CI runs the same checks even when local hooks are bypassed. The SPDX hook fixes only the staged paths passed to it. `make update-license-headers` -adds or refreshes headers throughout `src/`, `tests/`, `scripts/`, `templates/`, -`Makefile`, and root `conftest.py`; `make check-license-headers` checks those -roots without writing. Python, shell, and `.template` files are covered; Markdown, -YAML, and TOML do not require SPDX headers under this policy. +adds or refreshes headers throughout `src/`, `tests/`, `tests_e2e/`, `scripts/`, +`templates/`, and `.mise/tasks/`, plus `Makefile`, root `conftest.py`, and +`.mise.toml`. `make check-license-headers` checks those paths without writing. +Python, shell, and `.template` files are covered, including extensionless Mise +Bash tasks and the launcher. Mise TOML definitions carry headers; other TOML, +Markdown, and YAML files remain outside this header policy. Keep credentials and generated output local; keep required source and fixtures trackable. Git ignores do not control archive or Docker inclusion. @@ -304,8 +307,9 @@ failed mandatory jobs fail that status. Run the same Make targets locally; see the maintainer checklist for changing required checks. DCO and PR-title checks remain separate required policies. -Every job in `.github/workflows/ci.yml` calls a `make` target, so anything CI -rejects reproduces locally with the same command. Tests and the dependency +CI jobs invoke shared Mise tasks through `scripts/mise`, so their checks +reproduce locally with the same commands. Make targets remain compatibility +wrappers. Tests and the dependency licence check run on Python 3.11 to 3.14. Pull-request checks run for every base branch so stacked PRs receive CI; push checks remain scoped to `main`. diff --git a/Makefile b/Makefile index 9c68f00..95fb40d 100644 --- a/Makefile +++ b/Makefile @@ -1,318 +1,167 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# usersim developer and CI entry points. -# -# CI is a thin wrapper over these targets: every check -# runs identically on a laptop and on a runner. Keep the logic HERE rather than -# in pipeline YAML -- that is what keeps a move to a different CI provider a -# translation instead of a rewrite, and what stops "works locally" drift. - +# Compatibility only. Mise owns implementations, dependencies and defaults. SHELL := /bin/bash +MISE ?= scripts/mise +.DEFAULT_GOAL := help -UV ?= uv -UV_VERSION := $(shell sed -n 's/^required-version = "==\([^"]*\)"/\1/p' pyproject.toml) +# GNU Make forwards environment and command-line variable overrides to tasks. +# Defaults live only in the canonical Mise task implementation. -# A top-level `_`-prefixed directory holds material that is not part of the -# distribution and may carry its own targets. Absent one, this expands to -# nothing. -include $(wildcard _*/targets.mk) -# Overridable so CI can scope a job without editing recipes. -# -# scripts/ and templates/ are in scope deliberately. The gate scripts run in -# CI, and templates/probe/ is example code a contributor copies, so both -# should meet the same bar as src/. Leaving them out meant a later scope -# change would trigger a second repo-wide reformat. -PY_SRC ?= src scripts templates -PY_TESTS ?= tests conftest.py -PY_E2E ?= tests_e2e -PY_ALL := $(PY_SRC) $(PY_TESTS) $(PY_E2E) - -# Worker count for the parallel suite. `auto` follows the local core count, -# which suits a CPU-bound suite with every LLM call mocked (~2x on a laptop). -# -# Override it in containers. `auto` resolves to the HOST's core count inside -# Docker, not the cgroup CPU limit, so on a shared many-core runner it spawns -# far more workers than there are usable CPUs and the run wedges during worker -# startup, so CI pins an explicit worker count. -# -# `test-fast` stays serial deliberately: interleaved worker output makes a -# single failure much harder to read, and that target exists for debugging. -PYTEST_WORKERS ?= auto - -# Importable package names for coverage (not paths -- src/ -# installs as `usersim.engine`). -COV_XML ?= coverage.xml -COV_PKGS ?= usersim -COV_ARGS := $(foreach pkg,$(COV_PKGS),--cov=$(pkg)) - -# The coverage floor lives here, not in CI, so `make coverage` is the same gate -# everywhere. Measured at 85% (2,644 of 17,686 statements uncovered); 84 leaves -# a point of headroom for platform differences between a laptop and the Linux -# runner while still catching roughly 180 newly uncovered statements. Ratchet it -# upward as coverage improves; do not lower it to make a branch pass. -# -# Read it as a regression tripwire, not a quality score: reporting/ emits a -# React app from Python string literals, and those lines execute during tests -# while only being asserted on by substring match. -COV_FAIL_UNDER ?= 84 +.PHONY: help bootstrap +help: + @$(MISE) tasks -.DEFAULT_GOAL := help +bootstrap: + @bash scripts/install_mise.sh + @$(MISE) run setup:tools + +.PHONY: check-tools +check-tools: + @$(MISE) run check:tools + +.PHONY: check-lock +check-lock: + @$(MISE) run check:lock + +.PHONY: install-dev +install-dev: + @$(MISE) run setup + +.PHONY: install-ci +install-ci: + @$(MISE) run setup:ci + +.PHONY: install-kernel +install-kernel: + @$(MISE) run setup:kernel + +.PHONY: lint +lint: + @$(MISE) run check:lint + +.PHONY: lint-fix +lint-fix: + @$(MISE) run lint:fix + +.PHONY: format-check +format-check: + @$(MISE) run check:format + +.PHONY: format +format: + @$(MISE) run format + +.PHONY: check-all +check-all: + @$(MISE) run check + +.PHONY: check-hooks +check-hooks: + @$(MISE) run check:hooks + +.PHONY: check-all-fix +check-all-fix: + @$(MISE) run format:all + +.PHONY: test +test: + @$(MISE) run test + +.PHONY: test-fast +test-fast: + @$(MISE) run test:fast + +.PHONY: coverage +coverage: + @$(MISE) run test:ci + +.PHONY: test-e2e +test-e2e: + @$(MISE) run test:e2e + +.PHONY: validate-native +validate-native: + @$(MISE) run validate:native + +.PHONY: validate-ci +validate-ci: + @$(MISE) run validate:ci + +.PHONY: smoke +smoke: + @$(MISE) run test:smoke + +.PHONY: dist +dist: + @$(MISE) run build:dist + +.PHONY: check-dist +check-dist: + @$(MISE) run check:dist + +.PHONY: check-release-tag +check-release-tag: + @$(MISE) run check:release-tag + +.PHONY: check-dependency-licenses +check-dependency-licenses: + @$(MISE) run check:license:dependencies + +.PHONY: check-license-headers +check-license-headers: + @$(MISE) run check:license:headers + +.PHONY: update-license-headers +update-license-headers: + @$(MISE) run license:headers:fix + +.PHONY: clean-notebooks +clean-notebooks: + @$(MISE) run notebooks:clean + +.PHONY: check-notebooks-clean +check-notebooks-clean: + @$(MISE) run check:notebooks + +.PHONY: check-doc-links +check-doc-links: + @$(MISE) run check:docs + +.PHONY: health-check +health-check: + @$(MISE) run test:health -.PHONY: typecheck help bootstrap check-tools check-lock install-dev install-ci install-kernel health-check check-notebooks-clean lint lint-fix format format-check check-all \ - check-ci-status check-hooks check-all-fix validate-native validate-ci test test-fast test-e2e coverage smoke dist check-dist check-release-tag check-extensions check-doc-links clean-notebooks check-license-headers update-license-headers check-dependency-licenses clean +.PHONY: check-extensions +check-extensions: + @$(MISE) run check:extensions +.PHONY: clean +clean: + @$(MISE) run clean -help: ## Show the available targets - @echo "usersim make targets:" - @grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \ - | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-26s\033[0m %s\n", $$1, $$2}' +.PHONY: container-audit +container-audit: + @$(MISE) run container:audit -# --- setup ----------------------------------------------------------------- +.PHONY: container-build-test +container-build-test: + @$(MISE) run container:build:test -# Requires an existing uv installation; installs the declared release on PATH. -bootstrap: ## Install the repository's required uv version as a standalone tool - $(UV) tool install --force "uv==$(UV_VERSION)" +.PHONY: test-container +test-container: + @$(MISE) run test:ci-container -check-tools: ## Reject an unsupported uv version - @actual=$$($(UV) --version 2>/dev/null | awk '{print $$2}'); \ - if [ -z "$(UV_VERSION)" ] || [ "$$actual" != "$(UV_VERSION)" ]; then \ - echo "Expected uv $(UV_VERSION), found $${actual:-none}. Install uv, then run make bootstrap and ensure its bin directory is on PATH." >&2; \ - exit 1; \ - fi +.PHONY: typecheck check-ci-status +typecheck: + @$(MISE) run check:type -check-lock: check-tools ## Check dependency metadata against uv.lock without rewriting it - $(UV) lock --check - -# Reinstalling the project restores any file that removing another distribution -# took with it, such as the `usersim` command an earlier name also installed. -install-dev: check-tools ## Sync development tools and install static and commit-message hooks - $(UV) sync --locked --reinstall-package usersim - $(UV) run pre-commit install --hook-type pre-commit --hook-type commit-msg - -# --locked installs strictly from uv.lock and refuses to re-resolve, so -# dependency drift becomes a build failure rather than a silent -# re-resolution. No pre-commit hooks -- CI has no working tree to guard. -install-ci: check-tools ## Sync exactly what CI needs, enforcing the lockfile - $(UV) sync --locked - -# --- lint / format --------------------------------------------------------- - -typecheck: check-tools ## Check shipped code, gate scripts and e2e helpers against Python 3.11 - $(UV) run --locked ty check --error-on-warning - -lint: ## Check lint rules (no changes written) - $(UV) run ruff check --output-format=full $(PY_ALL) - @echo "Lint clean. Run 'make lint-fix' to auto-fix issues." - -lint-fix: ## Fix what ruff can fix automatically - $(UV) run ruff check --fix $(PY_ALL) - -format-check: ## Check formatting (no changes written) - $(UV) run ruff format --check $(PY_ALL) - @echo "Formatting clean. Run 'make format' to auto-fix issues." - -format: ## Reformat in place - $(UV) run ruff format $(PY_ALL) - -check-all: check-tools check-lock typecheck lint format-check check-license-headers check-doc-links check-notebooks-clean check-hooks ## Run ordinary static gates (hooks may rewrite files) - -check-hooks: ## Run hooks on every tracked file; review rewrites and rerun after failure - $(UV) run pre-commit run --all-files --show-diff-on-failure - -check-all-fix: lint-fix format ## Fix everything fixable, then you re-review - -# --- test ------------------------------------------------------------------ - -# PYTEST_ARGS is a passthrough, so a one-off run does not need a new target: -# make test PYTEST_ARGS="--durations=25 -k financial" -E2E_ARGS ?= -WHEEL_DIR ?= - -PYTEST_ARGS ?= - -test: ## Run the full test suite (parallel) - $(UV) run pytest -n $(PYTEST_WORKERS) $(PYTEST_ARGS) - -test-fast: ## Stop at the first failure, serially (tight debugging loop) - $(UV) run pytest -x -q - -coverage: ## Run tests with coverage and enforce the floor - $(UV) run pytest -n $(PYTEST_WORKERS) $(COV_ARGS) \ - --cov-report=term-missing \ - --cov-report="xml:$(COV_XML)" \ - --cov-fail-under=$(COV_FAIL_UNDER) $(PYTEST_ARGS) - -# Keep the installed suite separate: ordinary pytest's root conftest blocks -# loopback, and the e2e runner must never import the editable source tree. -test-e2e: check-tools ## Test an installed wheel through CLI subprocesses and a local HTTP provider - $(UV) run --frozen python scripts/run_e2e.py --uv "$(UV)" $(if $(WHEEL_DIR),--wheel-dir "$(WHEEL_DIR)") $(E2E_ARGS) - -validate-native: check-all check-dependency-licenses coverage smoke check-extensions ## Check source and installed extension behavior - -validate-ci: validate-native check-dist ## Validate source, then test the same clean distribution artifacts - $(MAKE) test-e2e WHEEL_DIR=dist - -smoke: ## Offline self-check of config, registries, and report builders - $(UV) run usersim smoke - -dist: ## Build the sdist, then the wheel from it, into a fresh dist/ - rm -rf dist - $(UV) build -q -o dist - @ls -1 dist - -# The editable install resolves imports from the source tree and finds assets -# next to them, so `make test` and `make smoke` stay green even when the built -# artifact is unusable. This target checks what a user actually installs: it -# builds both archives, checks them (scripts/check_dist.py), installs the wheel -# into a throwaway environment with no access to this checkout, and runs it -# from an empty directory. -# -# Run it before any release, and after any change to packaging metadata, asset -# locations, or resource loading. -check-dist: dist ## Build dist/, check both archives, clean-install the wheel and smoke it from an empty dir - @set -eu; \ - $(UV) run python scripts/check_dist.py dist; \ - WORK=$$(mktemp -d); \ - EMPTY=$$(mktemp -d); \ - trap 'rm -rf "$$WORK" "$$EMPTY"' EXIT; \ - echo "→ installing the wheel into a throwaway venv"; \ - $(UV) venv "$$WORK/venv" >/dev/null; \ - test -x "$$WORK/venv/bin/python" || { echo "venv was not created"; exit 1; }; \ - $(UV) pip install --quiet --python "$$WORK/venv/bin/python" dist/*.whl; \ - test -x "$$WORK/venv/bin/usersim" \ - || { echo " ✗ the wheel installed but shipped no usersim entry point"; exit 1; }; \ - echo "→ running from $$EMPTY (no repo in sight)"; \ - cd "$$EMPTY" && "$$WORK/venv/bin/usersim" smoke; \ - cd "$$EMPTY" && "$$WORK/venv/bin/usersim" simulate --dry-run \ - --locale en_US --num-rows 1 --out ./out >/dev/null; \ - echo "✓ installed package runs from a clean environment" +check-ci-status: + @$(MISE) run check:ci-status .PHONY: release-digests -release-digests: ## Print validated archive hashes; append to the CI summary when available - @python3 scripts/release_digests.py dist - -# A tag ruleset limits who can push a `v*` tag, not which commit it names or -# which version it claims, so the release workflow runs this on the tagged -# commit before publishing. Run it from the commit you are about to tag. -# A tag name may contain `$(shell ...)`, backticks or quotes. The recipe reads -# it as $$TAG, which no shell parses, and CI passes TAG in the environment, -# because make expands a value given on its command line. -check-release-tag: ## Fail unless TAG is v + the release version in dist/ and HEAD is on origin/main (TAG=v) - @test -n "$$TAG" || { echo "usage: make check-release-tag TAG=v"; exit 1; } - @$(UV) run python scripts/check_release_tag.py "$$TAG" dist - -check-dependency-licenses: ## Check runtime dependency licenses against the policy - @$(UV) run --isolated --no-dev --group license-check --locked \ - python scripts/check_dependency_licenses.py - -check-license-headers: ## Verify every source file carries the SPDX header - @$(UV) run python scripts/update_license_headers.py --check - -update-license-headers: ## Add or refresh the SPDX header on every source file - @$(UV) run python scripts/update_license_headers.py - -clean-notebooks: ## Strip outputs from every notebook (source only) - @$(UV) run --with nbstripout nbstripout $$(git ls-files '*.ipynb' | grep -v '^_') - @echo "✓ notebook outputs stripped" - -# The pre-commit hook that strips outputs only exists if someone ran -# `make install-dev`; a fresh clone has none. This is what actually keeps -# outputs, and the absolute paths and endpoint URLs inside them, out of the -# repository. -check-notebooks-clean: ## Fail if any committed notebook carries outputs - @$(UV) run python -c "$$CHECK_NOTEBOOKS_PY" - -define CHECK_NOTEBOOKS_PY -import json, subprocess, sys -files = subprocess.run( - ["git", "ls-files", "*.ipynb"], capture_output=True, text=True, check=True -).stdout.split() -dirty = [] -for path in files: - nb = json.load(open(path)) - n = sum(len(c.get("outputs", [])) for c in nb["cells"]) - n += sum(1 for c in nb["cells"] if c.get("execution_count")) - if n: - dirty.append(f"{path} ({n} output/execution entries)") -if dirty: - print("Committed notebooks carry outputs:", *dirty, sep="\n ") - print("\nRun `make clean-notebooks` and commit the result.") - sys.exit(1) -print(f"OK {len(files)} notebook(s) carry no outputs") -endef -export CHECK_NOTEBOOKS_PY - -# `uv run jupyter lab` already sees the kernel inside .venv. This registers a -# NAMED one so editors that pick kernels system-wide can find this project's -# environment rather than offering a bare "python3" per checkout. -install-kernel: ## Register a Jupyter kernel named for this project's venv - @$(UV) run python -m ipykernel install --user \ - --name usersim --display-name "User Sim (.venv)" - -check-doc-links: ## Fail on broken relative links in tracked markdown - @$(UV) run python scripts/check_doc_links.py - -# --- live checks (need credentials; never run in PR CI) -------------------- - -# The offline suite proves the code is self-consistent. This proves a real -# provider accepts what we send it, which is a different question: the -# parameters, the provider routing and the engine's token budget are only -# exercised by an actual run. One row, two turns, so the cost is negligible. -PROVIDER_CONFIG ?= src/usersim/cli/models_default.toml -HEALTH_LOCALE ?= ja_JP -HEALTH_PROBE ?= sov_ai_dynamic - -# Only missing credentials skip; configuration and execution errors fail. -HEALTH_TIMEOUT ?= 900 -HEALTH_SUMMARY_FILE ?= - -health-check: ## Live one-row simulate + eval + report against a provider - @$(UV) run python scripts/check_health.py \ - --models "$(PROVIDER_CONFIG)" --locale "$(HEALTH_LOCALE)" \ - --probe "$(HEALTH_PROBE)" --timeout "$(HEALTH_TIMEOUT)" \ - $(if $(HEALTH_SUMMARY_FILE),--summary-file "$(HEALTH_SUMMARY_FILE)") - -check-extensions: ## Install an out-of-tree package and prove all six seams work - @set -eu; \ - WORK=$$(mktemp -d); \ - EMPTY=$$(mktemp -d); \ - trap 'rm -rf "$$WORK" "$$EMPTY"' EXIT; \ - echo "→ building wheels into $$WORK"; \ - $(UV) build --wheel -o "$$WORK" >/dev/null; \ - echo "→ installing them plus the fixture extension into a throwaway venv"; \ - $(UV) venv "$$WORK/venv" >/dev/null; \ - test -x "$$WORK/venv/bin/python" || { echo "venv was not created"; exit 1; }; \ - $(UV) pip install --quiet --python "$$WORK/venv/bin/python" "$$WORK"/*.whl; \ - cp -R tests/fixtures/extension_package "$$WORK/ext"; \ - $(UV) pip install --quiet --python "$$WORK/venv/bin/python" "$$WORK/ext"; \ - echo "→ asking the installed CLI what it can see, from $$EMPTY"; \ - cp tests/fixtures/extension_package/verify_seams.py "$$EMPTY/"; \ - cd "$$EMPTY" && "$$WORK/venv/bin/python" verify_seams.py - -# --- housekeeping ---------------------------------------------------------- - -clean: ## Remove caches, coverage output, compiled artifacts, build output and package metadata - find . -type d -name __pycache__ -not -path './.venv*' -exec rm -rf {} + 2>/dev/null || true - find . -type f -name '*.py[co]' -not -path './.venv*' -delete 2>/dev/null || true - rm -rf .pytest_cache .ruff_cache .coverage coverage.xml htmlcov build dist src/*.egg-info - @echo "Cleaned." - -# Container validation uses only host Python and Docker; dependencies live in the image. -CONTAINER_ARGS ?= -.PHONY: container-audit container-build-test test-container -container-build-test: ## Build the CPU test image with locked dependencies - python3 scripts/run_container_tests.py build $(CONTAINER_ARGS) - -test-container: container-build-test ## Build fresh and run offline CPU coverage and e2e - python3 scripts/run_container_tests.py run $(CONTAINER_ARGS) - -container-audit: ## Audit the local Docker context before image build - python3 scripts/run_container_tests.py audit $(CONTAINER_ARGS) - - -# The aggregate uses only the runner's standard library; setup failures in other -# jobs must not prevent their results from being checked. -check-ci-status: ## Reject missing or unsuccessful jobs in CI_JOB_RESULTS JSON - python3 scripts/check_ci_results.py +release-digests: + @$(MISE) run release:digests diff --git a/README.md b/README.md index e9af552..204a0f8 100644 --- a/README.md +++ b/README.md @@ -122,9 +122,9 @@ model catalogue, every probe's assets, and the plugin entry points without making a single model call. `--dry-run` prints the plan for a run, also without calling a model. -To work on User Sim itself, or to run the notebooks, clone the repository and -run `uv sync`; [CONTRIBUTING.md](https://github.com/NVIDIA-NeMo/UserSim/blob/main/CONTRIBUTING.md) -has the details. +To develop User Sim or run its notebooks, clone the repository and follow +the [contributor setup](https://github.com/NVIDIA-NeMo/UserSim/blob/main/CONTRIBUTING.md#getting-set-up). It bootstraps the pinned +tools through Mise and installs the development environment. ## Quickstart diff --git a/containers/Dockerfile.test b/containers/Dockerfile.test index 44cf06d..e59f409 100644 --- a/containers/Dockerfile.test +++ b/containers/Dockerfile.test @@ -3,16 +3,17 @@ # Multi-platform manifest verified with docker buildx imagetools inspect. FROM python:3.12-slim-bookworm@sha256:34386ef0cb081344d7ec1c103ba398e6e9f64e9ab3a1509accc92a4e24a07258 AS tests -RUN apt-get update && apt-get install -y --no-install-recommends git make \ +RUN apt-get update && apt-get install -y --no-install-recommends git make curl ca-certificates gnupg \ && python -c 'import shutil; shutil.rmtree("/var/lib/apt/lists")' WORKDIR /workspace COPY . . -# pyproject.toml owns the uv version; no second pin belongs here. -RUN python -c 'import tomllib, subprocess; p=tomllib.load(open("pyproject.toml", "rb")); v=p["tool"]["uv"]["required-version"]; assert v.startswith("=="), "container requires an exact uv declaration"; subprocess.run(["python", "-m", "pip", "install", "--no-cache-dir", "uv"+v], check=True)' +# The same verified bootstrap and platform lock own tools in every environment. +RUN bash scripts/install_mise.sh && scripts/mise run setup:tools +ENV PATH=/workspace/.cache/tools:$PATH ENV UV_PROJECT_ENVIRONMENT=/opt/unit UV_PYTHON_DOWNLOADS=never \ DO_NOT_TRACK=1 HF_HUB_DISABLE_TELEMETRY=1 LITELLM_TELEMETRY=False -RUN uv sync --locked && uv build --wheel --out-dir /opt/wheels \ - && python scripts/run_e2e.py --work-dir /opt/e2e --wheel /opt/wheels/*.whl --prepare-only \ +RUN scripts/mise run setup:ci && scripts/mise exec -- uv build --wheel --out-dir /opt/wheels \ + && scripts/mise exec -- python scripts/run_e2e.py --work-dir /opt/e2e --wheel /opt/wheels/*.whl --prepare-only \ && chmod -R a+rwX /workspace /opt/e2e ENV UV_OFFLINE=1 UV_NO_SYNC=1 UV_CACHE_DIR=/tmp/uv-cache HOME=/tmp/home CMD ["python", "scripts/run_container_tests.py", "inside"] diff --git a/docs/engineering/ci.md b/docs/engineering/ci.md index ccc23e4..dcd512b 100644 --- a/docs/engineering/ci.md +++ b/docs/engineering/ci.md @@ -1,6 +1,6 @@ # Local and CI validation -CI calls repository Make targets so failures can be reproduced locally. After +CI calls repository Mise tasks (Make targets remain compatibility wrappers) so failures can be reproduced locally. After `make install-dev`, run `make validate-ci` to execute the native validation bundle, including distribution checks and installed CLI tests. @@ -22,17 +22,17 @@ through 3.14; a local bundle checks the active Python environment. Provider health checks run separately and need credentials. The separate `Installed CLI e2e` job runs the [installed workflow](e2e.md) on -Python 3.12 through `make test-e2e`. It retains failure diagnostics under +Python 3.12 through `scripts/mise run test:e2e`. It retains failure diagnostics under `test-results/e2e/` as `e2e-failure` for 14 days. The local bundle passes -`WHEEL_DIR=dist` after archive validation so the e2e test installs that same +`--wheel-dir dist` after archive validation so the e2e test installs that same wheel without rebuilding. ## Type checking `make typecheck` uses the locked `ty==0.0.69` against `src/usersim`, `scripts`, and `tests_e2e`, targeting Python 3.11 with `src` as the module root. The -dedicated CI job runs this target after locked setup; hooks, static validation, -and release validation inherit it through `check-all`. +dedicated CI job runs `scripts/mise run check:type` after locked setup; hooks, +static validation, and release validation inherit it through the `check` task. Ordinary unit-test fixtures are outside this initial scope. Keep annotations aligned with runtime behavior. Rule-specific suppressions @@ -41,9 +41,9 @@ do not disable a rule across the repository. ## Coverage -The Python 3.12 CI matrix job runs `make coverage`; the other Python versions -run `make test`. Both test paths use the same ordinary suite. Coverage failures -fail the existing `Tests (Python 3.12)` check. The 84% floor lives in the Makefile +The Python 3.12 CI matrix job runs `scripts/mise run test:ci`; the other Python +versions run `scripts/mise run test`. Both test paths use the same ordinary suite. Coverage failures +fail the existing `Tests (Python 3.12)` check. The 84% floor lives in the shared pytest coordinator and applies to local runs as well as CI. ```bash @@ -72,10 +72,11 @@ Failures, cancellation, missing results, malformed results, and skipped jobs fai the aggregate; there are no intentional job skips. Conditional coverage steps inside the test matrix do not skip their enclosing job. -The aggregate runs with `always()` and invokes `make check-ci-status` with +The aggregate runs with `always()` and invokes `python3 scripts/check_ci_results.py` with `CI_JOB_RESULTS` set to GitHub's `toJSON(needs)` object. Its result checker uses -only Python's standard library so dependency installation failures in other jobs -can still be reported. When adding a mandatory job, update both the workflow's +only Python's standard library, so it can report dependency or tool-bootstrap +failures in other jobs. The DCO job likewise invokes its shared Python checker +directly. Both checkers remain available as local Mise tasks. When adding a mandatory job, update both the workflow's `needs` list and the explicit registry in `scripts/check_ci_results.py`; workflow tests reject an omitted dependency or unregistered job. @@ -140,3 +141,6 @@ validation introduced by the contribution-policy change must also remain required according to that change's checklist. These independent policies are not members of this workflow's aggregate. Retest a successful PR and a PR with a deliberately failing mandatory check after the authorized ruleset update. + +See [Mise tools and tasks](mise.md) for canonical commands, tool ownership, +bootstrap prerequisites, and the continuing Make compatibility interface. diff --git a/docs/engineering/container-tests.md b/docs/engineering/container-tests.md index 040cc44..1f4a0ea 100644 --- a/docs/engineering/container-tests.md +++ b/docs/engineering/container-tests.md @@ -1,12 +1,14 @@ # CPU container tests The test image prepares a source unit-test environment and a separate installed-wheel -e2e environment during build. Execution uses the same `make coverage` and -`make test-e2e` commands as native validation, with external networking disabled. +e2e environment during build. Execution uses the same `test:ci` and `test:e2e` +Mise tasks as native validation, with external networking disabled. Make targets +remain compatibility wrappers. Install Docker with a running daemon. Build dependencies require internet access; test execution needs no provider credentials. The image uses Python 3.12 pinned by -manifest digest and reads the exact uv version from `pyproject.toml`. +manifest digest and installs uv from `.mise.toml` and `mise.lock` through the +same verified Mise bootstrap used locally and in CI. ```bash make container-build-test @@ -40,6 +42,7 @@ Run this bounded check separately with: ```bash make container-audit +# Or: scripts/mise run container:audit ``` The audit, image build, execution, and cleanup require `/var/run/docker.sock` diff --git a/docs/engineering/contribution-policy.md b/docs/engineering/contribution-policy.md index 8fc2d46..723556b 100644 --- a/docs/engineering/contribution-policy.md +++ b/docs/engineering/contribution-policy.md @@ -17,7 +17,7 @@ Cryptographic signatures are independent of DCO trailers and are not required. was installed previously. To reinstall them directly: ```bash -uv run pre-commit install --hook-type pre-commit --hook-type commit-msg +scripts/mise exec -- uv run pre-commit install --hook-type pre-commit --hook-type commit-msg ``` Normal commits use `git commit -s`. The commit-message hook invokes diff --git a/docs/engineering/e2e.md b/docs/engineering/e2e.md index bc66820..f28ee2d 100644 --- a/docs/engineering/e2e.md +++ b/docs/engineering/e2e.md @@ -11,15 +11,15 @@ make test-e2e The equivalent direct command is: ```bash -uv run --frozen python scripts/run_e2e.py +scripts/mise exec -- uv run --frozen python scripts/run_e2e.py ``` Pass an existing wheel to test the original release artifact without rebuilding: ```bash -uv run --frozen python scripts/run_e2e.py --wheel dist/usersim-VERSION-py3-none-any.whl +scripts/mise exec -- uv run --frozen python scripts/run_e2e.py --wheel dist/usersim-VERSION-py3-none-any.whl # Or require exactly one wheel in the release directory: -uv run --frozen python scripts/run_e2e.py --wheel-dir dist +scripts/mise exec -- uv run --frozen python scripts/run_e2e.py --wheel-dir dist ``` `--wheel` and `--wheel-dir` are mutually exclusive and cannot be combined with `--run-only`. An empty or ambiguous wheel directory fails setup. `--uv /path/to/uv` selects the setup executable when it is not the one on `PATH`. @@ -33,8 +33,8 @@ The runtime receives a small environment allowlist and a synthetic local API key Prepare environments while network access is available, then execute them without installation or downloads: ```bash -uv run --frozen python scripts/run_e2e.py --work-dir /tmp/usersim-e2e --prepare-only -uv run --frozen python scripts/run_e2e.py --work-dir /tmp/usersim-e2e --run-only +scripts/mise exec -- uv run --frozen python scripts/run_e2e.py --work-dir /tmp/usersim-e2e --prepare-only +scripts/mise exec -- uv run --frozen python scripts/run_e2e.py --work-dir /tmp/usersim-e2e --run-only ``` `--work-dir` must resolve outside the checkout and cannot be an ancestor of it. Paths containing `..` use their canonical locations for this check; symlinked work and artifact paths are rejected. Reuse the same path for both phases; the prepared environment and copied suite are a snapshot of setup. Changes to tests or wheels require another preparation. A new preparation removes the previous `installed.json` receipt before changing the environment, so an interrupted setup cannot advertise the old wheel as a valid prepared runtime. The container prepares these environments at image build time and runs the second command with external networking disabled. diff --git a/docs/engineering/mise.md b/docs/engineering/mise.md new file mode 100644 index 0000000..4bf1fdc --- /dev/null +++ b/docs/engineering/mise.md @@ -0,0 +1,56 @@ +# Mise tools and tasks + +Mise owns the developer task graph and the exact uv release in `.mise.toml`. +`mise.lock` records the downloads for Linux x64/ARM64 and macOS x64/ARM64. +Ruff and ty belong to the locked Python development environment rather than a +second tool registry. + +```bash +make bootstrap +scripts/mise run setup +scripts/mise run check +``` + +Bootstrap requires Bash, Python 3, curl, and GPG. It reads the supported Mise release +from `.mise.toml`, verifies the versioned upstream installer against the pinned +release-key fingerprint, and installs under `.cache/tools/`. Verification +failure stops installation. It does not update shell profiles or global Mise +configuration. `scripts/mise` provides repository-scoped writable cache, data, +configuration, and state directories; existing `MISE_*_DIR` overrides are honored. +Use `scripts/mise tasks` to list task descriptions. + +Make targets remain compatibility commands. Their implementations and defaults +live in the Mise tasks, so `make coverage PYTEST_WORKERS=2` and +`PYTEST_WORKERS=2 scripts/mise run test:ci` select the same operation. Environment +and Make command-line overrides keep their existing names. `UV_PYTHON` selects +the interpreter explicitly in matrix CI; `UV_PROJECT_ENVIRONMENT` selects a +separate Python environment without forcing checkout activation into e2e. + +Quality commands execute Ruff through `uv run`, using the same locked version +as hooks. `scripts/mise run check:tasks` validates task definitions and Bash +syntax, including extensionless task scripts. SPDX checks include those scripts +and the bootstrap helper. Task execution does not silently install missing +tools: run bootstrap/setup first. Live health checks remain a separate operation +with their established CLI credential-loading rules. + +## Common commands + +```bash +# Focused ordinary tests; PYTEST_ARGS is parsed without shell evaluation. +PYTEST_WORKERS=0 PYTEST_ARGS="tests/test_mise_tasks.py" scripts/mise run test +# Native gates, coverage, archives and e2e against the original wheel. +PYTEST_WORKERS=2 scripts/mise run validate:ci +# Fresh CPU image, resource bounds and network-disabled test execution. +scripts/mise run test:ci-container +# Check a pending commit message, or the configured PR commit range. +scripts/mise run check:dco "$(git rev-parse --git-path COMMIT_EDITMSG)" +# Replace main with your PR's target ref for a stacked branch. +BASE_SHA="$(git rev-parse --verify 'main^{commit}')" \ +HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')" scripts/mise run check:dco:range +``` + +`PYTEST_ARGS` and the quality scope variables support quoted arguments and +explicit empty scope overrides. Hook tasks forward filenames as separate +arguments. Full validation pins the final e2e `--wheel-dir dist` selector so an +ambient `WHEEL_DIR` or `E2E_ARGS` cannot redirect it to another archive. Failed +archive validation prevents e2e execution. diff --git a/docs/engineering/releases.md b/docs/engineering/releases.md index 35e8e49..30e3ee3 100644 --- a/docs/engineering/releases.md +++ b/docs/engineering/releases.md @@ -63,7 +63,9 @@ an automated trusted publisher would require a separately configured PyPI publisher and workflow permissions. Use the repository's pinned uv version (`make check-tools`) and a current -GitHub CLI that supports `gh release verify-asset`. Download into a fresh +GitHub CLI that supports `gh release verify-asset`. Run these commands from +the repository checkout; `scripts/mise exec -- uv` selects the declared uv +without requiring a global installation. Download into a fresh directory so an old wheel cannot be included accidentally: ```bash @@ -93,14 +95,14 @@ upload selection with uv's dry run. The production command is intentionally a separate step requiring the publishing maintainer's decision: ```bash -uv publish --dry-run --trusted-publishing never "$RELEASE_ASSETS"/* -uv publish --trusted-publishing never "$RELEASE_ASSETS"/* +scripts/mise exec -- uv publish --dry-run --trusted-publishing never "$RELEASE_ASSETS"/* +scripts/mise exec -- uv publish --trusted-publishing never "$RELEASE_ASSETS"/* ``` For a rehearsal, use a separate TestPyPI account/token and upload endpoint: ```bash -uv publish --trusted-publishing never \ +scripts/mise exec -- uv publish --trusted-publishing never \ --publish-url https://test.pypi.org/legacy/ "$RELEASE_ASSETS"/* ``` diff --git a/docs/notebooks.md b/docs/notebooks.md index 1c86114..c8a4085 100644 --- a/docs/notebooks.md +++ b/docs/notebooks.md @@ -2,11 +2,12 @@ The `notebooks/` directory mirrors the CLI flow for interactive iteration. They need the Jupyter kernel, which the runtime dependencies deliberately do -not carry. It comes with the dev group, which `uv sync` installs by default: +not carry. The locked development setup installs it with the dev group: ```bash -uv sync -uv run jupyter lab # sees the kernel inside .venv +make bootstrap +scripts/mise run setup +scripts/mise exec -- uv run jupyter lab # sees the kernel inside .venv ``` If you open the notebooks in an editor that picks kernels system-wide rather @@ -14,7 +15,7 @@ than launching Jupyter yourself, register a named kernel once so this project's environment is selectable: ```bash -make install-kernel # appears as "User Sim (.venv)" +scripts/mise run setup:kernel # appears as "User Sim (.venv)" ``` Outputs are stripped from the committed notebooks, so you will see source @@ -53,7 +54,7 @@ interactive iteration: small row counts, locale-by-locale inspection, conversation previews. Prefer the CLI for unattended and reproducible runs: ```bash -usersim simulate --locale en_US --num-rows 20 --out output/trajectories +scripts/mise exec -- uv run usersim simulate --locale en_US --num-rows 20 --out output/trajectories ``` Every LLM-driven subcommand accepts `--dry-run`, which prints the resolved diff --git a/mise.lock b/mise.lock new file mode 100644 index 0000000..4c40b18 --- /dev/null +++ b/mise.lock @@ -0,0 +1,29 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html + +[[tools.uv]] +version = "0.13.1" +backend = "aqua:astral-sh/uv" + +[tools.uv."platforms.linux-arm64"] +checksum = "sha256:345a8308e4d1835cdfaadf692836fd3137a6d45864773f05ad7bf59442b1ba16" +url = "https://github.com/astral-sh/uv/releases/download/0.13.1/uv-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/639313013" +provenance = "github-attestations" + +[tools.uv."platforms.linux-x64"] +checksum = "sha256:6c86040a8f045ab9adc9e3ec4cdafa03fd00e9a988ed2bf28971c7fc2d0debd0" +url = "https://github.com/astral-sh/uv/releases/download/0.13.1/uv-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/639313248" +provenance = "github-attestations" + +[tools.uv."platforms.macos-arm64"] +checksum = "sha256:cfe454573412ff7380d15fc7dae5997ab53ffce72fce0a78e91ddbc84001acd2" +url = "https://github.com/astral-sh/uv/releases/download/0.13.1/uv-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/639312976" +provenance = "github-attestations" + +[tools.uv."platforms.macos-x64"] +checksum = "sha256:bb7b805bdcf3d0bc8c74ceb94569e6b3e8aa35bd492acdbb9d46bf46d9d4ff6b" +url = "https://github.com/astral-sh/uv/releases/download/0.13.1/uv-x86_64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/639313196" +provenance = "github-attestations" diff --git a/pyproject.toml b/pyproject.toml index 3a40058..6e64bcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,8 +119,8 @@ conversation-simulator = "usersim.engine.plugin:conversation_simulator" trajectory-evaluator = "usersim.engine.plugin:trajectory_evaluator" [tool.uv] -# Shared developer and CI version; setup-uv reads this declaration directly. -required-version = "==0.13.1" +# Mise owns the exact uv pin; this declares the compatible tool family. +required-version = ">=0.13,<0.14" override-dependencies = [ # Keep in sync with the ``dependencies`` pin above. "data-designer>=0.9.5,<0.10", diff --git a/scripts/check_health.py b/scripts/check_health.py index 1f210bd..f30e55a 100644 --- a/scripts/check_health.py +++ b/scripts/check_health.py @@ -7,6 +7,7 @@ import argparse import json +import os import subprocess import sys import tempfile @@ -106,7 +107,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--locale", default="ja_JP") parser.add_argument("--probe", default="sov_ai_dynamic") parser.add_argument("--timeout", type=float, default=900, help="maximum seconds per CLI command (default: 900)") - parser.add_argument("--summary-file", type=Path) + parser.add_argument("--summary-file", type=Path, default=os.environ.get("HEALTH_SUMMARY_FILE") or None) args = parser.parse_args(argv) outcome = {"status": "failure", "stage": "preflight", "detail": ""} try: diff --git a/scripts/check_notebooks.py b/scripts/check_notebooks.py new file mode 100644 index 0000000..56a67df --- /dev/null +++ b/scripts/check_notebooks.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Reject outputs and execution counts in tracked notebooks.""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + + +def main() -> int: + files = subprocess.check_output(["git", "ls-files", "-z", "*.ipynb"]).decode().split("\0") + dirty = [] + for path in filter(None, files): + notebook = json.loads(Path(path).read_text()) + count = sum(len(cell.get("outputs", [])) + bool(cell.get("execution_count")) for cell in notebook["cells"]) + if count: + dirty.append(f"{path} ({count} output/execution entries)") + if dirty: + print("Committed notebooks carry outputs:", *dirty, sep="\n ") + print("Run mise run notebooks:clean and commit the result.") + return 1 + print(f"OK {len([path for path in files if path])} notebook(s) carry no outputs") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_task_scripts.py b/scripts/check_task_scripts.py new file mode 100644 index 0000000..9a42200 --- /dev/null +++ b/scripts/check_task_scripts.py @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Validate Bash task syntax, including extensionless task files.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def main() -> int: + paths = [ROOT / "scripts/install_mise.sh", ROOT / "scripts/mise"] + paths += [path for path in (ROOT / ".mise/tasks").rglob("*") if path.is_file() and path.suffix != ".toml"] + for path in paths: + subprocess.run(["bash", "-n", str(path)], check=True) + print(f"Validated {len(paths)} Bash task/bootstrap scripts") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_tool_versions.py b/scripts/check_tool_versions.py new file mode 100644 index 0000000..909c6b6 --- /dev/null +++ b/scripts/check_tool_versions.py @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Verify the active uv executable against the Mise registry without dependencies.""" + +from __future__ import annotations + +import os +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def main() -> int: + match = re.search(r'^uv\s*=\s*"([^"]+)"', (ROOT / ".mise.toml").read_text(), re.MULTILINE) + expected = match.group(1) if match else "" + try: + result = subprocess.run( + [os.environ.get("UV", "uv"), "--version"], capture_output=True, text=True, check=True, timeout=15 + ) + actual = result.stdout.split()[1] + except (OSError, subprocess.SubprocessError, IndexError): + actual = "missing or broken" + if expected and actual == expected: + return 0 + print(f"Expected uv {expected}, found {actual}. Run make bootstrap, then scripts/mise run setup.") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/install_mise.sh b/scripts/install_mise.sh new file mode 100755 index 0000000..68694f5 --- /dev/null +++ b/scripts/install_mise.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Verify the versioned upstream installer before installing the registry's Mise release. +set -euo pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +version="$(sed -n 's/^min_version = "\([^"]*\)"/\1/p' "$root/.mise.toml")" +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo 'Invalid Mise min_version' >&2; exit 1; } +install_path="${MISE_INSTALL_PATH:-$root/.cache/tools/mise}" +if [[ -x "$install_path" ]] && [[ "$("$install_path" --version | cut -d' ' -f1)" == "$version" ]]; then + exit 0 +fi +command -v gpg >/dev/null || { echo 'GPG is required to verify Mise; unsigned installation is forbidden.' >&2; exit 1; } +command -v python3 >/dev/null || { echo 'Python 3 is required to bound Mise installation.' >&2; exit 1; } +command -v curl >/dev/null || { echo 'curl is required to download the signed Mise installer.' >&2; exit 1; } +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +chmod 700 "$work" +fetch() { + curl -fsSL --connect-timeout 10 --max-time 60 --retry 3 --retry-delay 2 --retry-all-errors "$@" +} +key=24853EC9F655CE80B48E6C3A8B81C9D17413A06D +fetch -o "$work/key.asc" "https://keys.openpgp.org/vks/v1/by-fingerprint/$key" +gpg --homedir "$work" --batch --no-tty --import "$work/key.asc" +gpg --homedir "$work" --batch --no-tty --with-colons --list-keys "$key" | grep -q "^fpr:::::::::$key:" +fetch -o "$work/install.sig" "https://github.com/jdx/mise/releases/download/v$version/install.sh.sig" +gpg --homedir "$work" --batch --no-tty --status-fd 3 --output "$work/install.sh" --decrypt "$work/install.sig" 3>"$work/status" +awk -v key="$key" '$2 == "VALIDSIG" && ($3 == key || $12 == key) { valid = 1 } END { exit !valid }' "$work/status" \ + || { echo 'Mise installer signer does not match the pinned release key.' >&2; exit 1; } +mkdir -p "$(dirname "$install_path")" +MISE_VERSION="v$version" MISE_INSTALL_PATH="$install_path" MISE_INSTALL_HELP=0 python3 - "$work/install.sh" <<'PYTHON' +import os +import signal +import subprocess +import sys + +process = subprocess.Popen(["sh", sys.argv[1]], start_new_session=True) +try: + code = process.wait(timeout=300) +except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + raise SystemExit("Mise binary installation exceeded 300 seconds") +raise SystemExit(code) +PYTHON +[[ "$("$install_path" --version | cut -d' ' -f1)" == "$version" ]] || { echo 'Installed Mise version mismatch' >&2; exit 1; } +echo "Verified Mise $version installed at $install_path" diff --git a/scripts/mise b/scripts/mise new file mode 100755 index 0000000..f5f3f1a --- /dev/null +++ b/scripts/mise @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Keep compatibility calls usable without changing a shell profile or globals. +set -euo pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +export MISE_DATA_DIR="${MISE_DATA_DIR:-$root/.cache/mise/data}" +export MISE_CACHE_DIR="${MISE_CACHE_DIR:-$root/.cache/mise/cache}" +export MISE_CONFIG_DIR="${MISE_CONFIG_DIR:-$root/.cache/mise/config}" +export MISE_STATE_DIR="${MISE_STATE_DIR:-$root/.cache/mise/state}" +export MISE_TRUSTED_CONFIG_PATHS="${MISE_TRUSTED_CONFIG_PATHS:-$root}" +if [[ -n "${MISE_BIN:-}" ]]; then + binary="$MISE_BIN" +elif [[ -n "${MISE_INSTALL_PATH:-}" ]]; then + binary="$MISE_INSTALL_PATH" +elif [[ -x "$root/.cache/tools/mise" ]]; then + binary="$root/.cache/tools/mise" +elif command -v mise >/dev/null 2>&1; then + binary="$(command -v mise)" +else + echo 'Mise is missing. Run make bootstrap.' >&2 + exit 1 +fi +[[ -x "$binary" ]] || { echo "Mise is not executable at $binary; run make bootstrap." >&2; exit 1; } +binary="$(cd "$(dirname "$binary")" && pwd -P)/$(basename "$binary")" +export PATH="$(dirname "$binary"):$PATH" +exec "$binary" "$@" diff --git a/scripts/run_container_tests.py b/scripts/run_container_tests.py index 649fa1c..6b9d8d2 100644 --- a/scripts/run_container_tests.py +++ b/scripts/run_container_tests.py @@ -32,7 +32,7 @@ def run_logged( try: code = process.wait(timeout=timeout) except subprocess.TimeoutExpired: - # Stop descendants as well as Make; leave no timed-out suite running. + # Stop descendants as well as Mise; leave no timed-out suite running. os.killpg(process.pid, signal.SIGKILL) process.wait() print(f"Timed out after {timeout}s; see {log}", file=sys.stderr) @@ -96,7 +96,7 @@ def cleanup_container(client: list[str], environment: dict[str, str], name: str, def inside(artifacts: Path = Path("/artifacts")) -> int: - """Use the same Make contracts as native validation.""" + """Use the same Mise contracts as native validation.""" artifacts.mkdir(exist_ok=True) common: dict[str, str] = {**os.environ, "COVERAGE_FILE": str(artifacts / ".coverage")} unit: dict[str, str] = { @@ -112,8 +112,8 @@ def inside(artifacts: Path = Path("/artifacts")) -> int: "E2E_ARGS": shlex.join(["--work-dir", "/opt/e2e", "--run-only", "--artifacts", str(artifacts / "e2e")]), } codes = [ - run_logged(["make", task], artifacts / f"{name}.log", timeout=1500, env=environment) - for name, task, environment in (("unit", "coverage", unit), ("e2e", "test-e2e", e2e)) + run_logged([str(ROOT / "scripts/mise"), "run", task], artifacts / f"{name}.log", timeout=1500, env=environment) + for name, task, environment in (("unit", "test:ci", unit), ("e2e", "test:e2e", e2e)) ] return next((code for code in codes if code), 0) diff --git a/scripts/run_e2e.py b/scripts/run_e2e.py index 926a676..f0eca40 100644 --- a/scripts/run_e2e.py +++ b/scripts/run_e2e.py @@ -9,6 +9,7 @@ import hashlib import json import os +import shlex import shutil import subprocess import sys @@ -108,6 +109,10 @@ def prepare(work: Path, args: argparse.Namespace, parser: argparse.ArgumentParse python = runtime / "bin/python" env = environment(work) wheel = archive(work, args, parser) + resolved_uv = shutil.which(args.uv) + if not resolved_uv: + parser.error(f"uv executable not found: {args.uv}") + args.uv = str(Path(resolved_uv).resolve()) # Export locked runtime dependencies without the editable project or dev tools. requirements = work / "runtime-requirements.txt" command( @@ -237,7 +242,10 @@ def main() -> int: wheel_source = parser.add_mutually_exclusive_group() wheel_source.add_argument("--wheel", type=Path, help="Use an existing wheel instead of building one.") wheel_source.add_argument( - "--wheel-dir", type=Path, help="Use the single existing wheel in this directory; never build." + "--wheel-dir", + type=Path, + default=os.environ.get("WHEEL_DIR") or None, + help="Use the single existing wheel in this directory; never build.", ) parser.add_argument( "--work-dir", type=Path, help="Persistent external work directory (default: a new temporary one)." @@ -252,7 +260,13 @@ def main() -> int: mode.add_argument( "--run-only", action="store_true", help="Run prepared environments without installing or downloading." ) - args = parser.parse_args() + args = parser.parse_args([*shlex.split(os.environ.get("E2E_ARGS", "")), *sys.argv[1:]]) + wheel_directory = os.environ.get("WHEEL_DIR") + if wheel_directory: + if args.wheel: + parser.error("--wheel conflicts with the WHEEL_DIR environment selector") + if not args.wheel_dir: + args.wheel_dir = Path(wheel_directory) if args.run_only and (args.wheel or args.wheel_dir): parser.error("--run-only cannot be combined with --wheel or --wheel-dir; prepare that wheel first") work = ( diff --git a/scripts/run_ruff.py b/scripts/run_ruff.py new file mode 100644 index 0000000..4d53b38 --- /dev/null +++ b/scripts/run_ruff.py @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Apply the configured source scopes to a Ruff task without shell evaluation.""" + +from __future__ import annotations + +import os +import shlex +import subprocess +import sys + + +def main() -> int: + command = [os.environ.get("UV", "uv"), "run", "ruff", *sys.argv[1:]] + for name, default in ( + ("PY_SRC", "src scripts templates"), + ("PY_TESTS", "tests conftest.py"), + ("PY_E2E", "tests_e2e"), + ): + command += shlex.split(os.environ.get(name, default)) + return subprocess.call(command) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_test_suite.py b/scripts/run_test_suite.py new file mode 100644 index 0000000..311d50b --- /dev/null +++ b/scripts/run_test_suite.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Construct the ordinary pytest command shared by native and container tasks.""" + +from __future__ import annotations + +import argparse +import os +import shlex +import subprocess + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--coverage", action="store_true") + args = parser.parse_args() + command = [os.environ.get("UV", "uv"), "run", "pytest", "-n", os.environ.get("PYTEST_WORKERS", "auto")] + if args.coverage: + command += [f"--cov={package}" for package in shlex.split(os.environ.get("COV_PKGS", "usersim"))] + command += [ + "--cov-report=term-missing", + f"--cov-report=xml:{os.environ.get('COV_XML', 'coverage.xml')}", + f"--cov-fail-under={os.environ.get('COV_FAIL_UNDER', '84')}", + ] + command += shlex.split(os.environ.get("PYTEST_ARGS", "")) + return subprocess.call(command) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/update_license_headers.py b/scripts/update_license_headers.py index 6c5c4ab..41b0a5b 100644 --- a/scripts/update_license_headers.py +++ b/scripts/update_license_headers.py @@ -39,7 +39,7 @@ #: Files that the folder globs cannot reach: ``Makefile`` has no suffix, and #: ``conftest.py`` sits at the repository root rather than inside a package #: tree. Both carry `#` comments, so the standard header applies unchanged. -ROOT_FILES = frozenset(["Makefile", "conftest.py"]) +ROOT_FILES = frozenset(["Makefile", "conftest.py", "scripts/mise", ".mise.toml"]) # Maximum number of lines to search for SPDX license header MAX_HEADER_SEARCH_LINES = 10 @@ -257,7 +257,14 @@ def check_license_header_matches(file_path: Path, license_header: str) -> tuple[ def should_process_file(file_path: Path) -> bool: """Determine if a file should be processed for license headers.""" - if file_path.name not in ROOT_FILES and file_path.suffix not in SUPPORTED_EXTENSIONS: + mise_file = ".mise" in file_path.parts and "tasks" in file_path.parts + launcher = file_path.name == "mise" and file_path.parent.name == "scripts" + if ( + file_path.name not in ROOT_FILES + and file_path.suffix not in SUPPORTED_EXTENSIONS + and not mise_file + and not launcher + ): return False try: @@ -348,7 +355,7 @@ def main( files_needing_update: list[Path] = [] if files is None: - files = (f for ext in SUPPORTED_EXTENSIONS for f in path.glob(f"**/*{ext}")) + files = path.rglob("*") for file_path in files: if not file_path.is_file() or not should_process_file(file_path): @@ -403,7 +410,7 @@ def main( total_processed = total_updated = total_skipped = 0 # This project is one distribution under src/, so the roots are flat. - for folder in [] if args.files else ["src", "tests", "tests_e2e", "scripts", "templates"]: + for folder in [] if args.files else ["src", "tests", "tests_e2e", "scripts", "templates", ".mise/tasks"]: folder_path = repo_path / folder if not folder_path.exists(): continue diff --git a/tests/test_ci_workflows.py b/tests/test_ci_workflows.py index 42f9722..c0926cd 100644 --- a/tests/test_ci_workflows.py +++ b/tests/test_ci_workflows.py @@ -176,10 +176,8 @@ def test_health_checks_is_not_triggered_by_pull_requests(self) -> None: ) -class TestCiWrapsMakeTargets: - """CI that restates commands instead of calling the Makefile drifts from - what contributors run locally, and the drift only shows up as a failure - nobody can reproduce.""" +class TestCiWrapsMiseTasks: + """CI shares local tasks; standalone stdlib checkers need no tool setup.""" #: The jobs held to this, by workflow (None for every job). release.yml's #: release job is left out: it creates the GitHub release with a token only @@ -196,25 +194,33 @@ def _steps(self): for step in job["steps"]: yield name, job_name, step - def test_every_ci_step_is_a_make_target(self) -> None: + def test_ci_steps_share_tasks_or_the_standalone_status_checker(self) -> None: for name, job_name, step in self._steps(): if "uses" in step: continue run = step["run"].strip() - assert run.startswith("make "), ( - f"{name} job {job_name!r} runs {run!r} directly. Add a make target and call that instead." + if (name, job_name) == ("ci.yml", "status"): + assert run == "python3 scripts/check_ci_results.py" + continue + assert run == "bash scripts/install_mise.sh" or run.startswith("scripts/mise run "), ( + f"{name} job {job_name!r} runs {run!r} directly. Add a shared Mise task and call that instead." ) def test_the_targets_it_calls_exist(self) -> None: - makefile = (Path(__file__).resolve().parents[1] / "Makefile").read_text() - declared = { - line.split(":")[0] for line in makefile.splitlines() if line and not line[0].isspace() and ":" in line - } - for name, _, step in self._steps(): - if "run" not in step: + root = _WORKFLOWS.parents[1] / ".mise/tasks" + declared = set() + for path in root.rglob("*"): + if not path.is_file(): continue - target = step["run"].strip().split()[1] - assert target in declared, f"{name} calls `make {target}`, which the Makefile does not define." + if path.suffix == ".toml": + declared.update(tomllib.loads(path.read_text())) + else: + declared.add(":".join(path.relative_to(root).parts)) + for name, _, step in self._steps(): + run = step.get("run", "") + if run.startswith("scripts/mise run "): + target = run.split()[2] + assert target in declared, f"{name} calls missing Mise task {target}" class TestReleaseWorkflow: @@ -249,9 +255,9 @@ def test_the_tag_name_is_never_parsed_as_code(self) -> None: """A tag name may contain `$(shell ...)`, backticks or quotes. make expands a variable given on its command line, and a recipe that writes $(TAG) hands the name to the shell; only the environment passes it through untouched.""" - steps = [step for step in self._doc()["jobs"]["build"]["steps"] if "check-release-tag" in step.get("run", "")] + steps = [step for step in self._doc()["jobs"]["build"]["steps"] if "check:release-tag" in step.get("run", "")] assert len(steps) == 1, "release.yml's build job should run make check-release-tag exactly once." - assert steps[0]["run"].strip() == "make check-release-tag", ( + assert steps[0]["run"].strip() == "scripts/mise run check:release-tag", ( "Pass TAG in the step's env, not on make's command line, which make expands." ) assert steps[0].get("env", {}).get("TAG") == "${{ github.ref_name }}" @@ -263,9 +269,8 @@ def test_the_tag_name_is_never_parsed_as_code(self) -> None: def test_the_build_restores_no_cache(self) -> None: steps = self._doc()["jobs"]["build"]["steps"] - setups = [step for step in steps if step.get("uses", "").startswith("astral-sh/setup-uv@")] - assert setups and all(step.get("with", {}).get("enable-cache") is False for step in setups), ( - "release.yml's build must not restore a cache: one written by another run could reach the published files." + assert not any("cache@" in step.get("uses", "") for step in steps), ( + "release builds must not restore caches written by another run" ) @@ -308,14 +313,6 @@ def test_the_signed_commit_message_uses_only_substituted_placeholders(self) -> N ) -def test_native_matrix_matches_package_python_support() -> None: - project = tomllib.loads((_WORKFLOWS.parents[1] / "pyproject.toml").read_text())["project"] - supported = SpecifierSet(project["requires-python"]) - expected = [f"3.{minor}" for minor in range(30) if f"3.{minor}" in supported] - matrix = _load(_WORKFLOWS / "ci.yml")["jobs"]["test"]["strategy"]["matrix"] - assert matrix["python-version"] == expected - - class TestMandatoryCiAggregate: def test_aggregate_waits_for_every_job_and_checks_explicit_results(self) -> None: import importlib.util @@ -331,7 +328,7 @@ def test_aggregate_waits_for_every_job_and_checks_explicit_results(self) -> None assert status["if"] == "always()" assert status["name"] == "CI Status" assert status["env"]["CI_JOB_RESULTS"] == "${{ toJSON(needs) }}" - assert any(step.get("run") == "make check-ci-status" for step in status["steps"]) + assert [step["run"] for step in status["steps"] if "run" in step] == ["python3 scripts/check_ci_results.py"] def test_mandatory_jobs_cannot_be_optional_or_ignore_failures(self) -> None: jobs = _load(_WORKFLOWS / "ci.yml")["jobs"] @@ -364,3 +361,15 @@ def test_dco_trailers_rechecks_range_after_retargeting() -> None: env = doc["jobs"]["dco-trailers"]["env"] assert env["BASE_SHA"] == "${{ github.event.pull_request.base.sha }}" assert env["HEAD_SHA"] == "${{ github.event.pull_request.head.sha }}" + assert [step["run"] for step in doc["jobs"]["dco-trailers"]["steps"] if "run" in step] == [ + 'git fetch --no-tags origin "$BASE_SHA"', + 'python3 scripts/check_commit_signoffs.py --base "$BASE_SHA" --head "$HEAD_SHA"', + ] + + +def test_native_matrix_matches_package_python_support() -> None: + project = tomllib.loads((_WORKFLOWS.parents[1] / "pyproject.toml").read_text())["project"] + supported = SpecifierSet(project["requires-python"]) + expected = [f"3.{minor}" for minor in range(30) if f"3.{minor}" in supported] + matrix = _load(_WORKFLOWS / "ci.yml")["jobs"]["test"]["strategy"]["matrix"] + assert matrix["python-version"] == expected diff --git a/tests/test_container_runner.py b/tests/test_container_runner.py index dcd0166..5bfe460 100644 --- a/tests/test_container_runner.py +++ b/tests/test_container_runner.py @@ -99,7 +99,8 @@ def fake_logged(command, log, **kwargs): monkeypatch.setenv("COVERAGE_FILE", "original") monkeypatch.setattr(runner, "run_logged", fake_logged) assert runner.inside(tmp_path) == expected - assert [command for command, _ in calls] == [["make", "coverage"], ["make", "test-e2e"]] + assert [command[-2:] for command, _ in calls] == [["run", "test:ci"], ["run", "test:e2e"]] + assert all(command[0] == str(runner.ROOT / "scripts/mise") for command, _ in calls) assert [log.name for _, log in calls] == ["unit.log", "e2e.log"] assert "E2E_ARGS" not in environments[0] diff --git a/tests/test_coverage_gate.py b/tests/test_coverage_gate.py index 35dd7e9..0120ea9 100644 --- a/tests/test_coverage_gate.py +++ b/tests/test_coverage_gate.py @@ -75,8 +75,12 @@ def test_ci_enforces_coverage_once_and_retains_failure_artifacts(): job = workflow["jobs"]["test"] assert job["name"] == "Tests (Python ${{ matrix.python-version }})" assert "3.12" in job["strategy"]["matrix"]["python-version"] - tests = [step for step in job["steps"] if step.get("run", "").startswith("make test ")] - coverage = [step for step in job["steps"] if step.get("run", "").startswith("make coverage ")] + tests = [ + step + for step in job["steps"] + if step.get("run", "").startswith("scripts/mise run test") and step.get("run", "") == "scripts/mise run test" + ] + coverage = [step for step in job["steps"] if step.get("run", "").startswith("scripts/mise run test:ci")] assert len(tests) == len(coverage) == 1 assert tests[0]["if"] == "matrix.python-version != '3.12'" assert coverage[0]["if"] == "matrix.python-version == '3.12'" @@ -89,26 +93,11 @@ def test_ci_enforces_coverage_once_and_retains_failure_artifacts(): def test_native_and_ci_bundle_membership(): - result = subprocess.run( - ["make", "--question", "--print-data-base", "validate-ci"], - cwd=_ROOT, - capture_output=True, - text=True, - check=False, - timeout=30, - ) - # --question returns 1 when any prerequisite would need executing. - assert result.returncode in (0, 1), result.stderr - graph = dict( - line.split(":", 1) - for line in result.stdout.splitlines() - if line.startswith(("validate-native:", "validate-ci:")) - ) - assert set(graph["validate-native"].split()) == { - "check-all", - "check-dependency-licenses", - "coverage", - "smoke", - "check-extensions", - } - assert set(graph["validate-ci"].split()) == {"validate-native", "check-dist"} + import tomllib + + tasks = tomllib.loads((_ROOT / ".mise/tasks/packaging.toml").read_text()) + native = [step["task"] for step in tasks["validate:native"]["run"]] + assert native == ["check", "check:license:dependencies", "test:ci", "test:smoke", "check:extensions"] + ci = tasks["validate:ci"]["run"] + assert ci[:2] == [{"task": "validate:native"}, {"task": "check:dist"}] + assert ci[2] == "mise run test:e2e --wheel-dir dist" diff --git a/tests/test_e2e_harness.py b/tests/test_e2e_harness.py index f9721a5..2325a86 100644 --- a/tests/test_e2e_harness.py +++ b/tests/test_e2e_harness.py @@ -66,12 +66,12 @@ def test_archive_directory_must_have_exactly_one_wheel(count, tmp_path): def test_make_uses_selected_wheel_and_preserves_failure(status, tmp_path, command_test_env): uv = tmp_path / "fake-uv" recorded = tmp_path / "arguments.json" - version = re.search(r'required-version = "==([^"]+)"', (ROOT / "pyproject.toml").read_text()).group(1) + version = re.search(r'^uv = "([^"]+)"', (ROOT / ".mise.toml").read_text(), re.MULTILINE).group(1) uv.write_text( f"#!{sys.executable}\n" "import json, os, pathlib, sys\n" f"if sys.argv[1:] == ['--version']: print('uv {version}'); sys.exit(0)\n" - "pathlib.Path(os.environ['E2E_RECORDED_ARGS']).write_text(json.dumps(sys.argv[1:]))\n" + "pathlib.Path(os.environ['E2E_RECORDED_ARGS']).write_text(json.dumps({'argv':sys.argv[1:],'wheel':os.environ.get('WHEEL_DIR'),'args':os.environ.get('E2E_ARGS'),'uv':os.environ.get('UV')}))\n" f"sys.exit({status})\n" ) uv.chmod(0o755) @@ -87,10 +87,10 @@ def test_make_uses_selected_wheel_and_preserves_failure(status, tmp_path, comman ) assert (result.returncode == 0) is (status == 0), result.stderr arguments = json.loads(recorded.read_text()) - assert arguments[:4] == ["run", "--frozen", "python", "scripts/run_e2e.py"] - assert arguments[arguments.index("--wheel-dir") + 1] == str(wheels) - assert arguments[arguments.index("--uv") + 1] == str(uv) - assert arguments[-2:] == ["--artifacts", "output/e2e-custom"] + assert arguments["argv"] == ["run", "--frozen", "python", "scripts/run_e2e.py"] + assert arguments["wheel"] == str(wheels) + assert arguments["uv"] == str(uv) + assert arguments["args"] == "--artifacts output/e2e-custom" def test_ci_runs_installed_e2e_and_retains_diagnostics(): @@ -98,13 +98,84 @@ def test_ci_runs_installed_e2e_and_retains_diagnostics(): job = workflow["jobs"]["e2e"] assert job["timeout-minutes"] <= 30 commands = [step["run"] for step in job["steps"] if "run" in step] - assert commands == ["make install-ci", 'make test-e2e E2E_ARGS="--artifacts test-results/e2e"'] + assert commands == [ + "bash scripts/install_mise.sh", + "scripts/mise run setup:tools", + "scripts/mise run setup:ci", + "scripts/mise run test:e2e", + ] + e2e = next(step for step in job["steps"] if step.get("run") == "scripts/mise run test:e2e") + assert e2e["env"]["E2E_ARGS"] == "--artifacts test-results/e2e" upload = next(step for step in job["steps"] if step.get("uses", "").startswith("actions/upload-artifact@")) assert upload["if"] == "failure()" assert upload["with"]["path"] == "test-results/e2e/" assert not any(step.get("continue-on-error") for step in job["steps"]) +def test_explicit_wheel_conflicts_with_environment_directory(tmp_path): + import os + import subprocess + import sys + + result = subprocess.run( + [sys.executable, str(ROOT / "scripts/run_e2e.py"), "--wheel", str(tmp_path / "selected.whl"), "--prepare-only"], + env={**os.environ, "WHEEL_DIR": str(tmp_path / "old")}, + capture_output=True, + text=True, + check=False, + timeout=10, + ) + assert result.returncode == 2 + assert "conflicts with the WHEEL_DIR" in result.stderr + + +def test_final_explicit_directory_defeats_ambient_archive_selectors(tmp_path, monkeypatch): + import importlib.util + + spec = importlib.util.spec_from_file_location("e2e_identity", RUNNER) + runner = importlib.util.module_from_spec(spec) + spec.loader.exec_module(runner) + checkout = tmp_path / "checkout" + default_artifacts = checkout / "output/e2e" + default_artifacts.mkdir(parents=True) + prior = { + "operator-evidence.txt": b"untouched prior result", + } + for name, content in prior.items(): + (default_artifacts / name).write_bytes(content) + monkeypatch.setattr(runner, "ROOT", checkout) + validated = tmp_path / "validated" + other = tmp_path / "other" + for directory in (validated, other): + directory.mkdir() + (directory / "example.whl").touch() + selected = [] + monkeypatch.setenv("WHEEL_DIR", str(other)) + monkeypatch.setenv("E2E_ARGS", f"--wheel-dir {other}") + monkeypatch.setattr( + sys, + "argv", + [ + str(RUNNER), + "--wheel-dir", + str(validated), + "--work-dir", + str(tmp_path / "work"), + "--artifacts", + str(tmp_path / "artifacts"), + "--prepare-only", + ], + ) + monkeypatch.setattr( + runner, "prepare", lambda work, args, parser: selected.append(runner.archive(work, args, parser)) + ) + for _ in range(2): + assert runner.main() == 0 + assert {p.name: p.read_bytes() for p in default_artifacts.iterdir()} == prior + assert list((checkout / "output").iterdir()) == [default_artifacts] + assert selected == [(validated / "example.whl").resolve()] * 2 + + @pytest.mark.parametrize("filename,run_only", [(".env", False), (".env.local", True)]) def test_external_work_directory_stops_ancestor_dotenv_loading(filename, run_only, tmp_path, monkeypatch): import importlib.util diff --git a/tests/test_mise_bootstrap.py b/tests/test_mise_bootstrap.py new file mode 100644 index 0000000..6d7a845 --- /dev/null +++ b/tests/test_mise_bootstrap.py @@ -0,0 +1,110 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Bootstrap binds the installer signature to its pinned key and selected path.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +RELEASE_KEY = "24853EC9F655CE80B48E6C3A8B81C9D17413A06D" + + +@pytest.fixture(scope="module") +def signing_material(tmp_path_factory): + if not shutil.which("gpg"): + pytest.skip("Mise bootstrap requires GPG") + work = tmp_path_factory.mktemp("mise-signatures") + home = work / "gnupg" + home.mkdir(mode=0o700) + command = ["gpg", "--homedir", str(home), "--batch", "--pinentry-mode", "loopback", "--passphrase", ""] + keys = [] + for name in ("Pinned", "Other"): + subprocess.run( + [*command, "--quick-generate-key", f"{name} <{name.lower()}@example.test>", "ed25519", "sign", "0"], + check=True, + capture_output=True, + timeout=30, + ) + output = subprocess.check_output([*command, "--with-colons", "--list-keys", name], timeout=10).decode() + keys.append(next(line.split(":")[9] for line in output.splitlines() if line.startswith("fpr:"))) + (work / "keys.asc").write_bytes(subprocess.check_output([*command, "--armor", "--export"], timeout=10)) + installer = work / "installer.sh" + installer.write_text( + "#!/bin/sh\n" + 'printf "#!/bin/sh\\nprintf \'2026.8.5 fixture\\\\n\'\\n" > "$MISE_INSTALL_PATH"\n' + 'chmod +x "$MISE_INSTALL_PATH"\n' + 'touch "$BOOTSTRAP_MARKER"\n' + ) + for index, key in enumerate(keys): + subprocess.run( + [*command, "--local-user", key, "--output", str(work / f"installer-{index}.sig"), "--sign", str(installer)], + check=True, + capture_output=True, + timeout=10, + ) + yield work, keys[0] + subprocess.run(["gpgconf", "--homedir", str(home), "--kill", "all"], check=False, capture_output=True, timeout=10) + + +@pytest.mark.parametrize("signer,success", [(0, True), (1, False)]) +def test_mixed_key_bundle_requires_the_pinned_installer_signer(tmp_path, signing_material, signer, success): + material, pinned = signing_material + scripts = tmp_path / "scripts" + scripts.mkdir() + (tmp_path / ".mise.toml").write_text('min_version = "2026.8.5"\n') + # The fixture owns both private keys. Substitute only the public-key pin in + # its isolated copy so verification still runs through real GPG. + script = (ROOT / "scripts/install_mise.sh").read_text().replace(RELEASE_KEY, pinned) + (scripts / "install_mise.sh").write_text(script) + curl = tmp_path / "curl" + curl.write_text( + f"#!{sys.executable}\nimport os, shutil, sys\n" + "name = 'keys.asc' if 'by-fingerprint' in sys.argv[-1] else os.environ['SIGNED_FIXTURE']\n" + f"shutil.copyfile({str(material)!r} + '/' + name, sys.argv[sys.argv.index('-o') + 1])\n" + ) + curl.chmod(0o755) + marker = tmp_path / "executed" + destination = tmp_path / "custom-bin/mise" + result = subprocess.run( + ["bash", str(scripts / "install_mise.sh")], + env={ + **os.environ, + "PATH": str(tmp_path) + os.pathsep + os.environ["PATH"], + "MISE_INSTALL_PATH": str(destination), + "BOOTSTRAP_MARKER": str(marker), + "SIGNED_FIXTURE": f"installer-{signer}.sig", + }, + capture_output=True, + text=True, + check=False, + timeout=30, + ) + assert (result.returncode == 0) is success, result.stderr + assert marker.exists() is success + if not success: + assert "signer does not match" in result.stderr + + +def test_custom_install_path_is_used_by_the_task_wrapper(tmp_path): + executable = tmp_path / "mise" + executable.write_text('#!/bin/sh\nprintf "custom-mise %s\\n" "$1"\n') + executable.chmod(0o755) + env = {**os.environ, "MISE_INSTALL_PATH": str(executable)} + env.pop("MISE_BIN", None) + result = subprocess.run( + ["bash", str(ROOT / "scripts/mise"), "--version"], + env=env, + capture_output=True, + text=True, + check=True, + timeout=10, + ) + assert result.stdout == "custom-mise --version\n" diff --git a/tests/test_mise_tasks.py b/tests/test_mise_tasks.py new file mode 100644 index 0000000..3bb1c7e --- /dev/null +++ b/tests/test_mise_tasks.py @@ -0,0 +1,111 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Task and compatibility entry points preserve argv, selectors and failures.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture +def recording_uv(tmp_path, monkeypatch): + executable = tmp_path / "uv" + record = tmp_path / "record.json" + executable.write_text( + f"#!{sys.executable}\nimport json, os, pathlib, sys\n" + "if sys.argv[1:] == ['--version']:\n print('uv 0.13.1 fixture')\n raise SystemExit(0)\n" + "pathlib.Path(os.environ['TASK_RECORD']).write_text(json.dumps({'argv':sys.argv[1:]," + "'python':os.environ.get('UV_PYTHON'),'venv':os.environ.get('UV_PROJECT_ENVIRONMENT')}))\n" + "raise SystemExit(int(os.environ.get('TASK_STATUS','0')))\n" + ) + executable.chmod(0o755) + monkeypatch.setenv("UV", str(executable)) + monkeypatch.setenv("TASK_RECORD", str(record)) + return executable, record + + +@pytest.mark.parametrize("status", [0, 7]) +def test_make_and_mise_preserve_quoted_pytest_arguments(recording_uv, monkeypatch, status, command_test_env): + _, record = recording_uv + args = "-k 'alpha or beta' --junitxml='path with spaces.xml'" + monkeypatch.setenv("TASK_STATUS", str(status)) + records = [] + for command, extra in [ + (["make", "coverage", "PYTEST_WORKERS=2", f"PYTEST_ARGS={args}"], {}), + ([str(ROOT / "scripts/mise"), "run", "test:ci"], {"PYTEST_WORKERS": "2", "PYTEST_ARGS": args}), + ]: + result = subprocess.run( + command, cwd=ROOT, env=command_test_env(**extra), capture_output=True, check=False, timeout=30 + ) + assert (result.returncode == 0) is (status == 0) + records.append(json.loads(record.read_text())["argv"]) + assert records[0] == records[1] + assert records[0][-3:] == ["-k", "alpha or beta", "--junitxml=path with spaces.xml"] + + +@pytest.mark.parametrize("selector", ["3.11", "3.14"]) +def test_setup_preserves_requested_python_and_environment( + recording_uv, monkeypatch, tmp_path, selector, command_test_env +): + _, record = recording_uv + monkeypatch.setenv("UV_PYTHON", selector) + selected = tmp_path / "selected-venv" + monkeypatch.setenv("UV_PROJECT_ENVIRONMENT", str(selected)) + subprocess.run( + ["make", "install-ci"], cwd=ROOT, env=command_test_env(), capture_output=True, check=True, timeout=30 + ) + observed = json.loads(record.read_text()) + assert observed == {"argv": ["sync", "--locked"], "python": selector, "venv": str(selected)} + + +def test_extensionless_task_has_spdx_coverage(tmp_path): + import importlib.util + + spec = importlib.util.spec_from_file_location("license_for_mise", ROOT / "scripts/update_license_headers.py") + checker = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = checker + spec.loader.exec_module(checker) + task = tmp_path / ".mise/tasks/check/invalid" + task.parent.mkdir(parents=True) + task.write_text("#!/usr/bin/env bash\nif then\n") + assert checker.should_process_file(task) + assert checker.main(tmp_path, check_only=True, files=[task])[1] == 1 + + +def test_lint_scope_preserves_empty_overrides_and_quoted_paths(recording_uv, command_test_env): + _, record = recording_uv + subprocess.run( + ["make", "lint", "PY_SRC='path with spaces.py'", "PY_TESTS=", "PY_E2E="], + cwd=ROOT, + env=command_test_env(), + capture_output=True, + check=True, + timeout=30, + ) + assert json.loads(record.read_text())["argv"] == [ + "run", + "ruff", + "check", + "--output-format=full", + "path with spaces.py", + ] + + +def test_release_tag_remains_data_through_make_and_mise(recording_uv, monkeypatch, tmp_path, command_test_env): + _, record = recording_uv + marker = tmp_path / "executed" + tag = f"$(shell touch {marker})" + monkeypatch.setenv("TAG", tag) + subprocess.run( + ["make", "check-release-tag"], cwd=ROOT, env=command_test_env(), capture_output=True, check=True, timeout=30 + ) + assert json.loads(record.read_text())["argv"][-2:] == [tag, "dist"] + assert not marker.exists() diff --git a/tests/test_release_validation_workflow.py b/tests/test_release_validation_workflow.py index 0699dde..b40871b 100644 --- a/tests/test_release_validation_workflow.py +++ b/tests/test_release_validation_workflow.py @@ -15,17 +15,22 @@ def workflow(): def test_native_gates_and_original_wheel_run_before_archive_upload(): steps = workflow()["jobs"]["build"]["steps"] - commands = [step["run"] for step in steps if "run" in step] - assert commands[:5] == [ - "make install-ci", - "make validate-native", - "make check-dist", - "make check-release-tag", - "make test-e2e WHEEL_DIR=dist", + commands = [ + step["run"] + for step in steps + if step.get("run", "").startswith("scripts/mise run ") and step["run"] != "scripts/mise run setup:tools" + ] + assert commands == [ + "scripts/mise run setup:ci", + "scripts/mise run validate:native", + "scripts/mise run check:dist", + "scripts/mise run check:release-tag", + "scripts/mise run test:e2e --wheel-dir dist", + "scripts/mise run release:digests", ] - assert len(commands) == 6 - assert commands[5] == "make release-digests" assert workflow()["jobs"]["build"].get("environment") is None + e2e = next(step for step in steps if step.get("run") == "scripts/mise run test:e2e --wheel-dir dist") + assert e2e["env"]["WHEEL_DIR"] == "dist" uploads = [i for i, step in enumerate(steps) if step.get("uses", "").startswith("actions/upload-artifact@")] assert len(uploads) == 1 assert uploads[0] == len(steps) - 1