diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e36c6a..ccd53eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,10 +42,10 @@ jobs: python-version: ["3.10", "3.14"] steps: - name: Check out repository - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: ${{ matrix.python-version }} @@ -63,6 +63,9 @@ jobs: - name: Run skill source checks run: python3 scripts/validate.py + - name: Test CI policy validation + run: python3 scripts/test-validate-ci.py + - name: Test payload sync behavior run: python3 scripts/test-sync-payload.py @@ -78,7 +81,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Verify referenced URLs run: python3 scripts/verify-urls.py diff --git a/README.md b/README.md index 1067c85..9db14fd 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,7 @@ use portable paths and client-neutral operations. python3 .github/scripts/check-portability.py python3 scripts/validate-ci.py python3 scripts/validate.py +python3 scripts/test-validate-ci.py python3 scripts/verify-urls.py python3 scripts/test-sync-payload.py bash scripts/sync-payload.sh --ci @@ -230,6 +231,7 @@ python-project-workflow/ ├── scripts/ # Repository maintenance and validation tools │ ├── payload-manifest.json # Declares canonical files copied into the payload │ ├── sync-payload.sh # Synchronizes or checks the runtime payload mirror +│ ├── test-validate-ci.py # Regression tests for CI policy enforcement │ ├── test-sync-payload.py # Regression tests for payload drift behavior │ ├── validate-ci.py # Enforces CI routing and toolchain policy │ ├── validate.py # Checks skill structure, metadata, and references diff --git a/references/lint-format-typing-testing.md b/references/lint-format-typing-testing.md index 618aff9..bdd7355 100644 --- a/references/lint-format-typing-testing.md +++ b/references/lint-format-typing-testing.md @@ -8,39 +8,41 @@ adopting strictness incrementally. Assuming you have `uv` installed and a `pyproject.toml` with the modern baseline. ```bash -# Synchronize the virtual environment and install dependencies (including dev) -python -m uv sync +# Synchronize the virtual environment and install the default dev dependency group +uv sync # Check code style and linting (Ruff) -python -m ruff check . +uv run ruff check . # Check formatting (Ruff formatter) -python -m ruff format --check . +uv run ruff format --check . # Type checking (MyPy) -python -m mypy . +uv run mypy . # Run tests (pytest) -python -m pytest +uv run pytest # Build the package (if packaging metadata was touched) -python -m build +uv build ``` If `uv` is unavailable, create and activate a virtual environment using the -platform-appropriate activation command, then install the project and its dev -extra explicitly: +platform-appropriate activation command, then install the project and its +development tools explicitly: ```bash python -m venv .venv # Activate .venv for the current shell, then run: python -m pip install --upgrade pip -python -m pip install -e ".[dev]" +python -m pip install -e . +python -m pip install pytest ruff mypy build ``` -If the project does not define a `dev` extra, install its project-native -development requirements instead. The Ruff, mypy, pytest, and build commands -above remain unchanged. +If the project defines a project-native development extra or requirements file, +install that instead of the generic tool list. After activation, run Ruff, mypy, +pytest, and build through `python -m`; the `uv run` commands above apply only to +the uv-managed environment. ## Cross-Platform Testing Considerations diff --git a/references/pyproject-template.md b/references/pyproject-template.md index 2f1c696..af91977 100644 --- a/references/pyproject-template.md +++ b/references/pyproject-template.md @@ -42,8 +42,8 @@ classifiers = [ dependencies = [ # Example: "requests>=2.28.0", ] -# Optional dependencies (e.g., for testing, docs) -[project.optional-dependencies] +# Local development dependencies (PEP 735); uv sync includes dev by default +[dependency-groups] dev = [ "pytest", "ruff", diff --git a/scripts/sync-payload.sh b/scripts/sync-payload.sh index 84007ff..1063847 100644 --- a/scripts/sync-payload.sh +++ b/scripts/sync-payload.sh @@ -72,25 +72,27 @@ sync_file() { local label="$3" local mode="${4:-644}" - if [ ! -f "$source" ]; then - echo " MISSING source: $label" + if [ -L "$source" ] || [ ! -f "$source" ]; then + echo " INVALID source (must be a regular file): $label" DRIFT=true SYNC_ERROR=true return fi if $CI_MODE; then - if [ ! -f "$target" ] || ! cmp -s "$source" "$target" || ! mode_matches "$target" "$mode"; then + if [ -L "$target" ] || [ ! -f "$target" ] || ! cmp -s "$source" "$target" || ! mode_matches "$target" "$mode"; then echo " DRIFTED: $label" DRIFT=true fi return fi - if [ ! -f "$target" ] || ! cmp -s "$source" "$target" || ! mode_matches "$target" "$mode"; then + if [ -L "$target" ] || [ ! -f "$target" ] || ! cmp -s "$source" "$target" || ! mode_matches "$target" "$mode"; then CHANGED=true fi mkdir -p "$(dirname "$target")" + # Never let install follow a payload symlink outside the shipping boundary. + [ ! -L "$target" ] || rm -f "$target" install -m "$mode" "$source" "$target" } @@ -154,7 +156,15 @@ while IFS= read -r -d '' f; do relpath="$f" # shellcheck disable=SC2295 rel="${relpath#$PAYLOAD_DIR/}" - if ! is_covered "$rel"; then + if [ -L "$f" ]; then + echo " SYMLINKED: $rel" + if ! $CI_MODE; then + rm -f "$f" + CHANGED=true + else + DRIFT=true + fi + elif ! is_covered "$rel"; then echo " ORPHANED: $rel" if ! $CI_MODE; then rm -f "$f" @@ -163,7 +173,7 @@ while IFS= read -r -d '' f; do DRIFT=true fi fi -done < <(find "$PAYLOAD_DIR" -type f -print0) +done < <(find "$PAYLOAD_DIR" \( -type f -o -type l \) -print0) if ! $CI_MODE; then find "$PAYLOAD_DIR" -type d -empty -delete 2>/dev/null || true fi diff --git a/scripts/test-sync-payload.py b/scripts/test-sync-payload.py index 1a2c180..3a8800e 100644 --- a/scripts/test-sync-payload.py +++ b/scripts/test-sync-payload.py @@ -85,7 +85,47 @@ def main() -> int: if target.stat().st_mode & 0o111: raise AssertionError("normal sync did not repair payload permissions") - print("PASS: payload CI mode detects content, orphan, and permission drift") + target.unlink() + target.symlink_to(fixture / "references/core-footguns.md") + orphan.symlink_to(fixture / "scripts/validate.py") + + link_check = run_sync(fixture, "--ci") + if link_check.returncode == 0: + raise AssertionError("--ci accepted symlinked payload entries") + if not target.is_symlink() or not orphan.is_symlink(): + raise AssertionError("--ci modified symlinked payload entries") + if "SYMLINKED: references/core-footguns.md" not in link_check.stdout: + raise AssertionError( + f"--ci did not report the declared symlink:\n{link_check.stdout}" + ) + if "SYMLINKED: references/orphan.md" not in link_check.stdout: + raise AssertionError( + f"--ci did not report the orphan symlink:\n{link_check.stdout}" + ) + + link_sync = run_sync(fixture) + if link_sync.returncode != 0: + raise AssertionError(f"normal sync failed to repair links:\n{link_sync.stdout}") + if target.is_symlink() or target.read_bytes() != source.read_bytes(): + raise AssertionError("normal sync did not replace the declared symlink") + if orphan.exists() or orphan.is_symlink(): + raise AssertionError("normal sync did not remove the orphan symlink") + + source.unlink() + source.symlink_to(target) + source_link_check = run_sync(fixture, "--ci") + if source_link_check.returncode == 0: + raise AssertionError("--ci accepted a symlinked canonical source") + invalid_source = ( + "INVALID source (must be a regular file): references/core-footguns.md" + ) + if invalid_source not in source_link_check.stdout: + raise AssertionError( + "--ci did not report the symlinked canonical source:\n" + f"{source_link_check.stdout}" + ) + + print("PASS: payload CI mode detects content, orphan, permission, and symlink drift") return 0 diff --git a/scripts/test-validate-ci.py b/scripts/test-validate-ci.py new file mode 100644 index 0000000..48e54fd --- /dev/null +++ b/scripts/test-validate-ci.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Regression tests for CI policy validation.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import ModuleType + + +ROOT = Path(__file__).resolve().parents[1] +VALIDATOR = ROOT / "scripts/validate-ci.py" +WORKFLOW = ROOT / ".github/workflows/ci.yml" + + +def load_validator() -> ModuleType: + spec = importlib.util.spec_from_file_location("validate_ci", VALIDATOR) + if spec is None or spec.loader is None: + raise AssertionError(f"could not load {VALIDATOR}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def assert_rejected(module: ModuleType, workflow: str, label: str) -> None: + if not module.validate_workflow(workflow): + raise AssertionError(f"CI validator accepted {label}") + + +def main() -> int: + module = load_validator() + workflow = WORKFLOW.read_text(encoding="utf-8") + errors = module.validate_workflow(workflow) + if errors: + raise AssertionError(f"current workflow failed validation: {errors}") + + for command in module.REQUIRED_VALIDATE_COMMANDS: + assert_rejected( + module, + workflow.replace(f"run: {command}", f"# run: {command}", 1), + f"commented critical command {command!r}", + ) + + assert_rejected( + module, + workflow.replace('- ".gitignore"', '# - ".gitignore"'), + "commented .gitignore path filters", + ) + assert_rejected( + module, + workflow.replace("actions/checkout@", "actions/checkout@v5 # ", 1), + "mutable action tag", + ) + assert_rejected( + module, + workflow.replace( + "run: python3 scripts/validate.py", + "run: python3 ./scripts/verify-urls.py\n\n - run: python3 scripts/validate.py", + 1, + ), + "live URL check in validation matrix", + ) + + print("PASS: CI policy validator rejects missing gates and mutable action pins") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate-ci.py b/scripts/validate-ci.py index 5751d4e..dee2c68 100644 --- a/scripts/validate-ci.py +++ b/scripts/validate-ci.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Validate CI routing contracts that keep push and PR checks deterministic.""" +"""Validate CI routing contracts that keep checks deterministic and complete.""" from __future__ import annotations @@ -10,9 +10,19 @@ ROOT = Path(__file__).resolve().parents[1] WORKFLOW = ROOT / ".github/workflows/ci.yml" +SHA_PIN_RE = re.compile(r"^[^@\s]+@[0-9a-f]{40}$") +REQUIRED_VALIDATE_COMMANDS = ( + "python3 .github/scripts/check-portability.py", + "python3 scripts/validate-ci.py", + "python3 scripts/validate.py", + "python3 scripts/test-validate-ci.py", + "python3 scripts/test-sync-payload.py", + "bash scripts/sync-payload.sh --ci", + "python3 -m ruff check scripts .github/scripts", +) -def job_body(workflow: str, name: str) -> str | None: +def section_body(workflow: str, name: str) -> str | None: match = re.search( rf"(?ms)^ {re.escape(name)}:\n(?P.*?)(?=^ [A-Za-z0-9_-]+:\n|\Z)", workflow, @@ -20,27 +30,51 @@ def job_body(workflow: str, name: str) -> str | None: return match.group("body") if match else None -def main() -> int: - workflow = WORKFLOW.read_text(encoding="utf-8") +def active_workflow_lines(workflow: str) -> str: + """Remove comment-only lines before applying policy checks.""" + return "\n".join( + line for line in workflow.splitlines() if not line.lstrip().startswith("#") + ) + + +def has_run_command(body: str, command: str) -> bool: + return bool( + re.search( + rf"(?m)^\s*run:\s*{re.escape(command)}\s*(?:#.*)?$", + body, + ) + ) + + +def validate_workflow(workflow: str) -> list[str]: + active = active_workflow_lines(workflow) errors: list[str] = [] - validate = job_body(workflow, "validate") - external = job_body(workflow, "verify-urls") + for event_name in ("push", "pull_request"): + event = section_body(active, event_name) + if event is None: + errors.append(f"ci.yml: missing {event_name} event") + elif not re.search(r'(?m)^\s+-\s+["\']?\.gitignore["\']?\s*$', event): + errors.append( + f"ci.yml: {event_name} workflow must trigger on .gitignore changes" + ) + + validate = section_body(active, "validate") + external = section_body(active, "verify-urls") if validate is None: errors.append("ci.yml: missing validate job") else: - if '".gitignore"' not in workflow: - errors.append("ci.yml: validation workflow must trigger on .gitignore changes") - if "python3 scripts/verify-urls.py" in validate: + if "verify-urls.py" in validate: errors.append( "ci.yml: live URL checks must not run in the validation matrix" ) - if "python3 scripts/validate-ci.py" not in validate: - errors.append("ci.yml: validation matrix must run the CI policy check") + for command in REQUIRED_VALIDATE_COMMANDS: + if not has_run_command(validate, command): + errors.append( + f"ci.yml: validation matrix missing run command {command!r}" + ) if "python -m pip install ruff==0.12.4" not in validate: errors.append("ci.yml: validation matrix must install the pinned Ruff version") - if "python3 -m ruff check scripts .github/scripts" not in validate: - errors.append("ci.yml: Ruff must check scripts and .github/scripts") if 'python-version: ["3.10", "3.14"]' not in validate: errors.append( "ci.yml: Python matrix must test the advertised 3.10 lower bound and 3.14 stable boundary" @@ -52,23 +86,40 @@ def main() -> int: required = ( "if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'", "runs-on: ubuntu-latest", - "run: python3 scripts/verify-urls.py", ) for line in required: if line not in external: errors.append(f"ci.yml: verify-urls job missing {line!r}") + if not has_run_command(external, "python3 scripts/verify-urls.py"): + errors.append("ci.yml: verify-urls job missing its URL verifier command") if "matrix:" in external: errors.append("ci.yml: verify-urls job must not use a matrix") - if workflow.count("run: python3 scripts/verify-urls.py") != 1: + if active.count("scripts/verify-urls.py") != 1: errors.append("ci.yml: URL verifier must appear exactly once") + uses = re.findall(r"(?m)^\s*(?:-\s+)?uses:\s*([^#\s]+)", active) + if not uses: + errors.append("ci.yml: workflow must declare its external actions explicitly") + for reference in uses: + if reference.startswith("./"): + continue + if not SHA_PIN_RE.fullmatch(reference): + errors.append( + f"ci.yml: action reference must use a full commit SHA: {reference}" + ) + + return errors + + +def main() -> int: + errors = validate_workflow(WORKFLOW.read_text(encoding="utf-8")) if errors: for error in errors: print(error, file=sys.stderr) return 1 - print("PASS: live URL verification is isolated from push and pull-request CI") + print("PASS: CI validation gates, routing, and action pins are valid") return 0 diff --git a/scripts/validate.py b/scripts/validate.py index 6bc2117..ac608ef 100755 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -29,6 +29,14 @@ "## Verification Commands", "## Preserve Local Conventions", } +UV_COMMANDS = ( + "uv sync", + "uv run ruff check .", + "uv run ruff format --check .", + "uv run mypy .", + "uv run pytest", + "uv build", +) def rel(path: Path) -> str: @@ -46,6 +54,8 @@ def fail(message: str, *, hint: str | None = None) -> None: def read_text_checked(path: Path) -> str: + if path.is_symlink(): + fail(f"required file must not be a symlink: {rel(path)}") try: return path.read_text(encoding="utf-8") except FileNotFoundError: @@ -156,6 +166,12 @@ def check_skill() -> None: if not contains_markdown_phrase(body, directive): fail(f"{rel(SKILL)} missing Python 3.8 EOL policy: {directive}") + for command in UV_COMMANDS: + if command not in body: + fail(f"{rel(SKILL)} missing uv-managed verification command: {command}") + if "python -m uv" in body: + fail(f"{rel(SKILL)} must invoke the uv executable directly") + for missing_ref in ["packaging.md", "errors-and-logging.md", "cli.md", "migration-existing-code.md"]: if f"`{missing_ref}` (deferred)" in body: fail( @@ -189,9 +205,23 @@ def check_references() -> None: for obsolete in ('license = {text = "MIT"}', '"License :: OSI Approved :: MIT License"'): if obsolete in pyproject: fail(f"pyproject template contains deprecated license metadata: {obsolete}") - for required in ('license = "MIT"', 'license-files = ["LICENSE*"]', 'setuptools>=77.0.3'): + for required in ( + 'license = "MIT"', + 'license-files = ["LICENSE*"]', + "setuptools>=77.0.3", + "[dependency-groups]", + ): if required not in pyproject: fail(f"pyproject template missing current packaging metadata: {required}") + if "[project.optional-dependencies]\ndev = [" in pyproject: + fail("pyproject template must declare local dev tools as a dependency group") + + tooling = read_text_checked(REF_DIR / "lint-format-typing-testing.md") + for command in UV_COMMANDS: + if command not in tooling: + fail(f"tooling reference missing uv-managed command: {command}") + if "python -m uv" in tooling: + fail("tooling reference must invoke the uv executable directly") drift = read_text_checked(REF_DIR / "drift-classes.md") if "Edit source (root `SKILL.md`" in drift: diff --git a/skills/python-project-workflow/SKILL.md b/skills/python-project-workflow/SKILL.md index 9417e8c..18a8922 100644 --- a/skills/python-project-workflow/SKILL.md +++ b/skills/python-project-workflow/SKILL.md @@ -59,13 +59,17 @@ On load, classify the project to load only relevant guidance: | Signal | Classification | Load | |--------|---------------|------| -| No pyproject.toml, no setup.py, no tests directory | Greenfield | `pyproject-template.md`, then Orientation Checklist below | +| Empty/new directory, or an explicit request for a new scaffold, with no established source or tooling conventions | Greenfield | `pyproject-template.md`, then Orientation Checklist below | | pyproject.toml or setup.py present, coherent tooling | Existing | Orientation Checklist below, then tool-specific guidance | | Python only in scripts/, no packaging metadata, governance scripts | Automation / mature | `mature-repo-preservation.md`, skip packaging refs | | Eval/benchmark runners present | Automation with benchmarks | Also load `eval-benchmark-hardening.md` | **Support-code heuristic:** if all Python files are under `scripts/`, there are zero `.py` files in the repo root besides `__init__.py` stubs, and there is no `src/` layout, assume support code. The skill's `mature-repo-preservation.md` reference covers this case. +Apply the Existing and Automation rows before inferring Greenfield. Missing packaging metadata or a tests directory does +not make a repository greenfield when it already contains meaningful source, scripts, documentation, or project history. +Only classify such a repository as Greenfield when the user explicitly requests a new scaffold. + When in doubt, load only `mature-repo-preservation.md` — the smallest reference. You can always load more later. ## Orientation Checklist @@ -253,23 +257,23 @@ Freshness pitfall: ad-hoc verification is scoped to the files and behavior it ch For ordinary Python projects: ```bash -# Synchronize the virtual environment and install dependencies (including dev) -python -m uv sync +# Synchronize the virtual environment and install the default dev dependency group +uv sync # Check code style and linting (Ruff) -python -m ruff check . +uv run ruff check . # Check formatting (Ruff formatter) -python -m ruff format --check . +uv run ruff format --check . # Type checking (MyPy) -python -m mypy . +uv run mypy . # Run tests (pytest) -python -m pytest +uv run pytest # Build the package (if packaging metadata was touched) -python -m build +uv build ``` ## Reporting diff --git a/skills/python-project-workflow/references/lint-format-typing-testing.md b/skills/python-project-workflow/references/lint-format-typing-testing.md index 618aff9..bdd7355 100644 --- a/skills/python-project-workflow/references/lint-format-typing-testing.md +++ b/skills/python-project-workflow/references/lint-format-typing-testing.md @@ -8,39 +8,41 @@ adopting strictness incrementally. Assuming you have `uv` installed and a `pyproject.toml` with the modern baseline. ```bash -# Synchronize the virtual environment and install dependencies (including dev) -python -m uv sync +# Synchronize the virtual environment and install the default dev dependency group +uv sync # Check code style and linting (Ruff) -python -m ruff check . +uv run ruff check . # Check formatting (Ruff formatter) -python -m ruff format --check . +uv run ruff format --check . # Type checking (MyPy) -python -m mypy . +uv run mypy . # Run tests (pytest) -python -m pytest +uv run pytest # Build the package (if packaging metadata was touched) -python -m build +uv build ``` If `uv` is unavailable, create and activate a virtual environment using the -platform-appropriate activation command, then install the project and its dev -extra explicitly: +platform-appropriate activation command, then install the project and its +development tools explicitly: ```bash python -m venv .venv # Activate .venv for the current shell, then run: python -m pip install --upgrade pip -python -m pip install -e ".[dev]" +python -m pip install -e . +python -m pip install pytest ruff mypy build ``` -If the project does not define a `dev` extra, install its project-native -development requirements instead. The Ruff, mypy, pytest, and build commands -above remain unchanged. +If the project defines a project-native development extra or requirements file, +install that instead of the generic tool list. After activation, run Ruff, mypy, +pytest, and build through `python -m`; the `uv run` commands above apply only to +the uv-managed environment. ## Cross-Platform Testing Considerations diff --git a/skills/python-project-workflow/references/pyproject-template.md b/skills/python-project-workflow/references/pyproject-template.md index 2f1c696..af91977 100644 --- a/skills/python-project-workflow/references/pyproject-template.md +++ b/skills/python-project-workflow/references/pyproject-template.md @@ -42,8 +42,8 @@ classifiers = [ dependencies = [ # Example: "requests>=2.28.0", ] -# Optional dependencies (e.g., for testing, docs) -[project.optional-dependencies] +# Local development dependencies (PEP 735); uv sync includes dev by default +[dependency-groups] dev = [ "pytest", "ruff",