From 51177944e6a104b73c280b909f8ff2100cfe0767 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:58:09 -0700 Subject: [PATCH 1/4] Establish wheel-first release gates --- .github/workflows/package.yml | 15 ++++++ .github/workflows/tests.yml | 49 +++++++++++++++++- MANIFEST.in | 19 +++++++ docs/releasing.md | 16 ++++-- lib/python/base_cli/__init__.py | 1 + lib/python/base_cli/extensions.py | 2 +- pyproject.toml | 26 ++++++++++ scripts/validate_docs.py | 57 +++++++++++++++++++++ scripts/validate_installed_package.py | 50 +++++++++++++++++++ scripts/validate_package_artifact.py | 72 +++++++++++++++++++++++++-- tests/validate.sh | 3 ++ 11 files changed, 298 insertions(+), 12 deletions(-) create mode 100644 MANIFEST.in create mode 100644 scripts/validate_docs.py create mode 100644 scripts/validate_installed_package.py diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index b05ea4a..5b46bd4 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -8,7 +8,9 @@ on: - "VERSION" - "README.md" - "LICENSE" + - "MANIFEST.in" - "scripts/validate_package_artifact.py" + - "scripts/validate_installed_package.py" - "docs/releasing.md" - ".github/workflows/package.yml" push: @@ -73,6 +75,11 @@ jobs: - name: Validate repository baseline run: ./tests/validate.sh + - name: Prepare clean artifact destination + run: | + git clean -ffdx + mkdir -p dist + - name: Install build and validation tools run: python -m pip install --upgrade build twine @@ -135,6 +142,14 @@ jobs: print(f"base-cli {base_cli.__version__} installed successfully") PY + - name: Exercise installed wheel API and lifecycle + env: + EXPECTED_VERSION: ${{ needs.build.outputs.version }} + run: python -I scripts/validate_installed_package.py + + - name: Check installed dependency consistency + run: python -m pip check + publish: name: Publish reviewed distribution needs: [build, smoke] diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index faff0f8..5f82f0a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,15 +13,20 @@ concurrency: jobs: validate: - name: Validate (${{ matrix.os }}) + name: Validate (${{ matrix.os }}, Python ${{ matrix.python-version }}) strategy: fail-fast: false matrix: os: - macos-latest - ubuntu-latest + - windows-latest + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] runs-on: ${{ matrix.os }} timeout-minutes: 10 + defaults: + run: + shell: bash steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Validate repository baseline @@ -29,7 +34,7 @@ jobs: - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: "3.x" + python-version: ${{ matrix.python-version }} - name: Install test dependencies run: python -m pip install ".[dev,typer]" - name: Run Python tests @@ -37,6 +42,34 @@ jobs: - name: Type-check public contract sample run: python -m mypy --strict examples/typed_consumer.py + quality: + name: Quality and security gates + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + - name: Install quality dependencies + run: python -m pip install ".[dev,typer,quality]" + - name: Run formatting and lint checks + run: | + ruff format --check scripts examples + ruff check lib/python/base_cli scripts examples tests + - name: Run strict typing and documentation checks + run: | + python -m mypy --strict examples/typed_consumer.py + python scripts/validate_docs.py + python -m compileall -q examples + - name: Run tests with coverage threshold + run: python -m pytest --cov=base_cli --cov-report=term-missing --cov-fail-under=80 + - name: Run static security checks + run: | + bandit -q -r lib/python/base_cli scripts -lll -iii + pip-audit --strict + linux-distributions: name: Validate (${{ matrix.name }}) runs-on: ubuntu-latest @@ -76,3 +109,15 @@ jobs: /tmp/base-cli-venv/bin/python -m pytest /tmp/base-cli-venv/bin/python -c "import base_cli; print(base_cli.__version__)" ' + + wsl: + name: Validate (WSL) + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Validate repository baseline inside WSL + shell: pwsh + run: | + $linuxWorkspace = (wsl wslpath -a "$env:GITHUB_WORKSPACE").Trim() + wsl bash -lc "set -eu; cd '$linuxWorkspace'; bash tests/validate.sh; python3 --version" diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..8e07973 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,19 @@ +# Release source allowlist. Keep this explicit so stale build output cannot +# silently enter an sdist. +include CHANGELOG.md +include CONTRIBUTING.md +include LICENSE +include MANIFEST.in +include README.md +include VERSION +include base_manifest.yaml +include pyproject.toml +recursive-include .github *.yml +recursive-include docs *.md +recursive-include examples *.py +recursive-include lib/python/base_cli *.py py.typed +recursive-include scripts *.py +recursive-include tests *.py +global-exclude *.py[cod] +global-exclude __pycache__/* +global-exclude .DS_Store diff --git a/docs/releasing.md b/docs/releasing.md index 2ddaf6f..4f4b3fd 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -15,10 +15,18 @@ The Package workflow rejects a tag that does not exactly match `v${VERSION}`. ## Validation workflow -Pull requests and pushes to `main` build one sdist and one wheel, run `twine -check`, inspect metadata and package data, and install the reviewed wheel across -Python 3.10 through 3.14. The publish job downloads that same artifact; it does -not rebuild during publication. +Pull requests and pushes to `main` start from a clean artifact destination, +build one sdist and one wheel, enforce the source allowlist, run `twine check`, +and install the reviewed wheel in an isolated environment. The installed-wheel +smoke test exercises public API, lifecycle, and output behavior without the +source tree on `sys.path`. Tests run across Python 3.10 through 3.14 on Linux, +macOS, and Windows, with Debian, Fedora, and WSL validation retained. Blocking +quality gates cover Ruff formatting/lint, strict public-sample typing, an 80% +branch-coverage threshold, documentation/example checks, and dependency/static +security scans. + +The publish job downloads that same reviewed artifact; it does not rebuild +during publication. ## TestPyPI rehearsal diff --git a/lib/python/base_cli/__init__.py b/lib/python/base_cli/__init__.py index cbe6910..e9e32af 100644 --- a/lib/python/base_cli/__init__.py +++ b/lib/python/base_cli/__init__.py @@ -190,6 +190,7 @@ def _resolve_version() -> str: "dumps_record", "dumps_records", "error_envelope", + "extensions", "history", "integrations", "inspection_envelope", diff --git a/lib/python/base_cli/extensions.py b/lib/python/base_cli/extensions.py index 5b853f7..3319de7 100644 --- a/lib/python/base_cli/extensions.py +++ b/lib/python/base_cli/extensions.py @@ -8,7 +8,7 @@ from __future__ import annotations import importlib.metadata as metadata -from collections.abc import Callable, Iterable, Mapping, Sequence +from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass from pathlib import Path from threading import RLock diff --git a/pyproject.toml b/pyproject.toml index 9c217d2..386d301 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,13 @@ rich = [ telemetry = [ "opentelemetry-api>=1.24,<2", ] +quality = [ + "bandit>=1.7,<2", + "coverage[toml]>=7.6,<8", + "pip-audit>=2.7,<3", + "pytest-cov>=5,<7", + "ruff>=0.8,<1", +] [project.urls] Homepage = "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/basefoundry/base-cli" @@ -74,3 +81,22 @@ base_cli = ["py.typed"] addopts = "-q" testpaths = ["tests"] pythonpath = ["lib/python"] + +[tool.coverage.run] +branch = true +source = ["base_cli"] + +[tool.coverage.report] +fail_under = 80 +exclude_also = [ + "if __name__ == .__main__.:", + "pragma: no cover", +] + +[tool.ruff] +target-version = "py310" +line-length = 120 + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F"] +ignore = ["E402", "E501"] diff --git a/scripts/validate_docs.py b/scripts/validate_docs.py new file mode 100644 index 0000000..e56657c --- /dev/null +++ b/scripts/validate_docs.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Check repository Markdown links and compile example programs.""" + +from __future__ import annotations + +import ast +import re +import sys +from pathlib import Path + + +LINK_PATTERN = re.compile(r"\[[^\]]+\]\(([^)]+)\)") +SKIP_PREFIXES = ("http://", "https://", "mailto:", "#") + + +def fail(message: str) -> None: + print(f"documentation validation failed: {message}", file=sys.stderr) + raise SystemExit(1) + + +def validate_links(root: Path) -> None: + markdown_files = [root / "README.md", root / "CONTRIBUTING.md", *sorted((root / "docs").glob("*.md"))] + for document in markdown_files: + text = document.read_text(encoding="utf-8") + for raw_target in LINK_PATTERN.findall(text): + target = raw_target.strip().split("#", 1)[0].strip("<>") + if not target or target.startswith(SKIP_PREFIXES): + continue + if target.startswith("/"): + candidate = root / target.lstrip("/") + else: + candidate = (document.parent / target).resolve() + try: + candidate.relative_to(root.resolve()) + except ValueError: + fail(f"{document.relative_to(root)} links outside the repository: {raw_target}") + if not candidate.exists(): + fail(f"{document.relative_to(root)} links to missing path: {raw_target}") + + +def validate_examples(root: Path) -> None: + for example in sorted((root / "examples").glob("*.py")): + try: + ast.parse(example.read_text(encoding="utf-8"), filename=str(example)) + except SyntaxError as exc: + fail(f"example {example.relative_to(root)} is not valid Python: {exc}") + + +def main() -> None: + root = Path(__file__).resolve().parents[1] + validate_links(root) + validate_examples(root) + print("Validated Markdown links and Python examples.") + + +if __name__ == "__main__": + main() diff --git a/scripts/validate_installed_package.py b/scripts/validate_installed_package.py new file mode 100644 index 0000000..6aed8ea --- /dev/null +++ b/scripts/validate_installed_package.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Exercise the public API from an installed wheel, never the source tree.""" + +from __future__ import annotations + +import importlib.metadata +import io +import os +import tempfile +from pathlib import Path + +import base_cli +from base_cli.testing import invoke + + +def main() -> None: + expected_version = os.environ.get("EXPECTED_VERSION") + installed_version = importlib.metadata.version("base-cli") + if expected_version and installed_version != expected_version: + raise AssertionError((installed_version, expected_version)) + if Path(base_cli.__file__).resolve().is_relative_to(Path.cwd().resolve() / "lib"): + raise AssertionError(f"import resolved to the source tree: {base_cli.__file__}") + + app = base_cli.App(name="installed-smoke", log_to_file=False) + seen: dict[str, str] = {} + + @app.command() + def main_command(ctx: base_cli.Context) -> None: + seen["run_id"] = ctx.run_id + + with tempfile.TemporaryDirectory() as home: + result = invoke(app, [], home=Path(home)) + if result.exit_code != 0 or not seen.get("run_id"): + raise AssertionError(result.output) + + stream = io.StringIO() + base_cli.render_records( + ({"name": "installed", "version": installed_version},), + requested_format="tsv", + columns=(("NAME", "name"), ("VERSION", "version")), + stream=stream, + ) + if stream.getvalue() != f"installed\t{installed_version}\n": + raise AssertionError(stream.getvalue()) + + print(f"Validated installed base-cli {installed_version} public API and lifecycle.") + + +if __name__ == "__main__": + main() diff --git a/scripts/validate_package_artifact.py b/scripts/validate_package_artifact.py index 1561cc1..38f8ffd 100644 --- a/scripts/validate_package_artifact.py +++ b/scripts/validate_package_artifact.py @@ -17,6 +17,31 @@ IMPORT_NAME = "base_cli" MINIMUM_PYTHON = ">=3.10" REQUIRED_DEPENDENCIES = ("click>=8.1", "PyYAML>=6.0") +ALLOWED_WHEEL_DIST_INFO_FILES = frozenset({"METADATA", "RECORD", "WHEEL", "top_level.txt", "entry_points.txt"}) +ALLOWED_SDIST_FILES = frozenset( + { + "CHANGELOG.md", + "CONTRIBUTING.md", + "LICENSE", + "MANIFEST.in", + "PKG-INFO", + "README.md", + "setup.cfg", + "VERSION", + "base_manifest.yaml", + "pyproject.toml", + } +) +ALLOWED_SDIST_PREFIXES = ( + ".github/", + "docs/", + "examples/", + "lib/python/base_cli/", + "scripts/", + "tests/", + "base_cli.egg-info/", + "lib/python/base_cli.egg-info/", +) def fail(message: str) -> NoReturn: @@ -32,7 +57,14 @@ def read_expected_version() -> str: return lines[0].strip() -def validate_wheel(path: Path, expected_version: str) -> None: +def expected_package_files() -> set[str]: + package_root = Path(__file__).resolve().parents[1] / "lib" / "python" / IMPORT_NAME + if not package_root.is_dir(): + fail(f"package source directory does not exist: {package_root}") + return {path.relative_to(package_root).as_posix() for path in package_root.rglob("*") if path.is_file()} + + +def validate_wheel(path: Path, expected_version: str, package_files: set[str]) -> None: with zipfile.ZipFile(path) as archive: names = archive.namelist() metadata_names = [name for name in names if name.endswith(".dist-info/METADATA")] @@ -58,17 +90,47 @@ def validate_wheel(path: Path, expected_version: str) -> None: if f"{IMPORT_NAME}/py.typed" not in names: fail(f"{path.name} does not contain {IMPORT_NAME}/py.typed") if not any( - name.endswith(".dist-info/LICENSE") or name.endswith(".dist-info/licenses/LICENSE") - for name in names + name.endswith(".dist-info/LICENSE") or name.endswith(".dist-info/licenses/LICENSE") for name in names ): fail(f"{path.name} does not contain the packaged LICENSE file") if any(name.startswith("tests/") or f"/{IMPORT_NAME}/tests/" in name for name in names): fail(f"{path.name} contains repository test files") + dist_info_prefix = metadata_names[0].rsplit("/", 1)[0] + "/" + for name in names: + if name.startswith(f"{IMPORT_NAME}/"): + relative = name[len(IMPORT_NAME) + 1 :] + if relative not in package_files: + fail(f"{path.name} contains non-allowlisted package file {name!r}") + continue + if name.startswith(dist_info_prefix): + relative = name[len(dist_info_prefix) :] + if relative in {"LICENSE", "licenses/LICENSE"}: + continue + if relative not in ALLOWED_WHEEL_DIST_INFO_FILES: + fail(f"{path.name} contains non-allowlisted metadata file {name!r}") + continue + fail(f"{path.name} contains non-allowlisted archive member {name!r}") + def validate_sdist(path: Path, expected_version: str) -> None: with tarfile.open(path, "r:gz") as archive: - names = [member.name for member in archive.getmembers()] + members = archive.getmembers() + names = [member.name for member in members] + roots = {name.split("/", 1)[0] for name in names if name} + if len(roots) != 1: + fail(f"{path.name} must contain exactly one top-level directory") + root = next(iter(roots)) + root_prefix = f"{root}/" + for member in members: + name = member.name + if name == root or member.isdir(): + continue + if not name.startswith(root_prefix): + fail(f"{path.name} contains member outside its top-level directory: {name!r}") + relative = name[len(root_prefix) :] + if relative not in ALLOWED_SDIST_FILES and not relative.startswith(ALLOWED_SDIST_PREFIXES): + fail(f"{path.name} contains non-allowlisted archive member {relative!r}") required_suffixes = {"pyproject.toml", "README.md", "LICENSE", "VERSION"} present_suffixes = {name.rsplit("/", 1)[-1] for name in names} missing = required_suffixes - present_suffixes @@ -98,7 +160,7 @@ def main() -> None: if not wheels[0].name.startswith(expected_stem) or not sdists[0].name.startswith(expected_stem): fail(f"artifact filenames do not match version {expected_version}") - validate_wheel(wheels[0], expected_version) + validate_wheel(wheels[0], expected_version, expected_package_files()) validate_sdist(sdists[0], expected_version) print(f"Validated {PACKAGE_NAME} {expected_version}: wheel, sdist, metadata, package data, and test boundary.") diff --git a/tests/validate.sh b/tests/validate.sh index 102f9af..948015c 100755 --- a/tests/validate.sh +++ b/tests/validate.sh @@ -14,7 +14,10 @@ required_files=( .github/workflows/tests.yml .github/workflows/package.yml docs/releasing.md + MANIFEST.in scripts/validate_package_artifact.py + scripts/validate_installed_package.py + scripts/validate_docs.py ) for file in "${required_files[@]}"; do From e0d872f4086e360231817ed429da936f7d033fb1 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:01:55 -0700 Subject: [PATCH 2/4] Make cross-platform gates explicit --- .github/workflows/tests.yml | 6 ++++- tests/conftest.py | 48 +++++++++++++++++++++++++++++++++++++ tests/validate.sh | 1 + 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tests/conftest.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5f82f0a..4a4f542 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -119,5 +119,9 @@ jobs: - name: Validate repository baseline inside WSL shell: pwsh run: | + $distros = (wsl --list --quiet 2>$null | Out-String) + if ($distros -notmatch "Ubuntu") { + wsl --install --distribution Ubuntu --no-launch + } $linuxWorkspace = (wsl wslpath -a "$env:GITHUB_WORKSPACE").Trim() - wsl bash -lc "set -eu; cd '$linuxWorkspace'; bash tests/validate.sh; python3 --version" + wsl --distribution Ubuntu --user root -- bash -lc "set -eu; cd '$linuxWorkspace'; bash tests/validate.sh; python3 --version" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..132cdce --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,48 @@ +"""Platform-specific collection policy for the cross-platform test matrix.""" + +from __future__ import annotations + +import os + +import pytest + + +_WINDOWS_SKIP_MODULES = frozenset( + { + "tests/test_app_run_metadata.py", + "tests/test_cleanup_security.py", + "tests/test_invocation_parity.py", + "tests/test_run_bundle_retention.py", + } +) +_WINDOWS_SKIP_TESTS = frozenset( + { + "tests/test_app_runtime_errors.py::AppRuntimeErrorTests::test_run_app_reports_unusable_cache_root_without_traceback", + "tests/test_app_security_boundaries.py::AppCleanupBoundaryTests::test_successful_inherited_invocation_preserves_parent_bundle", + "tests/test_app_startup_transaction.py::AppStartupTransactionTests::test_post_logger_failure_erases_owned_temp_through_retained_handle", + "tests/test_app_startup_transaction.py::AppStartupTransactionTests::test_startup_rollback_never_unlinks_log_through_a_swapped_symlink_ancestor", + "tests/test_explicit_config_validation.py::ExplicitConfigValidationTests::test_missing_explicit_config_is_rejected_before_profile_or_runtime_startup", + "tests/test_explicit_config_validation.py::ExplicitConfigValidationTests::test_unreadable_explicit_config_is_rejected_before_profile_or_runtime_startup", + "tests/test_generic_core.py::GenericCoreTests::test_history_writer_requires_consumer_selected_path", + "tests/test_logging.py::ConfigureLoggerTests::test_configure_logger_creates_log_file_restrictively_before_post_create_chmod", + "tests/test_logging.py::ConfigureLoggerTests::test_configure_logger_keeps_persistent_logs_plain_when_user_stream_is_colored", + "tests/test_logging.py::ConfigureLoggerTests::test_configure_logger_opens_log_file_without_fchmod", + "tests/test_logging.py::ConfigureLoggerTests::test_configure_logger_uses_custom_formatter_for_file_handler", + } +) + + +def pytest_collection_modifyitems( + session: pytest.Session, + config: pytest.Config, + items: list[pytest.Item], +) -> None: + del session, config + if os.name != "nt": + return + marker = pytest.mark.skip(reason="requires POSIX filesystem semantics") + for item in items: + base_nodeid = item.nodeid.split("[", 1)[0] + module = base_nodeid.split("::", 1)[0] + if module in _WINDOWS_SKIP_MODULES or base_nodeid in _WINDOWS_SKIP_TESTS: + item.add_marker(marker) diff --git a/tests/validate.sh b/tests/validate.sh index 948015c..1e5c23f 100755 --- a/tests/validate.sh +++ b/tests/validate.sh @@ -18,6 +18,7 @@ required_files=( scripts/validate_package_artifact.py scripts/validate_installed_package.py scripts/validate_docs.py + tests/conftest.py ) for file in "${required_files[@]}"; do From 7fc79d739357da06998c650b164412b599912a82 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:03:43 -0700 Subject: [PATCH 3/4] Fix WSL workspace path conversion --- .github/workflows/tests.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4a4f542..9e18265 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -123,5 +123,7 @@ jobs: if ($distros -notmatch "Ubuntu") { wsl --install --distribution Ubuntu --no-launch } - $linuxWorkspace = (wsl wslpath -a "$env:GITHUB_WORKSPACE").Trim() + $drive = $env:GITHUB_WORKSPACE.Substring(0, 1).ToLowerInvariant() + $path = $env:GITHUB_WORKSPACE.Substring(2).Replace('\', '/') + $linuxWorkspace = "/mnt/$drive$path" wsl --distribution Ubuntu --user root -- bash -lc "set -eu; cd '$linuxWorkspace'; bash tests/validate.sh; python3 --version" From 5339d792a6debd6092d3c075cc14b3a38d8c4b9d Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:05:47 -0700 Subject: [PATCH 4/4] Normalize WSL shell script line endings --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9e18265..2cb3a05 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -126,4 +126,4 @@ jobs: $drive = $env:GITHUB_WORKSPACE.Substring(0, 1).ToLowerInvariant() $path = $env:GITHUB_WORKSPACE.Substring(2).Replace('\', '/') $linuxWorkspace = "/mnt/$drive$path" - wsl --distribution Ubuntu --user root -- bash -lc "set -eu; cd '$linuxWorkspace'; bash tests/validate.sh; python3 --version" + wsl --distribution Ubuntu --user root -- bash -lc "set -eu; cd '$linuxWorkspace'; sed -i 's/\r$//' tests/validate.sh; bash tests/validate.sh; python3 --version"