Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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]
Expand Down
55 changes: 53 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,30 +13,63 @@ 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
run: ./tests/validate.sh
- 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
run: python -m pytest
- 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
Expand Down Expand Up @@ -76,3 +109,21 @@ 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: |
$distros = (wsl --list --quiet 2>$null | Out-String)
if ($distros -notmatch "Ubuntu") {
wsl --install --distribution Ubuntu --no-launch
}
$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'; sed -i 's/\r$//' tests/validate.sh; bash tests/validate.sh; python3 --version"
19 changes: 19 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -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
16 changes: 12 additions & 4 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions lib/python/base_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ def _resolve_version() -> str:
"dumps_record",
"dumps_records",
"error_envelope",
"extensions",
"history",
"integrations",
"inspection_envelope",
Expand Down
2 changes: 1 addition & 1 deletion lib/python/base_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"]
57 changes: 57 additions & 0 deletions scripts/validate_docs.py
Original file line number Diff line number Diff line change
@@ -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()
50 changes: 50 additions & 0 deletions scripts/validate_installed_package.py
Original file line number Diff line number Diff line change
@@ -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()
Loading